package handlers

// HTTP surface for the SmartTimeline (t-paliad-171, design doc
// docs/design-smart-timeline-2026-05-08.md). Two endpoints:
//
//   GET  /api/projects/{id}/timeline           — read the merged timeline
//   POST /api/projects/{id}/timeline/milestone — write a custom milestone
//
// Both go through ProjectionService, which delegates visibility + RLS
// to DeadlineService / AppointmentService and enforces the project_events
// gate inline. No new RLS surface here.

import (
	"encoding/json"
	"net/http"
	"strings"
	"time"

	"github.com/google/uuid"

	"mgit.msbls.de/m/paliad/internal/services"
)


// GET /api/projects/{id}/timeline
//
// Query parameters:
//
//	?include=audit_full   — when present, project_events are returned
//	                        without the timeline_kind filter (legacy
//	                        Verlauf chronological view, behind the
//	                        "Audit-Log anzeigen" toggle).
//	?direct_only=1|true   — narrow to events whose project_id exactly
//	                        matches; default is project + descendants.
func handleGetProjectTimeline(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if dbSvc.projection == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"error": "projection service unavailable",
		})
		return
	}
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}
	q := r.URL.Query()
	opts := services.ProjectionOpts{
		IncludeAuditFull: q.Get("include") == "audit_full",
		DirectOnly:       parseDirectOnly(q.Get("direct_only")),
		LookaheadCap:     parseLookahead(q.Get("lookahead")),
		Lang:             q.Get("lang"),
	}
	rows, meta, err := dbSvc.projection.For(r.Context(), uid, id, opts)
	if err != nil {
		writeServiceError(w, err)
		return
	}
	// Always return [], never null — the frontend reads .length on the
	// result and would crash on a JSON null.
	if rows == nil {
		rows = []services.TimelineEvent{}
	}
	lanes := meta.Lanes
	if lanes == nil {
		lanes = []services.LaneInfo{}
	}
	// Surface projection meta via headers — Slice 1-3 frontends still
	// read X-Projection-Total / Lookahead / Tracks for the lookahead
	// toggle and Track chip.
	w.Header().Set("X-Projection-Has", boolStr(meta.HasProjection))
	w.Header().Set("X-Projection-Total", itoa(meta.ProjectedTotal))
	w.Header().Set("X-Projection-Shown", itoa(meta.ProjectedShown))
	w.Header().Set("X-Projection-Overdue", itoa(meta.PredictedOverdue))
	w.Header().Set("X-Projection-Lookahead", itoa(meta.Lookahead))
	if len(meta.AvailableTracks) > 0 {
		// Comma-separated list of track tags ("parent", "counterclaim:<id>",
		// "parent_context:<id>"). Track ids are UUIDs — safe in headers.
		w.Header().Set("X-Projection-Tracks", strings.Join(meta.AvailableTracks, ","))
	}
	// Slice 4 changed the wire shape from []TimelineEvent to an envelope
	// {events, lanes} so lane metadata can ride alongside the rows
	// without exceeding header-size limits when a Client-level
	// projection has many lanes. The frontend reads .events for the
	// per-row contract and .lanes for parallel-column rendering.
	writeJSON(w, http.StatusOK, services.ResponseEnvelope{
		Events: rows,
		Lanes:  lanes,
	})
}

// GET /api/projects/{id}/timeline.ics
//
// t-paliad-177 Slice 2 — iCal feed export. Returns a VCALENDAR with one
// VEVENT per deadline + appointment row (faraday-Q6: NO projected — a
// calendar feed must never carry predicted dates the user never
// confirmed). Reuses the formatter from caldav_ical.go so future
// CalDAV sync work and chart exports share one source of truth.
//
// Visibility piggybacks on ProjectionService.For (same gate as
// /timeline). Project title is fetched via ProjectService.GetByID and
// passed as the X-WR-CALNAME for Outlook / Apple Calendar display.
func handleGetProjectTimelineICS(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if dbSvc.projection == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"error": "projection service unavailable",
		})
		return
	}
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}
	rows, _, err := dbSvc.projection.For(r.Context(), uid, id, services.ProjectionOpts{})
	if err != nil {
		writeServiceError(w, err)
		return
	}
	proj, err := dbSvc.projects.GetByID(r.Context(), uid, id)
	if err != nil {
		writeServiceError(w, err)
		return
	}
	body := services.FormatTimelineICS(rows, proj.Title)
	w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
	// Sanitise the project title for the filename — RFC-7230 disallows
	// many bytes in header values, and Outlook truncates non-ASCII
	// disposition filenames inconsistently. ASCII slug + date is portable.
	w.Header().Set(
		"Content-Disposition",
		`attachment; filename="paliad-`+filenameSlug(proj.Title)+`-`+
			time.Now().UTC().Format("2006-01-02")+`.ics"`,
	)
	_, _ = w.Write([]byte(body))
}

func filenameSlug(s string) string {
	if s == "" {
		return "timeline"
	}
	out := make([]byte, 0, len(s))
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch {
		case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '.':
			out = append(out, c)
		default:
			if len(out) > 0 && out[len(out)-1] != '_' {
				out = append(out, '_')
			}
		}
	}
	for len(out) > 0 && (out[0] == '_' || out[len(out)-1] == '_') {
		if out[0] == '_' {
			out = out[1:]
		} else {
			out = out[:len(out)-1]
		}
	}
	if len(out) > 60 {
		out = out[:60]
	}
	if len(out) == 0 {
		return "timeline"
	}
	return string(out)
}

// POST /api/projects/{id}/timeline/anchor
//
// Body: {"rule_code":"inf.sod","actual_date":"2026-08-31","kind":"deadline"}
//
// 200 → AnchorResult JSON.
// 409 → predecessor_missing payload (m/paliad#31 layer 3 sequence guard).
//       The frontend renders the message in the active language as an
//       inline error and offers a "Stattdessen <predecessor> erfassen"
//       link that pre-fills the editor for the parent rule.
func handleProjectTimelineAnchor(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if dbSvc.projection == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"error": "projection service unavailable",
		})
		return
	}
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}

	var body struct {
		RuleCode   string `json:"rule_code"`
		ActualDate string `json:"actual_date"`
		Kind       string `json:"kind,omitempty"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}
	d, err := time.Parse("2006-01-02", body.ActualDate)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid actual_date — expected YYYY-MM-DD",
		})
		return
	}

	res, err := dbSvc.projection.RecordAnchor(r.Context(), uid, id, services.AnchorInput{
		RuleCode:   body.RuleCode,
		ActualDate: d,
		Kind:       body.Kind,
	})
	if err != nil {
		if pme, ok := services.IsPredecessorMissing(err); ok {
			writeJSON(w, http.StatusConflict, map[string]any{
				"error":                 "predecessor_missing",
				"missing_rule_code":     pme.MissingRuleCode,
				"missing_rule_name_de":  pme.MissingRuleNameDE,
				"missing_rule_name_en":  pme.MissingRuleNameEN,
				"requested_rule_code":   pme.RequestedRuleCode,
				"requested_rule_name_de": pme.RequestedRuleNameDE,
				"requested_rule_name_en": pme.RequestedRuleNameEN,
				"message_de": "Bitte zuerst „" + pme.MissingRuleNameDE +
					"“ (" + pme.MissingRuleCode + ") erfassen — daraus folgt die Frist „" +
					pme.RequestedRuleNameDE + "“.",
				"message_en": "Anchor „" + pme.MissingRuleNameEN +
					"“ (" + pme.MissingRuleCode + ") first — „" +
					pme.RequestedRuleNameEN + "“ flows from it.",
			})
			return
		}
		writeServiceError(w, err)
		return
	}
	out := map[string]any{"updated": res.Updated}
	if res.DeadlineID != nil {
		out["deadline_id"] = res.DeadlineID.String()
		out["kind"] = "deadline"
	}
	if res.AppointmentID != nil {
		out["appointment_id"] = res.AppointmentID.String()
		out["kind"] = "appointment"
	}
	writeJSON(w, http.StatusOK, out)
}

// POST /api/projects/{id}/timeline/skip
//
// Body: {"rule_code":"inf.prelim","reason":"Beklagter hat keinen PO eingelegt"}
//
// Marks the rule as "ist nicht eingetreten / wurde verschoben" — the
// projected row drops out of future reads until the user clears the
// rule_skipped event (admin / audit-log path).
func handleProjectTimelineSkip(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if dbSvc.projection == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"error": "projection service unavailable",
		})
		return
	}
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}
	var body struct {
		RuleCode string `json:"rule_code"`
		Reason   string `json:"reason,omitempty"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}
	if err := dbSvc.projection.RecordRuleSkipped(r.Context(), uid, id, body.RuleCode, body.Reason); err != nil {
		writeServiceError(w, err)
		return
	}
	writeJSON(w, http.StatusNoContent, nil)
}

// parseLookahead reads the ?lookahead=N query parameter; clamps to
// [1, MaxLookaheadCap] in the service. Returns 0 to mean "default" when
// the parameter is missing or malformed.
func parseLookahead(s string) int {
	if s == "" {
		return 0
	}
	n := 0
	for _, c := range s {
		if c < '0' || c > '9' {
			return 0
		}
		n = n*10 + int(c-'0')
		if n > 1000 {
			return 1000
		}
	}
	return n
}

func boolStr(b bool) string {
	if b {
		return "true"
	}
	return "false"
}

func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	neg := n < 0
	if neg {
		n = -n
	}
	var buf [20]byte
	i := len(buf)
	for n > 0 {
		i--
		buf[i] = byte('0' + n%10)
		n /= 10
	}
	if neg {
		i--
		buf[i] = '-'
	}
	return string(buf[i:])
}

// POST /api/projects/{id}/counterclaim
//
// Body: {
//   "proceeding_type_id": 9,        // optional, defaults to upc.rev.cfi
//   "flip_our_side": false,          // optional, default-flip otherwise
//   "title": "EP3456789 — Widerklage (CCR)",  // optional, auto-suggested
//   "case_number": "ACT_xxx_2026"    // optional CCR case number
// }
//
// Creates the CCR sub-project, writes audit rows on parent + child,
// returns the new project's id + canonical URL.
func handleCreateProjectCounterclaim(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	parentID, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}

	var body struct {
		ProceedingTypeID *int    `json:"proceeding_type_id,omitempty"`
		FlipOurSide      *bool   `json:"flip_our_side,omitempty"`
		Title            *string `json:"title,omitempty"`
		CaseNumber       *string `json:"case_number,omitempty"`
	}
	// Empty body is fine — full default behaviour.
	if r.ContentLength > 0 {
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
			return
		}
	}

	opts := services.CounterclaimOpts{
		ProceedingTypeID: body.ProceedingTypeID,
		FlipOurSide:      body.FlipOurSide,
		Title:            body.Title,
		CaseNumber:       body.CaseNumber,
	}
	child, err := dbSvc.projects.CreateCounterclaim(r.Context(), uid, parentID, opts)
	if err != nil {
		writeServiceError(w, err)
		return
	}
	writeJSON(w, http.StatusCreated, map[string]any{
		"id":               child.ID,
		"url":              "/projects/" + child.ID.String(),
		"counterclaim_of":  child.CounterclaimOf,
		"parent_id":        child.ParentID,
		"title":            child.Title,
		"our_side":         child.OurSide,
		"proceeding_type":  child.ProceedingTypeID,
		"case_number":      child.CaseNumber,
	})
}

// POST /api/projects/{id}/timeline/milestone
//
// Body shape: {"title": "...", "description": "...", "occurred_at": "YYYY-MM-DD"}
//
// Writes a paliad.project_events row with event_type='custom_milestone'
// and timeline_kind='custom_milestone'. Returns the resulting
// TimelineEvent so the caller can append it without a re-fetch.
func handleCreateProjectTimelineMilestone(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if dbSvc.projection == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"error": "projection service unavailable",
		})
		return
	}
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}

	var body struct {
		Title       string  `json:"title"`
		Description *string `json:"description,omitempty"`
		OccurredAt  *string `json:"occurred_at,omitempty"`
		BubbleUp    bool    `json:"bubble_up,omitempty"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}

	var occurred *time.Time
	if body.OccurredAt != nil && *body.OccurredAt != "" {
		t, err := time.Parse("2006-01-02", *body.OccurredAt)
		if err != nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{
				"error": "invalid occurred_at — expected YYYY-MM-DD",
			})
			return
		}
		occurred = &t
	}

	ev, err := dbSvc.projection.RecordCustomMilestone(r.Context(), uid, id,
		body.Title, body.Description, occurred, body.BubbleUp)
	if err != nil {
		writeServiceError(w, err)
		return
	}
	writeJSON(w, http.StatusCreated, ev)
}
