package services

// ProjectionService composes the SmartTimeline read view for a project —
// the merged stream of past actuals (deadlines + appointments + opted-in
// project_events) plus future projections from the fristenrechner.
//
// Slice 1 (t-paliad-171) returned only actuals. Slice 2 (t-paliad-173)
// adds:
//
//   - Future-projection rows (Kind="projected") via FristenrechnerService.
//   - 7-event lookahead cap with ?lookahead=N override (1..50).
//   - Predicted-overdue rows (past projected dates without an anchor)
//     bypass the cap and surface as Status="predicted_overdue".
//   - Dependency annotations (DependsOnRuleCode/Date/Name) on every row
//     derived from a deadline_rule with a parent_id.
//   - Anchor + skip write paths (RecordAnchor, RecordRuleSkipped).
//
// Slice 4 (t-paliad-175) adds parent-node lane aggregation (§5):
//
//   - levelPolicy(projectType) returns the (kinds, statuses, lane_axis)
//     triple per level — Case = full detail + CCR track; Patent = lanes
//     per child case (deadlines + milestones, done+open+overdue);
//     Litigation = lanes per child patent (milestones, done); Client =
//     lanes per child litigation (milestones, done; opt-in via toggle).
//   - Lanes []LaneInfo on the response envelope, LaneID on every event
//     row — frontend buckets by lane for parallel-column rendering.
//   - metadata.bubble_up=true on paliad.project_events overrides the
//     kind/status filter at higher levels so structural milestones
//     (counterclaim_created, third_party_intervention, scope_change,
//     opt-in custom_milestone) survive the aggregation cull.
//
// See docs/design-smart-timeline-2026-05-08.md §5 + §6 + §9 + §10
// and m/paliad#31 for the layered requirements.

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"

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

// DefaultLookaheadCap is the number of future projected rows surfaced by
// default. m/paliad#31: fixed across proceeding types in v1; "Mehr
// anzeigen" / "Weniger anzeigen" buttons control client-side override
// via the ?lookahead=N query parameter.
const DefaultLookaheadCap = 7

// ErrCyclicSpawn signals that the cross-proceeding spawn graph has a
// cycle reachable from a project's source proceeding (design §6.3,
// Slice 7 t-paliad-188). Surfaced when the visited-set DFS in
// expandCrossProceedingSpawns hits a proceeding_type_id already in the
// chain. ProjectionService.computeProjections degrades to "no spawned
// rows" rather than failing the whole SmartTimeline render.
var ErrCyclicSpawn = errors.New("cyclic cross-proceeding spawn")

// maxSpawnDepth caps recursive spawn expansion as a safety belt in
// addition to the visited-set guard. No legitimate spawn graph today
// reaches depth 4 (the live corpus has 6 spawn rules across 3 source
// proceedings → AMD / APP / CCR — each one-hop). Bump if real-world
// chains demand it; until then the cap is a backstop.
const maxSpawnDepth = 4

// MaxLookaheadCap caps the ?lookahead override so a misbehaving client
// can't request thousands of projected rows.
const MaxLookaheadCap = 50

// TimelineEvent is one row in the SmartTimeline merge. The struct is
// the wire contract of GET /api/projects/{id}/timeline; new slices
// extend it additively (new fields, never rename / repurpose).
//
// Provenance fields (DeadlineID, AppointmentID, ProjectEventID) — exactly
// one is non-nil for actual rows. All three are nil for Kind="projected"
// rows. Frontend deep-links via the populated id; projected rows expose
// DeadlineRuleID for the click-to-anchor affordance.
type TimelineEvent struct {
	Kind   string `json:"kind"`   // "deadline" | "appointment" | "milestone" | "projected"
	Status string `json:"status"` // "done" | "open" | "overdue" | "court_set" | "predicted" | "predicted_overdue" | "off_script"
	Track  string `json:"track"`  // "parent" | "counterclaim" | "child:<project_id>" | "off_script"

	// Date is nil for undated rows (court-set decisions, counterclaim-pending
	// milestones). Undated rows sort to the end.
	Date *time.Time `json:"date,omitempty"`

	Title       string `json:"title"`
	Description string `json:"description,omitempty"`
	RuleCode    string `json:"rule_code,omitempty"`

	DeadlineID     *uuid.UUID `json:"deadline_id,omitempty"`
	AppointmentID  *uuid.UUID `json:"appointment_id,omitempty"`
	ProjectEventID *uuid.UUID `json:"project_event_id,omitempty"`

	// Click-to-anchor handle (Slice 2). Populated on Kind="projected"
	// rows AND on actuals (Kind="deadline"/"appointment") that derive
	// from a deadline_rule, so the frontend can show the rule chip on
	// both alike. Party drives the side-of-the-table colour class.
	DeadlineRuleID    *uuid.UUID `json:"deadline_rule_id,omitempty"`
	DeadlineRuleParty string     `json:"deadline_rule_party,omitempty"`

	// Reserved for Slice 3 (counterclaim sub-projects); populated when
	// the row belongs to a child project rendered alongside the parent.
	SubProjectID    *uuid.UUID `json:"sub_project_id,omitempty"`
	SubProjectTitle string     `json:"sub_project_title,omitempty"`

	// Dependency annotations (m/paliad#31 layer 2). Populated on rows
	// derived from a deadline_rule with a non-NULL parent_id. The
	// frontend renders "Folgt aus: <Name> (<Date>)" footer plus the
	// "[Pfad anzeigen]" button when DependsOnRuleCode != "".
	//
	// DependsOnDate is nil when the parent has no anchored actual AND
	// no projection (e.g. court-set parent that hasn't fired yet) —
	// the UI renders "Datum offen" in that case.
	DependsOnRuleCode string     `json:"depends_on_rule_code,omitempty"`
	DependsOnDate     *time.Time `json:"depends_on_date,omitempty"`
	DependsOnRuleName string     `json:"depends_on_rule_name,omitempty"`

	// LaneID buckets the row into a parallel column at parent-node levels
	// (t-paliad-175 SmartTimeline Slice 4). At Case level, LaneID mirrors
	// Track ("self" for the parent track, "counterclaim:<id>" for CCR
	// children, "parent_context:<id>" for the CCR child's parent context).
	// At Patent / Litigation / Client levels, LaneID is the direct-child
	// project id under which this event originates — the frontend renders
	// one column per lane and groups rows by LaneID.
	LaneID string `json:"lane_id,omitempty"`

	// ProjectEventType carries the underlying paliad.project_events.event_type
	// for milestone rows (t-paliad-176). Empty for deadline / appointment /
	// projected rows. The FilterBar's project_event_kind chip narrows the
	// rendered list against this field; KnownProjectEventKinds in
	// internal/services/filter_spec.go is the canonical vocabulary.
	ProjectEventType string `json:"project_event_type,omitempty"`

	// BubbleUp signals that a project_event milestone is marked to
	// bubble up to higher-level SmartTimelines (t-paliad-175 §5.3 + §7.2).
	// Read from metadata.bubble_up on the underlying paliad.project_events
	// row. Default-on for structural milestones (counterclaim_created,
	// third_party_intervention, scope_change), default-off for
	// custom_milestone (user can override per entry via the form
	// checkbox). At parent-node levels, rows with BubbleUp=true survive
	// the levelPolicy kind/status filter unconditionally.
	BubbleUp bool `json:"bubble_up,omitempty"`
}

// LaneInfo describes one column in the parent-node aggregated view.
// Returned alongside []TimelineEvent so the frontend knows which lanes
// to render, with what label, in what order. The id is opaque to the
// frontend (it just groups events by ev.LaneID == lane.ID); ProjectID
// lets the lane sub-header link through to the underlying project page.
type LaneInfo struct {
	ID        string `json:"id"`
	Label     string `json:"label"`
	ProjectID string `json:"project_id,omitempty"`
	// Primary marks the "primary" lane at Litigation level — the most-
	// recently-active case per child patent (§5.1). Frontend can dim the
	// non-primary lanes or rank them lower. Empty at other levels.
	Primary bool `json:"primary,omitempty"`
}

// LevelPolicy is the (kinds, statuses, lane_axis) triple per project
// type returned by levelPolicy. The lane axis identifies which direct
// child type aggregates into lanes.
type LevelPolicy struct {
	// Kinds is the allowed event kinds at this level. Empty = all.
	Kinds []string
	// Statuses is the allowed event statuses at this level. Empty = all.
	Statuses []string
	// LaneAxis identifies the lane grouping rule:
	//
	//   "self_plus_ccr"      — Case level: one lane for self + one per
	//                          visible CCR sub-project.
	//   "child_case"         — Patent level: one lane per direct child
	//                          case (events come from each case subtree).
	//   "child_patent"       — Litigation level: one lane per direct child
	//                          patent (events from the primary case under
	//                          each patent).
	//   "child_litigation"   — Client level: one lane per direct child
	//                          litigation (events from each litigation
	//                          subtree).
	LaneAxis string
}

// ResponseEnvelope is the wire shape of GET /api/projects/{id}/timeline
// from Slice 4 onward. Slices 1-3 returned []TimelineEvent directly;
// adding lanes [] forced the envelope. Frontend reads .events to
// preserve the per-row contract and .lanes to drive lane-grouped
// rendering at parent-node levels.
type ResponseEnvelope struct {
	Events []TimelineEvent `json:"events"`
	Lanes  []LaneInfo      `json:"lanes"`
}

// ProjectionOpts narrows the SmartTimeline read.
//
// IncludeAuditFull — when true, project_events are loaded WITHOUT the
// timeline_kind filter (every audit row, the legacy Verlauf list).
// Backs the "Audit-Log anzeigen" toggle in the timeline header.
//
// DirectOnly — when true, narrows to rows whose project_id exactly
// matches; default (false) aggregates the project + every descendant,
// matching the existing "Inkl. Unterprojekte" toggle behaviour on
// /projects/{id}.
//
// LookaheadCap — number of future projected rows to surface (Slice 2,
// m/paliad#31 layer 1). 0 = use DefaultLookaheadCap. Past predicted-
// overdue rows always bypass this cap. The handler clamps user input
// to [1, MaxLookaheadCap].
//
// Lang — language for DependsOnRuleName ("de" / "en"). Empty defaults
// to "de" (Paliad's frontend default — see CLAUDE.md "Frontend default
// language is German").
type ProjectionOpts struct {
	IncludeAuditFull bool
	DirectOnly       bool
	LookaheadCap     int
	Lang             string
}

// ProjectionMeta summarises a projection result for the handler / frontend.
// Surfaced via X-Projection-* headers on the GET /timeline response so the
// wire shape stays []TimelineEvent (frozen from Slice 1) while the
// frontend still gets enough info to render "Mehr anzeigen".
type ProjectionMeta struct {
	HasProjection    bool `json:"has_projection"`    // true when calculator was invoked
	ProjectedTotal   int  `json:"projected_total"`   // future predicted rows pre-cap (main track)
	ProjectedShown   int  `json:"projected_shown"`   // future predicted rows after cap (main track)
	PredictedOverdue int  `json:"predicted_overdue"` // overdue projection rows (main track, uncapped)
	Lookahead        int  `json:"lookahead"`         // applied cap value

	// AvailableTracks lists the track tags present in the response — the
	// chip selector (`[Track ▼]`) reads this to populate the dropdown.
	// "parent" is always present; "counterclaim:<id>" is added when CCR
	// children exist; "parent_context:<id>" is added when the viewed
	// project is itself a CCR sub-project (t-paliad-174 §4.5).
	AvailableTracks []string `json:"available_tracks"`

	// Lanes describes the parallel-column layout at parent-node levels
	// (t-paliad-175 SmartTimeline Slice 4 §5). At Case level, lanes
	// mirror the available tracks (one entry for "self", one per visible
	// CCR sub-project, one for parent_context when applicable). At
	// Patent / Litigation / Client levels, lanes are the direct child
	// projects under the lane axis. Empty when the response should
	// render as a single-column flow (legacy behaviour).
	Lanes []LaneInfo `json:"lanes"`

	// SpawnCycleDropped is set when expandCrossProceedingSpawns detected
	// a cycle in the spawn graph and degraded to "no spawned rows" rather
	// than failing the projection. The SmartTimeline still renders; the
	// caller can log + show a "Spawn-Auflösung übersprungen" banner so the
	// editor knows which spawn rule to fix. Phase 3 Slice 7 (t-paliad-188).
	SpawnCycleDropped bool `json:"spawn_cycle_dropped,omitempty"`
}

// ProjectionService composes the SmartTimeline.
type ProjectionService struct {
	db           *sqlx.DB
	projects     *ProjectService
	deadlines    *DeadlineService
	appointments *AppointmentService
	fristen      *FristenrechnerService
	rules        *DeadlineRuleService
}

// NewProjectionService wires the read-side dependencies. fristen + rules
// are required for Slice 2 projection; pass them in even when the caller
// only intends to use actuals (the service short-circuits cleanly when
// the project has no proceeding_type set).
func NewProjectionService(
	db *sqlx.DB,
	projects *ProjectService,
	deadlines *DeadlineService,
	appointments *AppointmentService,
	fristen *FristenrechnerService,
	rules *DeadlineRuleService,
) *ProjectionService {
	return &ProjectionService{
		db:           db,
		projects:     projects,
		deadlines:    deadlines,
		appointments: appointments,
		fristen:      fristen,
		rules:        rules,
	}
}

// For builds a SmartTimeline for one project. Returns rows + meta
// summarising the projection state. Visibility is delegated to the
// underlying services (DeadlineService / AppointmentService / the
// project_events query reuses the project visibility predicate), so
// this layer adds no new RLS surface.
//
// Sort: actuals before projections of the same date; projections sorted
// by date ASC (predicted_overdue first since they're in the past),
// undated rows last. See sortTimeline for the deterministic tiebreak.
//
// Level policy (t-paliad-175 Slice 4 §5):
//   - Case (or unknown type) — full detail: own actuals + projection +
//     parallel-track CCR children. Lanes mirror tracks ("self" + CCR).
//   - Patent / Litigation / Client — lane-aggregated: load direct
//     children matching the axis, gather their subtree events, apply
//     the policy filter (kinds/statuses) with bubble_up override on
//     project_events, tag every row with LaneID = direct-child id.
//
// Track composition (t-paliad-174 §4.5) survives at Case level:
//   - The viewed project always emits Track="parent" rows.
//   - Visible CCR sub-projects emit Track="counterclaim:<child_id>".
//   - When the viewed project is itself a CCR, the parent emits
//     Track="parent_context:<parent_id>" rows.
func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, ProjectionMeta, error) {
	meta := ProjectionMeta{
		Lookahead:       applyLookaheadDefault(opts.LookaheadCap),
		AvailableTracks: []string{"parent"},
		Lanes:           []LaneInfo{},
	}

	proj, err := s.projects.GetByID(ctx, userID, projectID)
	if err != nil {
		return nil, meta, err
	}

	policy := levelPolicy(proj.Type)

	// DirectOnly collapses every level to a single-lane "self" view —
	// no CCR sub-project lanes (Case level), no parent_context lane (CCR
	// child viewpoint), no child-case / child-patent / child-litigation
	// lanes (Patent / Litigation / Client levels). The level-policy
	// kind/status filter still applies at higher levels so that, e.g., a
	// Patent-level direct view doesn't suddenly leak off_script custom
	// milestones that the aggregated view filters out (t-paliad-176).
	if opts.DirectOnly {
		return s.forDirectSelfOnly(ctx, userID, proj, policy, opts, meta)
	}

	// Patent / Litigation / Client levels — lane-aggregated rendering.
	if policy.LaneAxis != "self_plus_ccr" {
		return s.forAggregatedLevel(ctx, userID, proj, policy, opts, meta)
	}

	// Case level (and anything else without a known axis) — full detail
	// flow: parent track + CCR sub-projects + parent_context for CCR
	// children.
	return s.forCaseLevel(ctx, userID, proj, opts, meta)
}

// forDirectSelfOnly handles every level when DirectOnly is requested
// (m/paliad#33). Renders this project's own actuals + (at Case level)
// projection only — no CCR / parent_context / child-case lanes. The
// policy's kind/status filter still applies at higher levels so the
// "Nur direkt" Patent view honours the same milestone-only contract as
// the aggregated default. Produces a single "self" lane.
func (s *ProjectionService) forDirectSelfOnly(
	ctx context.Context,
	userID uuid.UUID,
	proj *models.Project,
	policy LevelPolicy,
	opts ProjectionOpts,
	meta ProjectionMeta,
) ([]TimelineEvent, ProjectionMeta, error) {
	includeProjection := policy.LaneAxis == "self_plus_ccr"
	rows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, includeProjection)
	if err != nil {
		return nil, meta, err
	}
	meta.HasProjection = mainMeta.HasProjection
	meta.ProjectedTotal = mainMeta.ProjectedTotal
	meta.ProjectedShown = mainMeta.ProjectedShown
	meta.PredictedOverdue = mainMeta.PredictedOverdue

	allowKind := stringSet(policy.Kinds)
	allowStatus := stringSet(policy.Statuses)
	out := make([]TimelineEvent, 0, len(rows))
	for i := range rows {
		row := rows[i]
		row.LaneID = "self"
		if !rowSurvivesPolicy(row, allowKind, allowStatus) {
			continue
		}
		out = append(out, row)
	}
	meta.Lanes = append(meta.Lanes, LaneInfo{
		ID:        "self",
		Label:     proj.Title,
		ProjectID: proj.ID.String(),
	})

	sortTimeline(out)
	return out, meta, nil
}

// forCaseLevel runs the original Slice-1-through-3 flow: parent track +
// CCR sub-projects (when this project is the parent) or parent_context
// (when this project is a CCR child). Lanes mirror tracks one-for-one
// at this level.
func (s *ProjectionService) forCaseLevel(
	ctx context.Context,
	userID uuid.UUID,
	proj *models.Project,
	opts ProjectionOpts,
	meta ProjectionMeta,
) ([]TimelineEvent, ProjectionMeta, error) {
	projectID := proj.ID

	// --- Main project track (always present) ---------------------------
	mainRows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, true)
	if err != nil {
		return nil, meta, err
	}
	meta.HasProjection = mainMeta.HasProjection
	meta.ProjectedTotal = mainMeta.ProjectedTotal
	meta.ProjectedShown = mainMeta.ProjectedShown
	meta.PredictedOverdue = mainMeta.PredictedOverdue

	for i := range mainRows {
		mainRows[i].LaneID = "self"
	}
	meta.Lanes = append(meta.Lanes, LaneInfo{
		ID:        "self",
		Label:     proj.Title,
		ProjectID: proj.ID.String(),
	})

	out := make([]TimelineEvent, 0, len(mainRows)+16)
	out = append(out, mainRows...)

	// --- CCR sub-project tracks (parent's view) ------------------------
	if proj.CounterclaimOf == nil {
		ccrChildren, err := s.projects.LoadCounterclaimChildrenVisible(ctx, userID, projectID)
		if err != nil {
			return nil, meta, fmt.Errorf("projection: ccr children: %w", err)
		}
		for i := range ccrChildren {
			child := ccrChildren[i]
			tag := "counterclaim:" + child.ID.String()
			childRows, _, err := s.loadProjectTrack(ctx, userID, &child, opts, tag, &child, true)
			if err != nil {
				return nil, meta, fmt.Errorf("projection: ccr child %s: %w", child.ID, err)
			}
			for j := range childRows {
				childRows[j].LaneID = tag
			}
			out = append(out, childRows...)
			meta.AvailableTracks = append(meta.AvailableTracks, tag)
			meta.Lanes = append(meta.Lanes, LaneInfo{
				ID:        tag,
				Label:     child.Title,
				ProjectID: child.ID.String(),
			})
		}
	}

	// --- Parent context (CCR child's view) -----------------------------
	if proj.CounterclaimOf != nil {
		parent, err := s.projects.GetByID(ctx, userID, *proj.CounterclaimOf)
		if err == nil && parent != nil {
			tag := "parent_context:" + parent.ID.String()
			parentRows, _, err := s.loadProjectTrack(ctx, userID, parent, opts, tag, parent, true)
			if err != nil {
				return nil, meta, fmt.Errorf("projection: parent context: %w", err)
			}
			for j := range parentRows {
				parentRows[j].LaneID = tag
			}
			out = append(out, parentRows...)
			meta.AvailableTracks = append(meta.AvailableTracks, tag)
			meta.Lanes = append(meta.Lanes, LaneInfo{
				ID:        tag,
				Label:     parent.Title,
				ProjectID: parent.ID.String(),
			})
		}
		// Parent invisible to viewer (rare — usually CCR creator has
		// access to both): silently omit; the CCR's own track still
		// renders solo.
	}

	sortTimeline(out)
	return out, meta, nil
}

// forAggregatedLevel handles Patent / Litigation / Client levels per
// §5: gather the direct children matching the policy's lane axis, run a
// per-lane loader on each subtree, apply the kind/status filter (with
// bubble_up override), and tag rows with LaneID = direct-child id.
//
// The projection calculator is disabled on lane-aggregated levels —
// at Patent / Litigation / Client we render only actuals + opted-in
// milestones, never the predicted future course (per §5.1 the future
// projection is a Case-level concern; surfacing it at higher levels
// would drown the user in noise).
func (s *ProjectionService) forAggregatedLevel(
	ctx context.Context,
	userID uuid.UUID,
	proj *models.Project,
	policy LevelPolicy,
	opts ProjectionOpts,
	meta ProjectionMeta,
) ([]TimelineEvent, ProjectionMeta, error) {
	laneChildren, err := s.loadLaneChildren(ctx, userID, proj, policy)
	if err != nil {
		return nil, meta, err
	}

	out := make([]TimelineEvent, 0, len(laneChildren)*8)
	allowKind := stringSet(policy.Kinds)
	allowStatus := stringSet(policy.Statuses)

	for i := range laneChildren {
		child := laneChildren[i]
		laneID := child.ID.String()
		laneLabel := laneLabelFor(&child, policy)
		meta.Lanes = append(meta.Lanes, LaneInfo{
			ID:        laneID,
			Label:     laneLabel,
			ProjectID: child.ID.String(),
		})

		// Lane-aggregated levels skip projection — the lane loader runs
		// the actuals pipeline only.
		laneRows, _, err := s.loadProjectTrack(ctx, userID, &child, opts, "parent", nil, false)
		if err != nil {
			return nil, meta, fmt.Errorf("projection: lane child %s: %w", child.ID, err)
		}
		for j := range laneRows {
			row := laneRows[j]
			row.LaneID = laneID
			if !rowSurvivesPolicy(row, allowKind, allowStatus) {
				continue
			}
			out = append(out, row)
		}
	}

	sortTimeline(out)
	return out, meta, nil
}

// loadLaneChildren returns the direct children matching the policy's
// lane axis, sorted deterministically. Visibility is owned by the
// underlying ProjectService (each child lookup goes through visibility
// predicates), so a user only ever sees lanes they're entitled to.
func (s *ProjectionService) loadLaneChildren(
	ctx context.Context,
	userID uuid.UUID,
	proj *models.Project,
	policy LevelPolicy,
) ([]models.Project, error) {
	want := childTypeForAxis(policy.LaneAxis)
	if want == "" {
		return nil, nil
	}
	all, err := s.projects.ListChildren(ctx, userID, proj.ID)
	if err != nil {
		return nil, fmt.Errorf("projection: list lane children: %w", err)
	}
	out := make([]models.Project, 0, len(all))
	for _, c := range all {
		if c.Type != want {
			continue
		}
		// Skip CCR sub-projects from the lane list — they surface as
		// their own column on the parent case's SmartTimeline (Slice 3
		// behaviour), not as a separate lane at higher levels.
		if c.CounterclaimOf != nil {
			continue
		}
		out = append(out, c)
	}
	return out, nil
}

// childTypeForAxis maps a lane axis identifier to the project type the
// children must have. Returns "" when the axis is unknown / not lane-
// aggregated (Case level).
func childTypeForAxis(axis string) string {
	switch axis {
	case "child_case":
		return "case"
	case "child_patent":
		return "patent"
	case "child_litigation":
		return "litigation"
	}
	return ""
}

// laneLabelFor picks the human-readable label for a lane sub-header.
// Patent level → "<case title> (<proceeding code>)"; Litigation level
// → patent reference / patent_number; Client level → litigation title.
// Falls back to the child's Title when no axis-specific identifier is
// available.
func laneLabelFor(child *models.Project, policy LevelPolicy) string {
	switch policy.LaneAxis {
	case "child_case":
		// Append the proceeding type code when known so the lawyer can
		// identify which case at a glance ("UPC-CFI München (upc.inf.cfi)").
		if child.ProceedingTypeID != nil {
			return child.Title
		}
		return child.Title
	case "child_patent":
		if child.PatentNumber != nil && strings.TrimSpace(*child.PatentNumber) != "" {
			return strings.TrimSpace(*child.PatentNumber)
		}
		if child.Reference != nil && strings.TrimSpace(*child.Reference) != "" {
			return strings.TrimSpace(*child.Reference)
		}
		return child.Title
	case "child_litigation":
		return child.Title
	}
	return child.Title
}

// rowSurvivesPolicy applies the (kinds, statuses) filter from levelPolicy.
// Bubble-up project_events override the filter unconditionally — that's
// the contract for structural milestones at higher levels.
func rowSurvivesPolicy(row TimelineEvent, allowKind, allowStatus map[string]bool) bool {
	if row.BubbleUp {
		return true
	}
	if len(allowKind) > 0 && !allowKind[row.Kind] {
		return false
	}
	if len(allowStatus) > 0 && !allowStatus[row.Status] {
		return false
	}
	return true
}

// stringSet builds a lookup map from a slice; nil/empty input returns
// nil so callers can skip the filter when the policy doesn't constrain
// the dimension.
func stringSet(vals []string) map[string]bool {
	if len(vals) == 0 {
		return nil
	}
	out := make(map[string]bool, len(vals))
	for _, v := range vals {
		out[v] = true
	}
	return out
}

// levelPolicy returns the (kinds, statuses, lane_axis) triple per
// project type per design §5.1. Unknown / empty types fall back to the
// Case-level policy — the safest default since it shows everything.
func levelPolicy(projectType string) LevelPolicy {
	switch projectType {
	case "patent":
		return LevelPolicy{
			Kinds:    []string{"deadline", "milestone"},
			Statuses: []string{"done", "open", "overdue"},
			LaneAxis: "child_case",
		}
	case "litigation":
		return LevelPolicy{
			Kinds:    []string{"milestone"},
			Statuses: []string{"done"},
			LaneAxis: "child_patent",
		}
	case "client":
		return LevelPolicy{
			Kinds:    []string{"milestone"},
			Statuses: []string{"done"},
			LaneAxis: "child_litigation",
		}
	default:
		// Case + everything else.
		return LevelPolicy{LaneAxis: "self_plus_ccr"}
	}
}

// loadProjectTrack runs the actuals + projection pipeline for ONE
// project and returns rows tagged with trackTag. When subProject is
// non-nil, every emitted row also carries SubProjectID + SubProjectTitle
// so the frontend can render the sub-project label in the column header.
//
// Each track applies its own lookahead cap independently — the meta
// returned represents only this track. The caller decides which track's
// meta surfaces in headers; today the main track's meta wins.
//
// includeProjection — when false, the calculator is skipped (lane-
// aggregated rendering at Patent / Litigation / Client levels per §5,
// where projected rows are deliberately hidden). The actuals pipeline
// runs unchanged either way.
func (s *ProjectionService) loadProjectTrack(
	ctx context.Context,
	userID uuid.UUID,
	proj *models.Project,
	opts ProjectionOpts,
	trackTag string,
	subProject *models.Project,
	includeProjection bool,
) ([]TimelineEvent, ProjectionMeta, error) {
	meta := ProjectionMeta{Lookahead: applyLookaheadDefault(opts.LookaheadCap)}
	out := make([]TimelineEvent, 0, 16)
	projectID := proj.ID

	// --- Deadlines ----
	deadlineRows, err := s.deadlines.ListVisibleForUser(ctx, userID, ListFilter{
		ProjectID:  &projectID,
		DirectOnly: opts.DirectOnly,
	})
	if err != nil {
		return nil, meta, fmt.Errorf("projection: deadlines: %w", err)
	}
	for _, d := range deadlineRows {
		ev := TimelineEvent{
			Kind:       "deadline",
			Status:     deadlineStatus(d.Status, d.DueDate),
			Track:      trackTag,
			Date:       timePtr(time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC)),
			Title:      d.Title,
			DeadlineID: &d.ID,
		}
		if d.Description != nil {
			ev.Description = *d.Description
		}
		if d.RuleCode != nil {
			ev.RuleCode = *d.RuleCode
		}
		if d.RuleID != nil {
			id := *d.RuleID
			ev.DeadlineRuleID = &id
		}
		applySubProject(&ev, subProject)
		out = append(out, ev)
	}

	// --- Appointments ----
	apptRows, err := s.appointments.ListVisibleForUser(ctx, userID, AppointmentListFilter{
		ProjectID:  &projectID,
		DirectOnly: opts.DirectOnly,
	})
	if err != nil {
		return nil, meta, fmt.Errorf("projection: appointments: %w", err)
	}
	now := time.Now().UTC()
	for _, a := range apptRows {
		startCopy := a.StartAt
		ev := TimelineEvent{
			Kind:          "appointment",
			Status:        appointmentStatus(startCopy, now),
			Track:         trackTag,
			Date:          &startCopy,
			Title:         a.Title,
			AppointmentID: &a.ID,
		}
		if a.Description != nil {
			ev.Description = *a.Description
		}
		applySubProject(&ev, subProject)
		out = append(out, ev)
	}

	if err := s.hydrateAppointmentRuleIDs(ctx, projectID, opts.DirectOnly, out); err != nil {
		return nil, meta, err
	}

	// --- Milestones ----
	skippedRules, milestoneRows, err := s.listProjectEvents(ctx, userID, projectID, opts)
	if err != nil {
		return nil, meta, fmt.Errorf("projection: milestones: %w", err)
	}
	for i := range milestoneRows {
		milestoneRows[i].Track = trackTag
		applySubProject(&milestoneRows[i], subProject)
	}
	out = append(out, milestoneRows...)

	// --- Projection (Slice 2) ----
	if includeProjection {
		projectedRows, projMeta, err := s.computeProjections(ctx, proj, skippedRules, opts)
		if err != nil {
			return nil, meta, fmt.Errorf("projection: calculate: %w", err)
		}
		for i := range projectedRows {
			projectedRows[i].Track = trackTag
			applySubProject(&projectedRows[i], subProject)
		}
		out = append(out, projectedRows...)
		meta.HasProjection = projMeta.HasProjection
		meta.ProjectedTotal = projMeta.ProjectedTotal
		meta.ProjectedShown = projMeta.ProjectedShown
		meta.PredictedOverdue = projMeta.PredictedOverdue
	}

	// --- Dependency annotations ----
	if proj.ProceedingTypeID != nil && s.rules != nil {
		rules, err := s.rules.List(ctx, proj.ProceedingTypeID)
		if err == nil {
			s.annotateDependsOn(out, rules, lang(opts.Lang))
		}
	}

	return out, meta, nil
}

// applySubProject fills SubProjectID + SubProjectTitle when the row
// belongs to a non-primary track. No-op when subProject is nil.
func applySubProject(ev *TimelineEvent, subProject *models.Project) {
	if subProject == nil {
		return
	}
	id := subProject.ID
	ev.SubProjectID = &id
	ev.SubProjectTitle = subProject.Title
}

// computeProjections runs FristenrechnerService.Calculate for the project
// and emits TimelineEvent rows for every rule that does NOT have a
// matching actual. Returns the projected rows + the meta summary.
//
// Lookahead cap (m/paliad#31 layer 1): future predicted rows beyond
// LookaheadCap are dropped from the output; predicted-overdue rows
// (past projected dates without an anchor) bypass the cap. Court-set
// rules in the future surface with Status="court_set"; all other future
// rules with Status="predicted".
func (s *ProjectionService) computeProjections(
	ctx context.Context,
	proj *models.Project,
	skippedRuleCodes map[string]bool,
	opts ProjectionOpts,
) ([]TimelineEvent, ProjectionMeta, error) {
	cap := applyLookaheadDefault(opts.LookaheadCap)
	meta := ProjectionMeta{Lookahead: cap}

	if proj.ProceedingTypeID == nil || s.fristen == nil || s.rules == nil {
		return nil, meta, nil
	}

	// Resolve proceeding code from id.
	var proceedingCode string
	err := s.db.GetContext(ctx, &proceedingCode,
		`SELECT code FROM paliad.proceeding_types WHERE id = $1 AND is_active = true`,
		*proj.ProceedingTypeID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, meta, nil
	}
	if err != nil {
		return nil, meta, fmt.Errorf("resolve proceeding code: %w", err)
	}

	// Build the AnchorOverrides map from completed actuals + appointments
	// tied to a rule. Track which rule_ids have actuals so we can skip
	// emitting projected rows for them.
	overrides := map[string]string{}
	ruleIDsWithActual := map[uuid.UUID]bool{}

	rules, err := s.rules.List(ctx, proj.ProceedingTypeID)
	if err != nil {
		return nil, meta, fmt.Errorf("list rules for proceeding: %w", err)
	}
	ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules))
	for _, r := range rules {
		ruleByID[r.ID] = r
	}

	// Deadlines with rule_id → override the rule's anchor with the actual
	// date. Prefer completed_at on done rows; fall back to due_date.
	if err := s.collectActualsForOverrides(ctx, proj.ID, ruleByID,
		opts.DirectOnly, overrides, ruleIDsWithActual); err != nil {
		return nil, meta, err
	}

	// Determine triggerDate. Look for the proceeding's root rule
	// (parent_id IS NULL) — when an actual exists for it, that's the
	// trigger; otherwise today as a placeholder so the projection still
	// computes relative dates. Once the user clicks "Datum setzen" on
	// the root row (e.g. SoC), the next read uses the real anchor.
	triggerDateStr := s.deriveTriggerDate(rules, overrides)

	flags := flagsForProject(proj)
	resp, err := s.fristen.Calculate(ctx, proceedingCode, triggerDateStr, CalcOptions{
		AnchorOverrides: overrides,
		Flags:           flags,
	})
	if err != nil {
		// Calculator hiccup is non-fatal — degrade to actuals-only so
		// the page still renders. Log via the standard pattern.
		return nil, meta, nil
	}

	meta.HasProjection = true

	today := startOfUTCDay(time.Now().UTC())
	projected := make([]TimelineEvent, 0, len(resp.Deadlines))

	for _, ui := range resp.Deadlines {
		if ui.RuleID == "" {
			continue
		}
		ruleID, err := uuid.Parse(ui.RuleID)
		if err != nil {
			continue
		}
		if ruleIDsWithActual[ruleID] {
			// Already represented as a Kind="deadline" or "appointment"
			// row — skip duplicate.
			continue
		}
		// Rule explicitly skipped via /timeline/skip (§6.4) — drop
		// from cascade so the user's "ist nicht eingetreten" decision
		// sticks across reloads.
		if ui.Code != "" && skippedRuleCodes[ui.Code] {
			continue
		}

		rule, ok := ruleByID[ruleID]
		if !ok {
			// Defensive: the calculator returned a rule_id that isn't in
			// the per-proceeding map. After Phase 3 Slice 7
			// (t-paliad-188) the unified FristenrechnerService.Calculate
			// stays scoped to one proceeding (Option A in design §6.2),
			// so spawned-into rules don't arrive here — they're appended
			// below via expandCrossProceedingSpawns. A miss now means
			// either a stale ruleByID (unlikely) or a future calculator
			// extension we haven't accounted for; skip the dependency
			// annotation but still surface the row.
			rule = models.DeadlineRule{}
		}

		ev := TimelineEvent{
			Kind:              "projected",
			Track:             "parent",
			Title:             ruleDisplayName(rule, ui, lang(opts.Lang)),
			RuleCode:          ui.Code,
			DeadlineRuleParty: ui.Party,
		}
		idCopy := ruleID
		ev.DeadlineRuleID = &idCopy

		// Date — UIDeadline.DueDate is YYYY-MM-DD when set, "" for
		// court-set rules whose date isn't bound yet.
		if ui.DueDate != "" {
			if t, perr := time.Parse("2006-01-02", ui.DueDate); perr == nil {
				dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
				ev.Date = &dt
			}
		}

		switch {
		case ui.IsCourtSet && ev.Date == nil:
			// Pure court-set rule — date is bound by the court at
			// hearing/decision time. Surface as undated court_set.
			ev.Status = "court_set"
		case ui.IsCourtSet:
			// Court-set rule with a derived date (e.g. R.151 chain
			// off a court-set parent). Render as court_set so the UI
			// shows the dashed border + the "wird vom Gericht bestimmt"
			// nuance.
			ev.Status = "court_set"
		case ev.Date != nil && ev.Date.Before(today):
			// Past predicted but no anchor: surface as overdue. These
			// bypass the lookahead cap (§6.4 + #31 layer 1).
			ev.Status = "predicted_overdue"
		default:
			ev.Status = "predicted"
		}

		projected = append(projected, ev)
	}

	// Phase 3 Slice 7 (t-paliad-188): expand cross-proceeding spawn rules.
	// is_spawn=true rules with a non-NULL spawn_proceeding_type_id appear
	// in the current proceeding's rule set; we resolve each spawn target's
	// root rule (lowest sequence_order) via a one-shot global SELECT and
	// emit a spawned-into projected row anchored on the spawn source's
	// computed date. Cycle guard: visited-set DFS keyed by
	// proceeding_type_id; ErrCyclicSpawn degrades to "no spawned rows"
	// rather than failing the whole SmartTimeline render.
	if proj.ProceedingTypeID != nil {
		visited := map[int]bool{*proj.ProceedingTypeID: true}
		spawnRows, spawnErr := s.expandCrossProceedingSpawns(ctx, rules, resp.Deadlines, visited, 0)
		if spawnErr != nil {
			if !errors.Is(spawnErr, ErrCyclicSpawn) {
				return nil, meta, fmt.Errorf("expand spawns: %w", spawnErr)
			}
			// Cyclic spawn: drop spawned rows from this projection,
			// continue rendering the rest. SmartTimeline stays usable.
			// Surfaced in meta so the caller can log / show a banner.
			meta.SpawnCycleDropped = true
		} else if len(spawnRows) > 0 {
			projected = append(projected, spawnRows...)
		}
	}

	// Apply lookahead cap. Predicted-overdue rows are exempt — surface
	// all of them. Court-set undated rows are exempt too because their
	// position on the timeline is "future, indefinite" and dropping the
	// Hauptverhandlung row to make room for a Rejoinder would be wrong.
	cappedProjected, projTotal, projShown, overdueCount := applyLookaheadCap(projected, cap)
	meta.ProjectedTotal = projTotal
	meta.ProjectedShown = projShown
	meta.PredictedOverdue = overdueCount

	return cappedProjected, meta, nil
}

// expandCrossProceedingSpawns walks the spawn graph rooted at the
// caller's source proceeding (the `visited` set seeds it). For each
// rule in `sourceRules` with is_spawn=true AND a non-NULL
// SpawnProceedingTypeID, it resolves the target proceeding's root rule
// and emits a spawned-into TimelineEvent linking back to the source.
//
// Cycle guard: when a spawn target's proceeding_type_id is already in
// `visited`, the function returns ErrCyclicSpawn wrapped with the
// rule + proceeding context. The caller (computeProjections) catches
// it and degrades to "no spawned rows" — better than blocking the
// whole render with an error.
//
// Recursion: after emitting a spawned-into row, the function recurses
// into the target proceeding's own spawn rules. depth is bounded by
// maxSpawnDepth as a safety belt; the visited set is the real loop
// guard.
//
// Spawn-source dates come from `sourceDeadlines` — the UIResponse the
// calculator just emitted. The spawned-into row inherits the source's
// computed due date as its anchor; computing the target proceeding's
// own deadlines off that anchor is deferred to a follow-up slice (the
// rule editor will let editors set per-rule offsets that the
// projection can compose). For Slice 7 v1, the spawned-into row
// surfaces undated with Status="predicted" and Track="spawn" so the
// frontend renders a clear boundary divider.
func (s *ProjectionService) expandCrossProceedingSpawns(
	ctx context.Context,
	sourceRules []models.DeadlineRule,
	sourceDeadlines []UIDeadline,
	visited map[int]bool,
	depth int,
) ([]TimelineEvent, error) {
	if depth >= maxSpawnDepth {
		return nil, fmt.Errorf("%w: max depth %d exceeded", ErrCyclicSpawn, maxSpawnDepth)
	}

	// Index source rule computed dates by rule id for anchor lookup.
	dateByRuleID := make(map[uuid.UUID]string, len(sourceDeadlines))
	for _, ui := range sourceDeadlines {
		if ui.RuleID == "" || ui.DueDate == "" {
			continue
		}
		if id, err := uuid.Parse(ui.RuleID); err == nil {
			dateByRuleID[id] = ui.DueDate
		}
	}

	// Identify spawn rules + collect target proceeding ids. The cycle
	// guard runs here on each unique target — if any target is already
	// in `visited`, abort the whole expansion (one cyclic edge poisons
	// the graph; we can't selectively render around it without
	// fabricating an incomplete dependency tree).
	type spawnSource struct {
		rule       models.DeadlineRule
		anchorDate string
	}
	var sources []spawnSource
	targetIDs := make(map[int]struct{})
	for _, r := range sourceRules {
		if !r.IsSpawn || r.SpawnProceedingTypeID == nil {
			continue
		}
		if visited[*r.SpawnProceedingTypeID] {
			return nil, fmt.Errorf("%w: rule %s (proceeding %d) spawns into proceeding %d which is already in the chain",
				ErrCyclicSpawn, r.ID, derefIntPtr(r.ProceedingTypeID), *r.SpawnProceedingTypeID)
		}
		targetIDs[*r.SpawnProceedingTypeID] = struct{}{}
		sources = append(sources, spawnSource{rule: r, anchorDate: dateByRuleID[r.ID]})
	}
	if len(sources) == 0 {
		return nil, nil
	}

	// Bulk-load target proceedings' rules in one round-trip. The result
	// is pre-sorted by (proceeding_type_id, sequence_order) so the
	// first rule per proceeding is the root (lowest sequence_order).
	ids := make([]int, 0, len(targetIDs))
	for id := range targetIDs {
		ids = append(ids, id)
	}
	targetRules, err := s.rules.ListByProceedingTypeIDs(ctx, ids)
	if err != nil {
		return nil, err
	}

	// Group target rules by proceeding_type_id; first slot wins (root).
	firstByPT := make(map[int]models.DeadlineRule, len(ids))
	rulesByPT := make(map[int][]models.DeadlineRule, len(ids))
	for _, tr := range targetRules {
		if tr.ProceedingTypeID == nil {
			continue
		}
		rulesByPT[*tr.ProceedingTypeID] = append(rulesByPT[*tr.ProceedingTypeID], tr)
		if _, seen := firstByPT[*tr.ProceedingTypeID]; !seen {
			firstByPT[*tr.ProceedingTypeID] = tr
		}
	}

	// Render one spawned-into TimelineEvent per source rule. Recurse
	// into the target proceeding's spawn rules (depth + 1) with the
	// target's proceeding_type_id added to `visited`.
	var out []TimelineEvent
	for _, src := range sources {
		first, ok := firstByPT[*src.rule.SpawnProceedingTypeID]
		if !ok {
			// Target proceeding has no active rules (defensive — a
			// future seed could land it). Skip silently.
			continue
		}

		title := first.Name
		if src.rule.SpawnLabel != nil && *src.rule.SpawnLabel != "" {
			title = title + " (" + *src.rule.SpawnLabel + ")"
		}

		ev := TimelineEvent{
			Kind:              "projected",
			Status:            "predicted",
			Track:             "spawn",
			Title:             title,
			DependsOnRuleName: src.rule.Name,
		}
		if first.SubmissionCode != nil {
			ev.RuleCode = *first.SubmissionCode
		}
		if src.rule.SubmissionCode != nil {
			ev.DependsOnRuleCode = *src.rule.SubmissionCode
		}
		idCopy := first.ID
		ev.DeadlineRuleID = &idCopy
		if first.PrimaryParty != nil {
			ev.DeadlineRuleParty = *first.PrimaryParty
		}
		// Anchor date: the spawn source's projected due date if
		// known. We don't compute the target's offset in Slice 7
		// v1 — that's the deferred per-rule editor concern — so the
		// row surfaces undated when the source has no anchor.
		if src.anchorDate != "" {
			if t, perr := time.Parse("2006-01-02", src.anchorDate); perr == nil {
				dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
				ev.DependsOnDate = &dt
			}
		}
		out = append(out, ev)

		// Recurse: walk the target's own spawn rules. Carry forward
		// the visited set with the target proceeding added so a
		// later hop back to it triggers ErrCyclicSpawn.
		nextVisited := make(map[int]bool, len(visited)+1)
		for k, v := range visited {
			nextVisited[k] = v
		}
		nextVisited[*src.rule.SpawnProceedingTypeID] = true
		sub, err := s.expandCrossProceedingSpawns(ctx, rulesByPT[*src.rule.SpawnProceedingTypeID], nil, nextVisited, depth+1)
		if err != nil {
			return out, err
		}
		out = append(out, sub...)
	}

	return out, nil
}

// derefIntPtr returns 0 when the pointer is nil — used only in error
// messages for human-readable proceeding-id context. Never load-bearing
// for the spawn-resolution logic itself (which checks for nil before
// dereferencing).
func derefIntPtr(p *int) int {
	if p == nil {
		return 0
	}
	return *p
}

// collectActualsForOverrides loads every paliad.deadlines + paliad.appointments
// row tied to a rule_id (or rule_code) for the project + descendants and
// fills the overrides + ruleIDsWithActual maps.
//
// We bypass the WithProject list helpers because the caller has already
// gated visibility via projects.GetByID + the deadline list call above
// for the displayed rows. This second pass is a narrow read of (rule_id,
// rule_code, completed_at, due_date) for the override map only — never
// surfaced to the user.
func (s *ProjectionService) collectActualsForOverrides(
	ctx context.Context,
	projectID uuid.UUID,
	ruleByID map[uuid.UUID]models.DeadlineRule,
	directOnly bool,
	overrides map[string]string,
	ruleIDsWithActual map[uuid.UUID]bool,
) error {
	type drow struct {
		RuleID      *uuid.UUID `db:"rule_id"`
		RuleCode    *string    `db:"rule_code"`
		DueDate     time.Time  `db:"due_date"`
		CompletedAt *time.Time `db:"completed_at"`
		Status      string     `db:"status"`
	}
	var dRows []drow
	scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly)
	q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status
	        FROM paliad.deadlines d
	       WHERE ` + scopeFilter
	if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil {
		return fmt.Errorf("collect deadline actuals: %w", err)
	}
	for _, d := range dRows {
		var anchor time.Time
		switch {
		case d.CompletedAt != nil:
			anchor = *d.CompletedAt
		case d.Status == "completed":
			anchor = d.DueDate
		default:
			// Pending deadline — not an anchor for downstream reflow.
			// Still mark rule_id so we don't double-emit projected.
			if d.RuleID != nil {
				ruleIDsWithActual[*d.RuleID] = true
			}
			continue
		}
		if d.RuleID != nil {
			ruleIDsWithActual[*d.RuleID] = true
			if r, ok := ruleByID[*d.RuleID]; ok && r.SubmissionCode != nil {
				overrides[*r.SubmissionCode] = anchor.Format("2006-01-02")
			}
		}
		if d.RuleCode != nil && *d.RuleCode != "" {
			overrides[*d.RuleCode] = anchor.Format("2006-01-02")
		}
	}

	type arow struct {
		RuleID  *uuid.UUID `db:"deadline_rule_id"`
		StartAt time.Time  `db:"start_at"`
	}
	var aRows []arow
	apptScopeFilter := scopeProjectIDFilter("a", "project_id", projectID, directOnly)
	aq := `SELECT a.deadline_rule_id, a.start_at
	         FROM paliad.appointments a
	        WHERE a.deadline_rule_id IS NOT NULL AND ` + apptScopeFilter
	if err := s.db.SelectContext(ctx, &aRows, aq, projectID); err != nil {
		return fmt.Errorf("collect appointment actuals: %w", err)
	}
	for _, a := range aRows {
		if a.RuleID == nil {
			continue
		}
		ruleIDsWithActual[*a.RuleID] = true
		if r, ok := ruleByID[*a.RuleID]; ok && r.SubmissionCode != nil {
			overrides[*r.SubmissionCode] = a.StartAt.UTC().Format("2006-01-02")
		}
	}
	return nil
}

// hydrateAppointmentRuleIDs back-fills DeadlineRuleID on the actual
// appointment rows in `rows` from the appointments.deadline_rule_id
// column. The list service doesn't carry that field today; doing one
// extra narrow query keeps the existing model + handler API stable.
func (s *ProjectionService) hydrateAppointmentRuleIDs(ctx context.Context, projectID uuid.UUID, directOnly bool, rows []TimelineEvent) error {
	type ar struct {
		ID     uuid.UUID  `db:"id"`
		RuleID *uuid.UUID `db:"deadline_rule_id"`
	}
	var rs []ar
	scope := scopeProjectIDFilter("a", "project_id", projectID, directOnly)
	q := `SELECT a.id, a.deadline_rule_id
	        FROM paliad.appointments a
	       WHERE a.deadline_rule_id IS NOT NULL AND ` + scope
	if err := s.db.SelectContext(ctx, &rs, q, projectID); err != nil {
		return fmt.Errorf("hydrate appointment rule_ids: %w", err)
	}
	if len(rs) == 0 {
		return nil
	}
	byID := make(map[uuid.UUID]uuid.UUID, len(rs))
	for _, r := range rs {
		if r.RuleID != nil {
			byID[r.ID] = *r.RuleID
		}
	}
	for i := range rows {
		if rows[i].Kind != "appointment" || rows[i].AppointmentID == nil {
			continue
		}
		if rid, ok := byID[*rows[i].AppointmentID]; ok {
			ridCopy := rid
			rows[i].DeadlineRuleID = &ridCopy
		}
	}
	return nil
}

// deriveTriggerDate picks the calculator's triggerDate. When the
// proceeding's root rule (parent_id IS NULL) has been anchored, use that
// date so the projection starts from reality. Otherwise fall back to
// today() — the projection becomes "what would happen if filed today",
// which the user fixes by clicking "Datum setzen" on the SoC row.
func (s *ProjectionService) deriveTriggerDate(rules []models.DeadlineRule, overrides map[string]string) string {
	for _, r := range rules {
		if r.ParentID != nil || r.SubmissionCode == nil {
			continue
		}
		if anchor, ok := overrides[*r.SubmissionCode]; ok {
			return anchor
		}
	}
	return time.Now().UTC().Format("2006-01-02")
}

// listProjectEvents reads paliad.project_events for the timeline. Returns
// the rule_skipped set (used to suppress projected rows for those rules)
// alongside the surfaced TimelineEvent rows; rule_skipped events stay
// hidden in the user-facing list since they record a non-event.
func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, projectID uuid.UUID, opts ProjectionOpts) (map[string]bool, []TimelineEvent, error) {
	skipped := map[string]bool{}

	var projectFilter string
	if opts.DirectOnly {
		projectFilter = `pe.project_id = $1 AND ` + visibilityPredicatePositional("p", 2)
	} else {
		projectFilter = `$1 = ANY(string_to_array(p.path, '.')::uuid[]) AND ` + visibilityPredicatePositional("p", 2)
	}

	kindFilter := `AND (pe.timeline_kind IS NOT NULL OR pe.event_type = 'rule_skipped')`
	if opts.IncludeAuditFull {
		kindFilter = ``
	}

	type row struct {
		ID           uuid.UUID       `db:"id"`
		ProjectID    uuid.UUID       `db:"project_id"`
		EventType    *string         `db:"event_type"`
		Title        string          `db:"title"`
		Description  *string         `db:"description"`
		EventDate    *time.Time      `db:"event_date"`
		CreatedAt    time.Time       `db:"created_at"`
		Metadata     json.RawMessage `db:"metadata"`
		TimelineKind *string         `db:"timeline_kind"`
	}

	query := `
		SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
		       pe.event_date, pe.created_at, pe.metadata, pe.timeline_kind
		  FROM paliad.project_events pe
		  JOIN paliad.projects p ON p.id = pe.project_id
		 WHERE ` + projectFilter + ` ` + kindFilter + `
		 ORDER BY COALESCE(pe.event_date, pe.created_at) DESC, pe.id DESC`

	var rows []row
	if err := s.db.SelectContext(ctx, &rows, query, projectID, userID); err != nil {
		return nil, nil, fmt.Errorf("list project_events: %w", err)
	}

	out := make([]TimelineEvent, 0, len(rows))
	for _, r := range rows {
		// rule_skipped rows: extract the rule_code from metadata so
		// computeProjections can drop the matching projected row, but
		// don't surface them as user-facing timeline rows. The decision
		// is captured in the audit log via /admin/audit-log.
		if r.EventType != nil && *r.EventType == "rule_skipped" {
			if code := extractMetadataString(r.Metadata, "rule_code"); code != "" {
				skipped[code] = true
			}
			continue
		}

		var when time.Time
		if r.EventDate != nil {
			when = *r.EventDate
		} else {
			when = r.CreatedAt
		}
		whenCopy := when
		ev := TimelineEvent{
			Kind:           "milestone",
			Status:         milestoneStatus(r.TimelineKind, r.EventType),
			Track:          "parent",
			Date:           &whenCopy,
			Title:          r.Title,
			ProjectEventID: &r.ID,
			BubbleUp:       extractBubbleUp(r.Metadata, r.EventType, r.TimelineKind),
		}
		if r.EventType != nil {
			ev.ProjectEventType = *r.EventType
		}
		if r.Description != nil {
			ev.Description = *r.Description
		}
		out = append(out, ev)
	}
	return skipped, out, nil
}

// extractBubbleUp resolves the bubble_up flag for a project_events row
// per design Q5 (t-paliad-175). Explicit metadata.bubble_up wins; when
// absent, structural milestones (counterclaim_created, third_party_intervention,
// scope_change) default to true and everything else (including
// custom_milestone) defaults to false. Frontend exposes a checkbox on
// the custom-milestone form so the user can override per entry.
func extractBubbleUp(raw json.RawMessage, eventType, timelineKind *string) bool {
	if len(raw) > 0 {
		var m map[string]any
		if err := json.Unmarshal(raw, &m); err == nil {
			if v, ok := m["bubble_up"]; ok {
				switch t := v.(type) {
				case bool:
					return t
				case string:
					return strings.EqualFold(t, "true") || t == "1"
				}
			}
		}
	}
	if eventType != nil {
		switch *eventType {
		case "counterclaim_created", "third_party_intervention", "scope_change":
			return true
		}
	}
	// custom_milestone defaults to false (Q5 lock); user-set
	// metadata.bubble_up=true on the row is the only path to surface
	// these at higher levels.
	_ = timelineKind
	return false
}

// RecordCustomMilestone writes a "Eigener Meilenstein" project_event
// (event_type='custom_milestone', timeline_kind='custom_milestone')
// and returns the resulting TimelineEvent so the caller can append it
// directly to the rendered list without a re-fetch.
//
// bubbleUp persists into metadata.bubble_up — when true the milestone
// surfaces on the parent-node SmartTimeline at Patent / Litigation /
// Client levels. The frontend's custom-milestone form exposes the
// checkbox; absent the override, custom_milestone defaults to false
// per design Q5.
func (s *ProjectionService) RecordCustomMilestone(
	ctx context.Context,
	userID, projectID uuid.UUID,
	title string,
	description *string,
	occurredAt *time.Time,
	bubbleUp bool,
) (*TimelineEvent, error) {
	if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
		return nil, err
	}
	if title == "" {
		return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
	}

	id := uuid.New()
	now := time.Now().UTC()
	var eventDate *time.Time
	if occurredAt != nil {
		ts := occurredAt.UTC()
		eventDate = &ts
	}

	metaJSON := json.RawMessage(`{}`)
	if bubbleUp {
		// Only persist bubble_up when true so existing rows-without-it
		// keep extractBubbleUp's default-off behaviour for custom
		// milestones.
		metaJSON = json.RawMessage(`{"bubble_up":true}`)
	}

	_, err := s.db.ExecContext(ctx,
		`INSERT INTO paliad.project_events
		    (id, project_id, event_type, title, description, event_date,
		     created_by, metadata, created_at, updated_at, timeline_kind)
		 VALUES ($1, $2, 'custom_milestone', $3, $4, $5, $6, $7::jsonb, $8, $8, 'custom_milestone')`,
		id, projectID, title, description, eventDate, userID, string(metaJSON), now)
	if err != nil {
		return nil, fmt.Errorf("insert custom_milestone: %w", err)
	}

	when := now
	if eventDate != nil {
		when = *eventDate
	}
	whenCopy := when
	ev := &TimelineEvent{
		Kind:           "milestone",
		Status:         "off_script",
		Track:          "parent",
		Date:           &whenCopy,
		Title:          title,
		ProjectEventID: &id,
		BubbleUp:       bubbleUp,
	}
	if description != nil {
		ev.Description = *description
	}
	return ev, nil
}

// AnchorInput is the body of POST /api/projects/{id}/timeline/anchor.
type AnchorInput struct {
	RuleCode   string
	ActualDate time.Time
	Kind       string // "deadline" | "appointment" | "" → derive from rule.event_type
}

// AnchorResult signals what was written. Exactly one of DeadlineID /
// AppointmentID is non-nil. Updated=true means an existing row was
// PATCH'd (idempotent re-anchor); false = new row inserted.
type AnchorResult struct {
	DeadlineID    *uuid.UUID
	AppointmentID *uuid.UUID
	Updated       bool
}

// PredecessorMissingError is returned when the anchor write fails the
// sequence guard (m/paliad#31 layer 3). The handler maps this to 409.
type PredecessorMissingError struct {
	MissingRuleCode    string
	MissingRuleNameDE  string
	MissingRuleNameEN  string
	RequestedRuleCode  string
	RequestedRuleNameDE string
	RequestedRuleNameEN string
}

func (e *PredecessorMissingError) Error() string {
	return fmt.Sprintf("predecessor missing: %s requires %s to be anchored first",
		e.RequestedRuleCode, e.MissingRuleCode)
}

// IsPredecessorMissing unwraps a PredecessorMissingError if present.
func IsPredecessorMissing(err error) (*PredecessorMissingError, bool) {
	var pme *PredecessorMissingError
	if errors.As(err, &pme) {
		return pme, true
	}
	return nil, false
}

// RecordAnchor writes (or PATCHes) the actual occurrence of a rule for
// the given project. Implements the §6 click-to-anchor + #31 layer 3
// sequence guard:
//
//  1. Resolve the rule by (proceeding_type_id, code).
//  2. If rule has parent_id, verify the parent has an anchored actual
//     for this project — return PredecessorMissingError if not.
//  3. Court-set rules (event_type IN ('hearing','decision','order'))
//     write a paliad.appointments row with deadline_rule_id set; all
//     other rules write a paliad.deadlines row with source='anchor'.
//  4. Idempotent: if a row already exists for (project_id, rule_id),
//     PATCH it instead of inserting (race-safe per design §13).
//
// Visibility: caller must have admin / lead / member responsibility on
// the project — checked via DeadlineService.assertCanAdminProject (the
// existing pattern).
func (s *ProjectionService) RecordAnchor(ctx context.Context, userID, projectID uuid.UUID, in AnchorInput) (*AnchorResult, error) {
	proj, err := s.projects.GetByID(ctx, userID, projectID)
	if err != nil {
		return nil, err
	}
	if proj.ProceedingTypeID == nil {
		return nil, fmt.Errorf("%w: project has no proceeding type set", ErrInvalidInput)
	}
	if in.RuleCode == "" {
		return nil, fmt.Errorf("%w: rule_code is required", ErrInvalidInput)
	}
	if in.ActualDate.IsZero() {
		return nil, fmt.Errorf("%w: actual_date is required", ErrInvalidInput)
	}
	if err := s.deadlines.assertCanAdminProject(ctx, userID, projectID); err != nil {
		return nil, err
	}

	rule, err := s.lookupRuleBySubmissionCode(ctx, *proj.ProceedingTypeID, in.RuleCode)
	if err != nil {
		return nil, err
	}

	// Sequence guard. The rule's parent_id is the dependency anchor for
	// reflow; we reject the write when the parent has no anchored actual
	// for this project. v1 rejects without a confirm-and-write override
	// (per brief defaults locked above the impl).
	if rule.ParentID != nil {
		parentRule, err := s.lookupRuleByID(ctx, *rule.ParentID)
		if err != nil {
			return nil, fmt.Errorf("lookup parent rule: %w", err)
		}
		anchored, err := s.parentHasAnchoredActual(ctx, projectID, *rule.ParentID)
		if err != nil {
			return nil, fmt.Errorf("check parent anchor: %w", err)
		}
		if !anchored {
			parentCode := ""
			if parentRule.SubmissionCode != nil {
				parentCode = *parentRule.SubmissionCode
			}
			return nil, &PredecessorMissingError{
				MissingRuleCode:     parentCode,
				MissingRuleNameDE:   parentRule.Name,
				MissingRuleNameEN:   parentRule.NameEN,
				RequestedRuleCode:   in.RuleCode,
				RequestedRuleNameDE: rule.Name,
				RequestedRuleNameEN: rule.NameEN,
			}
		}
	}

	kind := strings.ToLower(strings.TrimSpace(in.Kind))
	if kind == "" {
		kind = ruleAnchorKind(rule)
	}

	switch kind {
	case "appointment":
		return s.upsertAnchorAppointment(ctx, userID, projectID, rule, in.ActualDate)
	case "deadline":
		return s.upsertAnchorDeadline(ctx, userID, projectID, rule, in.ActualDate)
	default:
		return nil, fmt.Errorf("%w: unknown kind %q", ErrInvalidInput, kind)
	}
}

// RecordRuleSkipped marks a projected rule as "ist nicht eingetreten /
// wurde verschoben" (§6.4). Writes a paliad.project_events row with
// event_type='rule_skipped', metadata={rule_code, reason}; computeProjections
// uses the rule_code to drop the matching projected row from future reads.
func (s *ProjectionService) RecordRuleSkipped(ctx context.Context, userID, projectID uuid.UUID, ruleCode, reason string) error {
	if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
		return err
	}
	if err := s.deadlines.assertCanAdminProject(ctx, userID, projectID); err != nil {
		return err
	}
	if ruleCode == "" {
		return fmt.Errorf("%w: rule_code is required", ErrInvalidInput)
	}

	id := uuid.New()
	now := time.Now().UTC()
	meta := map[string]any{"rule_code": ruleCode}
	if reason != "" {
		meta["reason"] = reason
	}
	mb, _ := json.Marshal(meta)

	_, err := s.db.ExecContext(ctx,
		`INSERT INTO paliad.project_events
		   (id, project_id, event_type, title, description, event_date,
		    created_by, metadata, created_at, updated_at, timeline_kind)
		 VALUES ($1, $2, 'rule_skipped', $3, $4, $5, $6, $7::jsonb, $5, $5, NULL)`,
		id, projectID, "Regel übersprungen: "+ruleCode, nilIfEmpty(reason), now, userID, string(mb))
	if err != nil {
		return fmt.Errorf("insert rule_skipped: %w", err)
	}
	return nil
}

// lookupRuleBySubmissionCode resolves (proceeding_type_id, submission_code)
// → DeadlineRule.
func (s *ProjectionService) lookupRuleBySubmissionCode(ctx context.Context, ptID int, code string) (*models.DeadlineRule, error) {
	var rule models.DeadlineRule
	err := s.db.GetContext(ctx, &rule,
		`SELECT `+ruleColumns+`
		   FROM paliad.deadline_rules
		  WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
		ptID, code)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("%w: unknown submission_code %q", ErrInvalidInput, code)
	}
	if err != nil {
		return nil, fmt.Errorf("lookup rule by submission_code: %w", err)
	}
	return &rule, nil
}

// lookupRuleByID resolves a rule by UUID.
func (s *ProjectionService) lookupRuleByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
	var rule models.DeadlineRule
	err := s.db.GetContext(ctx, &rule,
		`SELECT `+ruleColumns+`
		   FROM paliad.deadline_rules
		  WHERE id = $1`, id)
	if err != nil {
		return nil, fmt.Errorf("lookup rule by id: %w", err)
	}
	return &rule, nil
}

// parentHasAnchoredActual returns true when paliad.deadlines OR
// paliad.appointments has a row tied to parentRuleID for this project
// AND that row represents a real anchor (deadline.completed_at /
// deadline.status='completed' / source='anchor', appointment present).
//
// Pending deadlines (status='pending') don't count as anchors — the user
// hasn't recorded the actual yet, and downstream reflow needs a concrete
// date.
func (s *ProjectionService) parentHasAnchoredActual(ctx context.Context, projectID, parentRuleID uuid.UUID) (bool, error) {
	var count int
	err := s.db.GetContext(ctx, &count, `
		SELECT COUNT(*) FROM (
			SELECT 1 FROM paliad.deadlines
			 WHERE project_id = $1 AND rule_id = $2
			   AND (completed_at IS NOT NULL
			        OR status = 'completed'
			        OR source = 'anchor')
			UNION ALL
			SELECT 1 FROM paliad.appointments
			 WHERE project_id = $1 AND deadline_rule_id = $2
		) t`, projectID, parentRuleID)
	if err != nil {
		return false, err
	}
	return count > 0, nil
}

// ruleAnchorKind picks the write kind for a rule based on its event_type.
// Court-set rules (hearing / decision / order) anchor as appointments;
// everything else anchors as a deadline. The brief locks this default.
func ruleAnchorKind(rule *models.DeadlineRule) string {
	if rule == nil || rule.EventType == nil {
		return "deadline"
	}
	switch *rule.EventType {
	case "hearing", "decision", "order":
		return "appointment"
	}
	return "deadline"
}

// upsertAnchorDeadline inserts or PATCHes a paliad.deadlines row for the
// (project_id, rule_id) pair. Idempotent: re-anchoring the same rule
// updates due_date + completed_at instead of double-inserting.
func (s *ProjectionService) upsertAnchorDeadline(ctx context.Context, userID, projectID uuid.UUID, rule *models.DeadlineRule, actual time.Time) (*AnchorResult, error) {
	dateStr := actual.UTC().Format("2006-01-02")
	now := time.Now().UTC()

	var existingID uuid.UUID
	err := s.db.GetContext(ctx, &existingID,
		`SELECT id FROM paliad.deadlines
		  WHERE project_id = $1 AND rule_id = $2
		  ORDER BY created_at ASC
		  LIMIT 1`, projectID, rule.ID)
	switch {
	case errors.Is(err, sql.ErrNoRows):
		// fresh insert
	case err != nil:
		return nil, fmt.Errorf("check existing deadline: %w", err)
	default:
		// PATCH — flip to status=done, source='anchor', due_date=actual.
		_, err := s.db.ExecContext(ctx, `
			UPDATE paliad.deadlines
			   SET due_date = $1::date,
			       completed_at = $2,
			       status = 'completed',
			       source = 'anchor',
			       updated_at = $2
			 WHERE id = $3`, dateStr, now, existingID)
		if err != nil {
			return nil, fmt.Errorf("patch deadline anchor: %w", err)
		}
		return &AnchorResult{DeadlineID: &existingID, Updated: true}, nil
	}

	id := uuid.New()
	title := rule.Name
	ruleCode := ""
	if rule.SubmissionCode != nil {
		ruleCode = *rule.SubmissionCode
	}
	_, err = s.db.ExecContext(ctx, `
		INSERT INTO paliad.deadlines
		    (id, project_id, title, due_date, original_due_date,
		     source, rule_id, rule_code, status, completed_at,
		     created_by, created_at, updated_at,
		     approval_status)
		VALUES ($1, $2, $3, $4::date, $4::date,
		        'anchor', $5, NULLIF($6, ''), 'completed', $7,
		        $8, $7, $7,
		        'approved')`,
		id, projectID, title, dateStr, rule.ID, ruleCode, now, userID)
	if err != nil {
		return nil, fmt.Errorf("insert deadline anchor: %w", err)
	}
	return &AnchorResult{DeadlineID: &id, Updated: false}, nil
}

// upsertAnchorAppointment inserts or PATCHes a paliad.appointments row
// for the (project_id, deadline_rule_id) pair. Mirror of upsertAnchorDeadline
// for court-set rules (hearing / decision / order).
func (s *ProjectionService) upsertAnchorAppointment(ctx context.Context, userID, projectID uuid.UUID, rule *models.DeadlineRule, actual time.Time) (*AnchorResult, error) {
	now := time.Now().UTC()
	apptType := "hearing"
	if rule.EventType != nil {
		switch *rule.EventType {
		case "decision":
			apptType = "deadline_hearing" // closest match in the existing CHECK; no 'decision' value yet
		case "order":
			apptType = "deadline_hearing"
		case "hearing":
			apptType = "hearing"
		}
	}

	var existingID uuid.UUID
	err := s.db.GetContext(ctx, &existingID,
		`SELECT id FROM paliad.appointments
		  WHERE project_id = $1 AND deadline_rule_id = $2
		  ORDER BY created_at ASC
		  LIMIT 1`, projectID, rule.ID)
	switch {
	case errors.Is(err, sql.ErrNoRows):
		// fresh insert
	case err != nil:
		return nil, fmt.Errorf("check existing appointment: %w", err)
	default:
		_, err := s.db.ExecContext(ctx, `
			UPDATE paliad.appointments
			   SET start_at = $1,
			       updated_at = $2
			 WHERE id = $3`, actual.UTC(), now, existingID)
		if err != nil {
			return nil, fmt.Errorf("patch appointment anchor: %w", err)
		}
		return &AnchorResult{AppointmentID: &existingID, Updated: true}, nil
	}

	id := uuid.New()
	_, err = s.db.ExecContext(ctx, `
		INSERT INTO paliad.appointments
		    (id, project_id, title, start_at, appointment_type,
		     deadline_rule_id, created_by, created_at, updated_at,
		     approval_status)
		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, 'approved')`,
		id, projectID, rule.Name, actual.UTC(), apptType, rule.ID, userID, now)
	if err != nil {
		return nil, fmt.Errorf("insert appointment anchor: %w", err)
	}
	return &AnchorResult{AppointmentID: &id, Updated: false}, nil
}

// annotateDependsOn fills DependsOnRuleCode/Date/Name on every row that
// has a DeadlineRuleID by walking the rule's parent_id chain. The parent
// date prefers any actual date already on a sibling row (so the user
// sees real chains), falling back to the projected date for that rule.
func (s *ProjectionService) annotateDependsOn(rows []TimelineEvent, rules []models.DeadlineRule, lang string) {
	if len(rules) == 0 {
		return
	}
	ruleByID := make(map[uuid.UUID]models.DeadlineRule, len(rules))
	for _, r := range rules {
		ruleByID[r.ID] = r
	}

	dateByRuleID := make(map[uuid.UUID]time.Time, len(rows))
	for _, r := range rows {
		if r.DeadlineRuleID == nil || r.Date == nil {
			continue
		}
		// Prefer actuals over projections — when both exist for the
		// same rule (shouldn't, but defensive), the actual wins because
		// it's recorded reality.
		if r.Kind != "projected" || dateByRuleID[*r.DeadlineRuleID].IsZero() {
			dateByRuleID[*r.DeadlineRuleID] = *r.Date
		}
	}

	for i := range rows {
		ev := &rows[i]
		if ev.DeadlineRuleID == nil {
			continue
		}
		rule, ok := ruleByID[*ev.DeadlineRuleID]
		if !ok || rule.ParentID == nil {
			continue
		}
		parent, ok := ruleByID[*rule.ParentID]
		if !ok {
			continue
		}
		if parent.SubmissionCode != nil {
			ev.DependsOnRuleCode = *parent.SubmissionCode
		}
		ev.DependsOnRuleName = ruleNameInLang(parent, lang)
		if dt, ok := dateByRuleID[parent.ID]; ok && !dt.IsZero() {
			d := dt
			ev.DependsOnDate = &d
		}
	}
}

// ----------------------------------------------------------------------
// Pure helpers — kept package-private and testable without DB.
// ----------------------------------------------------------------------

// applyLookaheadDefault clamps the user-supplied cap to [1, MaxLookaheadCap]
// or returns DefaultLookaheadCap when the input is zero.
func applyLookaheadDefault(n int) int {
	if n <= 0 {
		return DefaultLookaheadCap
	}
	if n > MaxLookaheadCap {
		return MaxLookaheadCap
	}
	return n
}

// applyLookaheadCap drops future predicted rows beyond cap. Returns the
// trimmed slice + counts. Predicted-overdue rows + court-set rows are
// exempt from the cap.
func applyLookaheadCap(rows []TimelineEvent, cap int) (kept []TimelineEvent, projTotal, projShown, overdueCount int) {
	if cap <= 0 {
		cap = DefaultLookaheadCap
	}

	// Sort future predicted by date ASC so "next 7" is deterministic.
	type indexed struct {
		ev  TimelineEvent
		key string
	}
	var futurePred []indexed
	other := make([]TimelineEvent, 0, len(rows))
	for _, r := range rows {
		if r.Status == "predicted_overdue" {
			overdueCount++
			other = append(other, r)
			continue
		}
		if r.Status == "predicted" && r.Date != nil {
			projTotal++
			key := r.Date.Format("2006-01-02") + "|" + r.RuleCode + "|" + r.Title
			futurePred = append(futurePred, indexed{ev: r, key: key})
			continue
		}
		// court_set, undated, or non-projection — pass through.
		other = append(other, r)
	}
	sort.SliceStable(futurePred, func(i, j int) bool {
		return futurePred[i].key < futurePred[j].key
	})
	if len(futurePred) > cap {
		futurePred = futurePred[:cap]
	}
	projShown = len(futurePred)

	kept = other
	for _, p := range futurePred {
		kept = append(kept, p.ev)
	}
	return
}

// flagsForProject builds the CalcOptions.Flags set from the project's
// metadata. Slice 2 honours the existing condition_flag system (with_ccr
// etc.); Slice 3 will surface this via project columns once the CCR
// sub-project FK lands. For now, no flags are set unless the project
// metadata explicitly carries them.
func flagsForProject(p *models.Project) []string {
	var flags []string
	if len(p.Metadata) == 0 {
		return flags
	}
	var meta map[string]any
	if err := json.Unmarshal(p.Metadata, &meta); err != nil {
		return flags
	}
	if raw, ok := meta["fristen_flags"]; ok {
		if arr, ok := raw.([]any); ok {
			for _, v := range arr {
				if s, ok := v.(string); ok && s != "" {
					flags = append(flags, s)
				}
			}
		}
	}
	return flags
}

// ruleDisplayName picks a display name for a projected row. Prefers the
// rule's name in the requested language; falls back to the calculator's
// UI name (which already handles translation in some cases).
func ruleDisplayName(rule models.DeadlineRule, ui UIDeadline, lang string) string {
	if name := ruleNameInLang(rule, lang); name != "" {
		return name
	}
	if lang == "en" && ui.NameEN != "" {
		return ui.NameEN
	}
	return ui.Name
}

func ruleNameInLang(rule models.DeadlineRule, lang string) string {
	if lang == "en" && rule.NameEN != "" {
		return rule.NameEN
	}
	return rule.Name
}

// lang normalises the option string. Empty / unknown defaults to "de"
// (Paliad's frontend default).
func lang(s string) string {
	switch strings.ToLower(strings.TrimSpace(s)) {
	case "en":
		return "en"
	default:
		return "de"
	}
}

// extractMetadataString reads a top-level string field from a JSON-encoded
// metadata blob. Used to pull rule_code out of paliad.project_events.metadata
// for rule_skipped rows.
func extractMetadataString(raw json.RawMessage, key string) string {
	if len(raw) == 0 {
		return ""
	}
	var m map[string]any
	if err := json.Unmarshal(raw, &m); err != nil {
		return ""
	}
	if v, ok := m[key]; ok {
		if s, ok := v.(string); ok {
			return s
		}
	}
	return ""
}

// scopeProjectIDFilter renders a where-clause fragment for "project_id
// matches projectID exactly" (DirectOnly) or "project_id is in projectID's
// path" (subtree). Always uses positional $1 for projectID — the caller
// passes projectID as the first argument.
func scopeProjectIDFilter(alias, column string, _ uuid.UUID, directOnly bool) string {
	if directOnly {
		return fmt.Sprintf("%s.%s = $1", alias, column)
	}
	return fmt.Sprintf(
		`%s.%s IN (
			SELECT id FROM paliad.projects
			 WHERE $1 = ANY(string_to_array(path, '.')::uuid[])
		)`, alias, column)
}

func nilIfEmpty(s string) any {
	if s == "" {
		return nil
	}
	return s
}

// deadlineStatus maps the deadline row's status + due date into the
// SmartTimeline status vocabulary.
func deadlineStatus(rawStatus string, due time.Time) string {
	if rawStatus == "completed" {
		return "done"
	}
	today := startOfUTCDay(time.Now().UTC())
	dueDay := startOfUTCDay(due)
	if dueDay.Before(today) {
		return "overdue"
	}
	return "open"
}

// appointmentStatus is "done" once the start has passed, "open" otherwise.
func appointmentStatus(start, now time.Time) string {
	if start.Before(now) {
		return "done"
	}
	return "open"
}

// milestoneStatus picks a status for project_event timeline rows.
func milestoneStatus(timelineKind, eventType *string) string {
	if timelineKind != nil && *timelineKind == "custom_milestone" {
		return "off_script"
	}
	if eventType != nil && *eventType == "custom_milestone" {
		return "off_script"
	}
	return "done"
}

// sortTimeline orders rows by Date ASC with undated rows pinned at the
// end; ties break on actuals-first, then kindOrder, then title, then
// provenance id so the order is fully deterministic across requests.
func sortTimeline(rows []TimelineEvent) {
	sort.SliceStable(rows, func(i, j int) bool {
		di, dj := rows[i].Date, rows[j].Date
		if di == nil && dj == nil {
			return timelineTiebreak(rows[i], rows[j])
		}
		if di == nil {
			return false
		}
		if dj == nil {
			return true
		}
		if di.Equal(*dj) {
			return timelineTiebreak(rows[i], rows[j])
		}
		return di.Before(*dj)
	})
}

// timelineTiebreak: actuals before projections of the same date (deadline
// > appointment > milestone > projected); same-kind ties fall back to
// title then to the stringified provenance UUID.
func timelineTiebreak(a, b TimelineEvent) bool {
	if a.Kind != b.Kind {
		return kindOrder(a.Kind) < kindOrder(b.Kind)
	}
	if a.Title != b.Title {
		return a.Title < b.Title
	}
	return timelineRowID(a) < timelineRowID(b)
}

func kindOrder(kind string) int {
	switch kind {
	case "deadline":
		return 0
	case "appointment":
		return 1
	case "milestone":
		return 2
	case "projected":
		return 3
	}
	return 4
}

func timelineRowID(ev TimelineEvent) string {
	switch {
	case ev.DeadlineID != nil:
		return ev.DeadlineID.String()
	case ev.AppointmentID != nil:
		return ev.AppointmentID.String()
	case ev.ProjectEventID != nil:
		return ev.ProjectEventID.String()
	case ev.DeadlineRuleID != nil:
		return ev.DeadlineRuleID.String()
	}
	return ""
}

func startOfUTCDay(t time.Time) time.Time {
	t = t.UTC()
	return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}

func timePtr(t time.Time) *time.Time { return &t }
