package litigationplanner

import (
	"context"
	"encoding/json"
	"fmt"
	"sort"
	"time"

	"github.com/google/uuid"
)

// Calculate renders the full UI timeline for a proceeding type + trigger date.
// Preserves the pre-Phase-C in-memory calculator's classification:
//
//   - Rules with duration_value = 0 and no parent_id → IsRootEvent
//     (due date = trigger date)
//   - Rules with duration_value = 0 and a parent_id → IsCourtSet
//     (due date empty, UI shows "court-set" placeholder)
//   - All other rules → calculate from either the trigger date (no parent)
//     or the previously-computed date for their parent rule.
//
// Audit-driven extensions:
//
//   - opts.Flags can flip flag-conditioned rules onto their alt_* values
//     (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr").
//   - opts.PriorityDateStr overrides the anchor for rules with
//     anchor_alt='priority_date' (e.g. epa.grant.exa publication date
//     is 18mo from priority, not filing).
//   - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the
//     caller redirect a downstream rule's parent anchor to a user-set
//     date.
func Calculate(
	ctx context.Context,
	proceedingCode string,
	triggerDateStr string,
	opts CalcOptions,
	catalog Catalog,
	holidays HolidayCalendar,
	courts CourtRegistry,
) (*Timeline, error) {
	// Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven
	// branch (Pipeline-C unified rules). proceedingCode is ignored on
	// this path.
	if opts.TriggerEventIDFilter != nil {
		return calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts, catalog, holidays, courts)
	}

	triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
	if err != nil {
		return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
	}

	var priorityDate *time.Time
	if opts.PriorityDateStr != "" {
		pd, err := time.Parse("2006-01-02", opts.PriorityDateStr)
		if err != nil {
			return nil, fmt.Errorf("invalid priority date %q: %w", opts.PriorityDateStr, err)
		}
		priorityDate = &pd
	}
	flagSet := make(map[string]struct{}, len(opts.Flags))
	for _, f := range opts.Flags {
		flagSet[f] = struct{}{}
	}
	// v1 simplification (t-paliad-265): when any IncludeCCRFor entry
	// exists, we treat with_ccr as set in the flag context.
	if len(opts.IncludeCCRFor) > 0 {
		flagSet["with_ccr"] = struct{}{}
	}

	// Parse anchor overrides up-front so a malformed date errors out
	// before we start walking rules.
	overrideDates := make(map[string]time.Time, len(opts.AnchorOverrides))
	for code, dateStr := range opts.AnchorOverrides {
		od, err := time.Parse("2006-01-02", dateStr)
		if err != nil {
			return nil, fmt.Errorf("invalid anchor override for %q (%q): %w", code, dateStr, err)
		}
		overrideDates[code] = od
	}

	// Trigger-event anchors keyed by paliad.trigger_events.code
	// (t-paliad-342). Parsed up-front so malformed dates error before
	// the rule walk. When a rule has trigger_event_id set, the engine
	// looks up triggerAnchorByCode[trigger_event.code] for the
	// semantic anchor instead of falling back to the proceeding's
	// trigger date.
	triggerAnchorByCode := make(map[string]time.Time, len(opts.TriggerEventAnchors))
	for code, dateStr := range opts.TriggerEventAnchors {
		td, err := time.Parse("2006-01-02", dateStr)
		if err != nil {
			return nil, fmt.Errorf("invalid trigger event anchor for %q (%q): %w", code, dateStr, err)
		}
		triggerAnchorByCode[code] = td
	}

	// Look up proceeding type metadata.
	pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
	if err != nil {
		return nil, err
	}

	// Sub-track routing (m/paliad#58). When the user picks a proceeding
	// that has no native rules and is normally a sub-track of another
	// proceeding (today: upc.ccr.cfi → upc.inf.cfi + with_ccr), route
	// rule lookup to the parent and merge the default flags into the
	// user's flag set. The response identity stays on the user-picked
	// proceeding so the page header still reads "Counterclaim for
	// Revocation", but the timeline body is the parent's full flow with
	// the sub-track flag enabled.
	var subTrackNote SubTrackRouting
	var hasSubTrackNote bool
	pt := pickedProceeding
	if route, ok := LookupSubTrackRouting(proceedingCode); ok {
		subTrackNote = route
		hasSubTrackNote = true
		parentPt, parentRules, err := catalog.LoadProceeding(ctx, route.ParentCode, opts.ProjectHint)
		if err != nil {
			return nil, fmt.Errorf("sub-track %q routes to %q which is not active: %w", proceedingCode, route.ParentCode, err)
		}
		pt = parentPt
		rules = parentRules
		// Merge default flags into the user's flag set so the gated
		// rules render. User-supplied flags win on conflict.
		for _, f := range route.DefaultFlags {
			if _, exists := flagSet[f]; !exists {
				flagSet[f] = struct{}{}
			}
		}
	}

	// Resolve (country, regime) for non-working-day adjustment. Court
	// wins when supplied; otherwise default by proceeding regime.
	defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
	country, regime, err := courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime)
	if err != nil {
		return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
	}

	if len(opts.RuleOverrides) > 0 {
		rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
	}

	// AppealTarget filter (Slice B1, m/paliad#124 §18.1). When set,
	// keep only rules whose AppliesToTarget contains the requested
	// slug. Unknown slugs short-circuit to no-op (defensive: a stale
	// frontend chip shouldn't break the render). Empty AppliesToTarget
	// on a rule means "doesn't belong to an appeal target" — such a
	// rule is suppressed under any non-empty AppealTarget filter.
	if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
		filtered := make([]Rule, 0, len(rules))
		for _, r := range rules {
			for _, t := range r.AppliesToTarget {
				if t == opts.AppealTarget {
					filtered = append(filtered, r)
					break
				}
			}
		}
		rules = filtered
	}

	// ruleByID lets the conditional-rendering branches resolve a parent
	// rule's display fields (submission_code, name, name_en) for the
	// "abhängig von <ParentRuleName>" chip without re-scanning the rules
	// slice on every iteration. (t-paliad-289)
	ruleByID := make(map[uuid.UUID]Rule, len(rules))
	for _, r := range rules {
		ruleByID[r.ID] = r
	}

	// triggerEventByID powers the trigger-event override on the
	// conditional-label chip (m/paliad#126 / t-paliad-294). When a rule
	// carries a real paliad.trigger_events row, that catalog event —
	// not the rule's parent_id — is the rule's actual semantic anchor.
	// The override fires below when stamping ParentRule* on the wire so
	// the chip reads e.g. "abhängig von Antrag auf Vertraulichkeit
	// gegenüber der Öffentlichkeit" for R.262(2) — instead of the
	// (misleading) parent_id-derived "abhängig von Klageerhebung".
	//
	// Bulk-loaded in one round-trip; trees in the live corpus carry at
	// most a handful of trigger_event_id-bearing rules (2 today on
	// upc.inf.cfi), so the IN(...) is small.
	var triggerIDs []int64
	seenTrigger := make(map[int64]struct{}, len(rules))
	for _, r := range rules {
		if r.TriggerEventID == nil {
			continue
		}
		if _, ok := seenTrigger[*r.TriggerEventID]; ok {
			continue
		}
		seenTrigger[*r.TriggerEventID] = struct{}{}
		triggerIDs = append(triggerIDs, *r.TriggerEventID)
	}
	triggerEventByID, err := catalog.LoadTriggerEventsByIDs(ctx, triggerIDs)
	if err != nil {
		return nil, fmt.Errorf("load trigger events for conditional labels: %w", err)
	}

	// Walk the rule list in TOPOLOGICAL order (parents before children),
	// not the raw sequence_order order from the catalog. The catalog
	// returns rules sorted by sequence_order, which is the chronological/
	// display order. That order is parent-first for the common
	// timing='after' case but parent-LAST for timing='before' children
	// (e.g. upc.inf.cfi.translation_request at seq=45 vs its parent
	// upc.inf.cfi.oral at seq=50 — m/paliad#135). Without topological
	// ordering the parent-state checks below (courtSet[parent] /
	// computed[parent_code]) read stale empty maps when a child appears
	// before its parent, and the engine falls back to the trigger date
	// → fabricates dates before the SoC.
	//
	// Original sequence_order is restored at the end of the walk so the
	// wire shape and the timeline view's render order stay identical to
	// the legacy behaviour modulo the bug fix.
	sequenceIndex := make(map[uuid.UUID]int, len(rules))
	for i, r := range rules {
		sequenceIndex[r.ID] = i
	}
	walkRules := topoSortByParentDepth(rules)

	computed := make(map[string]time.Time, len(rules))
	courtSet := make(map[uuid.UUID]bool, len(rules))
	deadlines := make([]TimelineEntry, 0, len(rules))

	skipRules := opts.SkipRules
	perCardAppellant := opts.PerCardAppellant
	skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
	hiddenCount := 0
	rulesAwaitingAnchor := 0
	appellantContext := make(map[uuid.UUID]string, len(rules))

	for _, r := range walkRules {
		// Phase-3 unified gate: evaluate condition_expr (jsonb).
		// Suppression semantic preserved: when the gate fires false
		// AND no alt_* values exist, the rule is dropped from the
		// timeline entirely (purely conditional). When alt_* values
		// exist, the gate-false branch still renders, just without
		// the alt-swap.
		gateMet := EvalConditionExpr([]byte(r.ConditionExpr), flagSet)
		if !gateMet && r.AltDurationValue == nil {
			continue
		}

		// Optional-rule suppression (t-paliad-342 / youpcorg#2570).
		// Rules tagged priority='optional' don't auto-fire in the
		// default timeline; the caller opts in via
		// CalcOptions.IncludeOptional. Cascade through skippedIDs so
		// children chaining off the suppressed rule also drop — they
		// can't compute a date against a missing parent.
		if r.Priority == "optional" && !opts.IncludeOptional {
			skippedIDs[r.ID] = struct{}{}
			continue
		}

		// SkipRules suppression (t-paliad-265).
		// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
		// we re-surface the directly-skipped row (faded via IsHidden)
		// instead of dropping it.
		var isHidden bool
		if r.SubmissionCode != nil {
			if _, skipped := skipRules[*r.SubmissionCode]; skipped {
				hiddenCount++
				if !opts.IncludeHidden {
					skippedIDs[r.ID] = struct{}{}
					continue
				}
				isHidden = true
			}
		}
		if r.ParentID != nil {
			if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped {
				skippedIDs[r.ID] = struct{}{}
				continue
			}
		}

		// AppellantContext propagation. A rule with its own
		// PerCardAppellant pick stamps its UUID with that value.
		// Otherwise inherit from parent if the parent had a context.
		var ctxVal string
		if r.SubmissionCode != nil {
			if v, ok := perCardAppellant[*r.SubmissionCode]; ok {
				ctxVal = v
			}
		}
		if ctxVal == "" && r.ParentID != nil {
			if v, ok := appellantContext[*r.ParentID]; ok {
				ctxVal = v
			}
		}
		if ctxVal != "" {
			appellantContext[r.ID] = ctxVal
		}

		ruleTiming := ""
		if r.Timing != nil {
			ruleTiming = *r.Timing
		}
		d := TimelineEntry{
			RuleID:           r.ID.String(),
			Name:             r.Name,
			NameEN:           r.NameEN,
			Priority:         r.Priority,
			ConditionExpr:    json.RawMessage(r.ConditionExpr),
			AppellantContext: ctxVal,
			ChoicesOffered:   json.RawMessage(r.ChoicesOffered),
			IsHidden:         isHidden,
			DurationValue:    r.DurationValue,
			DurationUnit:     r.DurationUnit,
			Timing:           ruleTiming,
		}
		if r.SubmissionCode != nil {
			d.Code = *r.SubmissionCode
		}
		if r.PrimaryParty != nil {
			d.Party = *r.PrimaryParty
		}
		if r.RuleCode != nil {
			d.RuleRef = *r.RuleCode
		}
		if r.LegalSource != nil {
			d.LegalSource = *r.LegalSource
			d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
			d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
		}
		if r.DeadlineNotes != nil {
			d.Notes = *r.DeadlineNotes
		}
		if r.DeadlineNotesEn != nil {
			d.NotesEN = *r.DeadlineNotesEn
		}

		// Resolve the parent rule once so every conditional-rendering
		// branch (incl. the optional-not-recorded path below) can stamp
		// ParentRule* on the wire without re-scanning. Populated even
		// for non-conditional rows — the frontend dependency-footer
		// ("Folgt aus …") already consumes this on regular projected
		// rows. (t-paliad-289)
		var parentRule *Rule
		if r.ParentID != nil {
			if pr, ok := ruleByID[*r.ParentID]; ok {
				parentRule = &pr
				if pr.SubmissionCode != nil {
					d.ParentRuleCode = *pr.SubmissionCode
				}
				d.ParentRuleName = pr.Name
				d.ParentRuleNameEN = pr.NameEN
			}
		}

		// Trigger-event override on the user-facing dependency identity
		// (m/paliad#126 / t-paliad-294). When a rule has a real
		// trigger_event_id, that catalog event is the actual semantic
		// anchor — not the parent_id node, which is only the calc-time
		// arithmetic anchor. Only the user-facing wire fields shift
		// here; the calc-time anchor logic for trigger_event_id rules
		// lives just below.
		var triggerEventAnchor time.Time
		var hasTriggerEventAnchor bool
		if r.TriggerEventID != nil {
			if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
				d.ParentRuleCode = te.Code
				d.ParentRuleName = te.NameDE
				d.ParentRuleNameEN = te.Name
				if td, ok := triggerAnchorByCode[te.Code]; ok {
					triggerEventAnchor = td
					hasTriggerEventAnchor = true
				}
			}

			// Trigger-event semantic-anchor suppression (t-paliad-342 /
			// youpcorg#2568). When a rule has an explicit trigger_event_id
			// but the caller hasn't supplied a date for that event via
			// CalcOptions.TriggerEventAnchors, the engine refuses to
			// fabricate a date off the proceeding's trigger date — the
			// rule's semantic anchor is the event itself, not the SoC.
			// Render IsConditional with empty dates and propagate via
			// courtSet so descendants chaining off this rule also surface
			// as conditional rather than projecting fictional dates.
			if !hasTriggerEventAnchor {
				d.IsConditional = true
				d.IsCourtSet = true
				d.DueDate = ""
				d.OriginalDate = ""
				courtSet[r.ID] = true
				rulesAwaitingAnchor++
				if r.SubmissionCode != nil {
					skippedIDs[r.ID] = struct{}{}
				}
				deadlines = append(deadlines, d)
				continue
			}
		}

		// Propagate court-set status from a parent rule whose date the
		// court determines: if the anchor itself has no real date,
		// nothing downstream can be computed either — UNLESS the user
		// has supplied an override date for the parent.
		parentOverridden := false
		if r.ParentID != nil && courtSet[*r.ParentID] {
			for _, prev := range rules {
				if prev.ID == *r.ParentID {
					if prev.SubmissionCode != nil {
						if _, ok := overrideDates[*prev.SubmissionCode]; ok {
							parentOverridden = true
						}
					}
					break
				}
			}
		}
		parentIsCourtSet := r.ParentID != nil && courtSet[*r.ParentID] && !parentOverridden

		// Zero-duration rules fall into one of four buckets:
		//   1. parent=nil, not court-determined → IsRootEvent (trigger anchor)
		//   2. parent=nil, court-determined → IsCourtSet
		//   3. parent set, court-determined → IsCourtSet (waypoint)
		//   4. parent set, NOT court-determined → "filed-with-parent"
		//
		// AnchorOverrides: when the user has set a date for any zero-
		// duration rule, that override wins over both the court-set
		// placeholder and the parent-inheritance.
		if r.DurationValue == 0 {
			if r.SubmissionCode != nil {
				if ov, ok := overrideDates[*r.SubmissionCode]; ok {
					d.DueDate = ov.Format("2006-01-02")
					d.OriginalDate = d.DueDate
					d.IsOverridden = true
					computed[*r.SubmissionCode] = ov
					deadlines = append(deadlines, d)
					continue
				}
			}

			// Trigger-event anchor wins over the bucket logic below: a
			// zero-duration rule with trigger_event_id is "occurs on the
			// trigger event's date". Anchor missing was already caught
			// above (suppression branch).
			if hasTriggerEventAnchor {
				d.DueDate = triggerEventAnchor.Format("2006-01-02")
				d.OriginalDate = d.DueDate
				if r.SubmissionCode != nil {
					computed[*r.SubmissionCode] = triggerEventAnchor
				}
				deadlines = append(deadlines, d)
				continue
			}

			if r.ParentID == nil && !r.IsCourtSet {
				// Bucket 1: timeline anchor.
				d.IsRootEvent = true
				d.DueDate = triggerDateStr
				d.OriginalDate = triggerDateStr
				if r.SubmissionCode != nil {
					computed[*r.SubmissionCode] = triggerDate
				}
			} else if r.ParentID != nil && !r.IsCourtSet {
				// Bucket 4: filed-with-parent. Inherit parent's date.
				if parentIsCourtSet {
					// Indirect: rule isn't itself court-determined,
					// it's blocked because its parent is.
					d.IsCourtSet = true
					d.IsCourtSetIndirect = true
					d.IsConditional = true
					d.DueDate = ""
					d.OriginalDate = ""
					courtSet[r.ID] = true
				} else {
					var parentDate time.Time
					var haveParentDate bool
					for _, prev := range rules {
						if prev.ID == *r.ParentID {
							if prev.SubmissionCode != nil {
								if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
									parentDate = ov
									haveParentDate = true
								} else if ref, ok := computed[*prev.SubmissionCode]; ok {
									parentDate = ref
									haveParentDate = true
								}
							}
							break
						}
					}
					if haveParentDate {
						d.DueDate = parentDate.Format("2006-01-02")
						d.OriginalDate = d.DueDate
						if r.SubmissionCode != nil {
							computed[*r.SubmissionCode] = parentDate
						}
					} else {
						// Parent not yet computed (defensive).
						d.IsCourtSet = true
						d.IsCourtSetIndirect = true
						d.IsConditional = true
						d.DueDate = ""
						d.OriginalDate = ""
						courtSet[r.ID] = true
					}
				}
			} else {
				// Buckets 2 + 3: court-determined directly.
				d.IsCourtSet = true
				d.DueDate = ""
				d.OriginalDate = ""
				courtSet[r.ID] = true
			}
			deadlines = append(deadlines, d)
			continue
		}

		// If the parent is court-determined and not overridden we have
		// no real anchor date; surface this rule as court-set too
		// rather than fabricating one off the trigger date. IsConditional
		// surfaces the "abhängig von <ParentRuleName>" UX (t-paliad-289).
		if parentIsCourtSet {
			d.IsCourtSet = true
			d.IsCourtSetIndirect = true
			d.IsConditional = true
			d.DueDate = ""
			d.OriginalDate = ""
			courtSet[r.ID] = true
			deadlines = append(deadlines, d)
			continue
		}

		// Anchor priority:
		//   1. trigger_event_id semantic anchor (t-paliad-342) — when
		//      the rule has trigger_event_id and the caller supplied a
		//      date in TriggerEventAnchors, that date wins over the
		//      parent chain AND the priority_date alt-anchor. The
		//      missing-anchor case was already short-circuited above.
		//   2. priority_date alt-anchor (epa.grant.exa publish).
		//   3. parent's computed date (or user override).
		//   4. proceeding trigger date (default fallback).
		baseDate := triggerDate
		if hasTriggerEventAnchor {
			baseDate = triggerEventAnchor
		} else if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
			baseDate = *priorityDate
		} else if r.ParentID != nil {
			for _, prev := range rules {
				if prev.ID == *r.ParentID {
					if prev.SubmissionCode != nil {
						if ov, ok := overrideDates[*prev.SubmissionCode]; ok {
							baseDate = ov
						} else if ref, ok := computed[*prev.SubmissionCode]; ok {
							baseDate = ref
						}
					}
					break
				}
			}
		}

		// Flag-conditioned alt-swap (legacy with_ccr pattern): when the
		// gate fires AND alt_* values exist, swap the primary duration
		// to the alt values. This is distinct from combine_op below —
		// alt-swap is a one-or-the-other choice keyed on flags, whereas
		// combine_op computes both legs and picks max/min.
		durationValue := r.DurationValue
		durationUnit := r.DurationUnit
		timing := ""
		if r.Timing != nil {
			timing = *r.Timing
		}
		if r.CombineOp == nil && gateMet && HasConditionExpr(r.ConditionExpr) && r.AltDurationValue != nil {
			durationValue = *r.AltDurationValue
			if r.AltDurationUnit != nil {
				durationUnit = *r.AltDurationUnit
			}
			if r.AltRuleCode != nil {
				d.RuleRef = *r.AltRuleCode
			}
		}

		// User override on this rule: replace the calculated date with
		// the user's date. Skip holiday rollover — the user's date is
		// authoritative.
		if r.SubmissionCode != nil {
			if ov, ok := overrideDates[*r.SubmissionCode]; ok {
				d.OriginalDate = ov.Format("2006-01-02")
				d.DueDate = ov.Format("2006-01-02")
				d.WasAdjusted = false
				d.AdjustmentReason = nil
				d.IsOverridden = true
				computed[*r.SubmissionCode] = ov
				deadlines = append(deadlines, d)
				continue
			}
		}

		origDate, adjusted, wasAdj, reason := ApplyDuration(
			baseDate, durationValue, durationUnit, timing, country, regime, holidays,
		)

		// combine_op composite: compute the alt leg too, apply max/min.
		if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
			altOrig, altAdj, altWasAdj, altReason := ApplyDuration(
				baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
			)
			switch *r.CombineOp {
			case "max":
				if altAdj.After(adjusted) {
					origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
				}
			case "min":
				if altAdj.Before(adjusted) {
					origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
				}
			}
		}

		d.OriginalDate = origDate.Format("2006-01-02")
		d.DueDate = adjusted.Format("2006-01-02")
		d.WasAdjusted = wasAdj
		d.AdjustmentReason = reason

		// Optional-on-the-other-side detection (t-paliad-289 Symptom B).
		// Rules with priority='optional' AND primary_party='both' whose
		// data-model parent is the proceeding's trigger anchor (parent
		// has parent_id=NULL and is not court-set, i.e. the SoC root
		// rule) represent a rule whose REAL triggering event sits
		// outside the rule data — e.g. R.262(2) Erwiderung auf
		// Vertraulichkeitsantrag anchors on SoC in the data, but the
		// real trigger is the opposing party's confidentiality motion
		// which may never happen. Without an explicit anchor on the
		// rule itself, the projection must NOT claim a concrete date.
		if !d.IsOverridden && !d.IsConditional &&
			r.Priority == "optional" &&
			r.PrimaryParty != nil && *r.PrimaryParty == "both" &&
			parentRule != nil && parentRule.ParentID == nil && !parentRule.IsCourtSet {
			d.IsConditional = true
			d.DueDate = ""
			d.OriginalDate = ""
			d.WasAdjusted = false
			d.AdjustmentReason = nil
			// Mark this rule's ID as having an uncertain anchor so
			// rules chaining off it also surface conditional via the
			// parentIsCourtSet path.
			courtSet[r.ID] = true
		}

		if r.SubmissionCode != nil {
			computed[*r.SubmissionCode] = adjusted
		}
		deadlines = append(deadlines, d)
	}

	// Stamp AppealRole on every entry when an appeal-target filter is
	// active so the frontend column-bucketer can route primary_party=
	// 'both' rules into the user-perspective columns
	// (Berufungskläger vs Berufungsbeklagter). Court events stay empty
	// — they route on Party='court' regardless. (t-paliad-307 /
	// m/paliad#136 Bug 1)
	if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
		for i := range deadlines {
			if deadlines[i].Code == "" {
				continue
			}
			deadlines[i].AppealRole = AppealFilerRole(deadlines[i].Code)
		}
	}

	// Restore sequence_order on the output slice. The compute walk
	// re-ordered rules topologically (parent-first) so the parent-state
	// checks resolved correctly; the wire shape and the linear timeline
	// view both rely on sequence_order being the surface render order.
	// (m/paliad#135)
	sort.SliceStable(deadlines, func(i, j int) bool {
		a, errA := uuid.Parse(deadlines[i].RuleID)
		b, errB := uuid.Parse(deadlines[j].RuleID)
		if errA != nil || errB != nil {
			return false
		}
		return sequenceIndex[a] < sequenceIndex[b]
	})

	// t-paliad-296: within consecutive runs of rules sharing the same
	// trigger group (parent_id + trigger_event_id), reorder by duration
	// ascending so optional events following the same anchor render in
	// their likely-sequence order. Different trigger groups keep their
	// proceeding-sequence position — the chunk walk only sorts adjacent
	// same-group rows. Court-set / conditional rows sort LAST.
	sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)

	// Synthetic trigger-event row for appeal timelines (t-paliad-307 /
	// m/paliad#136 Bug 2). The decision being appealed (Endentscheidung
	// R.118, Kostenentscheidung, Anordnung, …) isn't a rule in the
	// upc.apl catalog — it's the anchor the user picked. Lawyers expect
	// it to surface as the first row of the timeline so the chain reads
	// decision → appeal filings → next decision. Emitted only when an
	// appeal_target is in play and the helper returns a non-empty label.
	if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
		nameDE := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de")
		nameEN := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en")
		if nameDE != "" || nameEN != "" {
			trig := TimelineEntry{
				Name:           nameDE,
				NameEN:         nameEN,
				Party:          PrimaryPartyCourt,
				Priority:       "informational",
				DueDate:        triggerDateStr,
				OriginalDate:   triggerDateStr,
				IsRootEvent:    true,
				IsTriggerEvent: true,
			}
			deadlines = append([]TimelineEntry{trig}, deadlines...)
		}
	}

	resp := &Timeline{
		ProceedingType:      pickedProceeding.Code,
		ProceedingName:      pickedProceeding.Name,
		ProceedingNameEN:    pickedProceeding.NameEN,
		TriggerDate:         triggerDateStr,
		Deadlines:           deadlines,
		HiddenCount:         hiddenCount,
		RulesAwaitingAnchor: rulesAwaitingAnchor,
	}
	// Sub-track routing keeps the user-picked proceeding's identity,
	// so the trigger-event label rides on `pickedProceeding`.
	if pickedProceeding.TriggerEventLabelDE != nil {
		resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
	}
	if pickedProceeding.TriggerEventLabelEN != nil {
		resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
	}
	// t-paliad-301 / m/paliad#132 Bug B — appeal_target-driven trigger
	// label. When the request narrows to a specific appeal target, the
	// "Auslösendes Ereignis" label describes the underlying decision
	// (Endentscheidung / Kostenentscheidung / Anordnung /
	// Schadensbemessung / Bucheinsicht) rather than the appeal
	// proceeding itself. Overrides the proceeding's own
	// trigger_event_label set above.
	if opts.AppealTarget != "" {
		if de := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de"); de != "" {
			resp.TriggerEventLabel = de
		}
		if en := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en"); en != "" {
			resp.TriggerEventLabelEN = en
		}
	}
	if hasSubTrackNote {
		resp.ContextualNote = subTrackNote.NoteDE
		resp.ContextualNoteEN = subTrackNote.NoteEN
	}
	return resp, nil
}

// calculateByTriggerEvent renders the Pipeline-C timeline for an event
// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id
// chains), have no flag gating, no priority_date alt-anchor, no party
// classification, and no IsRootEvent / IsCourtSet semantics. The math
// is just: base + (timing-signed) duration → optional alt-leg combine
// → optional weekend/holiday rollover for calendar units.
//
// Timeline.ProceedingType / ProceedingName stay empty —
// EventDeadlineService owns the trigger-event metadata.
func calculateByTriggerEvent(
	ctx context.Context,
	triggerEventID int64,
	triggerDateStr string,
	opts CalcOptions,
	catalog Catalog,
	holidays HolidayCalendar,
	courts CourtRegistry,
) (*Timeline, error) {
	triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
	if err != nil {
		return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
	}

	// Pipeline-C rules originate from youpc's UPC-flavoured deadline
	// corpus — DE / UPC defaults match the legacy EventDeadlineService.
	country, regime, err := courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC)
	if err != nil {
		return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
	}

	rules, err := catalog.LoadRulesByTriggerEvent(ctx, triggerEventID)
	if err != nil {
		return nil, err
	}
	if len(opts.RuleOverrides) > 0 {
		rules = ApplyRuleOverrides(rules, opts.RuleOverrides)
	}

	deadlines := make([]TimelineEntry, 0, len(rules))
	for _, r := range rules {
		timing := ""
		if r.Timing != nil {
			timing = *r.Timing
		}
		baseRaw, baseAdj, baseChanged, baseReason := ApplyDuration(
			triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, holidays,
		)
		picked := baseAdj
		original := baseRaw
		wasAdj := baseChanged
		reason := baseReason

		if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
			altRaw, altAdj, altChanged, altReason := ApplyDuration(
				triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, holidays,
			)
			switch *r.CombineOp {
			case "max":
				if altAdj.After(baseAdj) {
					picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
				}
			case "min":
				if altAdj.Before(baseAdj) {
					picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
				}
			}
		}

		d := TimelineEntry{
			RuleID:           r.ID.String(),
			Name:             r.Name,
			NameEN:           r.NameEN,
			Priority:         r.Priority,
			ConditionExpr:    json.RawMessage(r.ConditionExpr),
			DueDate:          picked.Format("2006-01-02"),
			OriginalDate:     original.Format("2006-01-02"),
			WasAdjusted:      wasAdj,
			AdjustmentReason: reason,
			DurationValue:    r.DurationValue,
			DurationUnit:     r.DurationUnit,
			Timing:           timing,
		}
		if r.SubmissionCode != nil {
			d.Code = *r.SubmissionCode
		}
		if r.PrimaryParty != nil {
			d.Party = *r.PrimaryParty
		}
		if r.RuleCode != nil {
			d.RuleRef = *r.RuleCode
		}
		if r.LegalSource != nil {
			d.LegalSource = *r.LegalSource
			d.LegalSourceDisplay = FormatLegalSourceDisplay(*r.LegalSource)
			d.LegalSourceURL = BuildLegalSourceURL(*r.LegalSource)
		}
		if r.DeadlineNotes != nil {
			d.Notes = *r.DeadlineNotes
		}
		if r.DeadlineNotesEn != nil {
			d.NotesEN = *r.DeadlineNotesEn
		}
		deadlines = append(deadlines, d)
	}

	return &Timeline{
		// Trigger-event responses don't carry proceeding metadata —
		// EventDeadlineService.Calculate fills the trigger fields in
		// the legacy CalculateResponse shape. Leaving these empty is
		// the stable contract.
		ProceedingType: "",
		ProceedingName: "",
		TriggerDate:    triggerDateStr,
		Deadlines:      deadlines,
	}, nil
}

// CalculateRule computes a single deadline from a rule + trigger date.
// Used by the v4 result-card click flow. Distinct from Calculate: no
// parent-chain walk, no full-timeline rendering — just one date out.
//
// When the rule is court-determined, DueDate is empty and
// IsCourtSet=true; the caller should disable the "Add to project" CTA.
//
// When the rule has a condition_expr gate and the caller's Flags
// satisfy it AND alt_duration_value is non-NULL, the calc swaps to
// alt_*. When the gate is not satisfied, the calc still proceeds with
// the base duration_value and surfaces FlagsRequired.
func CalculateRule(
	ctx context.Context,
	params CalcRuleParams,
	catalog Catalog,
	holidays HolidayCalendar,
	courts CourtRegistry,
) (*RuleCalculation, error) {
	triggerDate, err := time.Parse("2006-01-02", params.TriggerDate)
	if err != nil {
		return nil, fmt.Errorf("invalid trigger date %q: %w", params.TriggerDate, err)
	}

	rule, pt, err := resolveRule(ctx, params, catalog)
	if err != nil {
		return nil, err
	}

	mandWire, _ := wireFlagsFromPriority(rule.Priority)
	out := &RuleCalculation{
		Rule: RuleCalculationRule{
			ID:            rule.ID.String(),
			NameDE:        rule.Name,
			NameEN:        rule.NameEN,
			DurationValue: rule.DurationValue,
			DurationUnit:  rule.DurationUnit,
			IsMandatory:   mandWire,
		},
		Proceeding: RuleCalculationProceeding{
			Code:   pt.Code,
			NameDE: pt.Name,
			NameEN: pt.NameEN,
		},
		TriggerDate: params.TriggerDate,
	}
	if rule.SubmissionCode != nil {
		out.Rule.LocalCode = *rule.SubmissionCode
	}
	if rule.RuleCode != nil {
		out.Rule.RuleRef = *rule.RuleCode
	}
	if rule.LegalSource != nil {
		out.Rule.LegalSource = *rule.LegalSource
		out.Rule.LegalSourceDisplay = FormatLegalSourceDisplay(*rule.LegalSource)
		out.Rule.LegalSourceURL = BuildLegalSourceURL(*rule.LegalSource)
	}
	if rule.PrimaryParty != nil {
		out.Rule.Party = *rule.PrimaryParty
	}
	if rule.DeadlineNotes != nil {
		out.Rule.NotesDE = *rule.DeadlineNotes
	}
	if rule.DeadlineNotesEn != nil {
		out.Rule.NotesEN = *rule.DeadlineNotesEn
	}
	// Slice 9 (t-paliad-195) replacement for the dropped condition_flag
	// text[] enumeration: walk the jsonb gate to pull out flag-leaf
	// names. Returns nil on an unconditional rule.
	out.FlagsRequired = ExtractFlagsFromExpr(rule.ConditionExpr)

	// Court-determined: no calculable date.
	if rule.IsCourtSet {
		out.IsCourtSet = true
		return out, nil
	}

	// Resolve flag-conditional duration via the unified condition_expr
	// evaluator.
	flagSet := make(map[string]struct{}, len(params.Flags))
	for _, f := range params.Flags {
		flagSet[f] = struct{}{}
	}
	durationValue := rule.DurationValue
	durationUnit := rule.DurationUnit
	gateMet := EvalConditionExpr([]byte(rule.ConditionExpr), flagSet)
	if gateMet && HasConditionExpr(rule.ConditionExpr) {
		out.FlagsApplied = out.FlagsRequired
		if rule.AltDurationValue != nil {
			durationValue = *rule.AltDurationValue
		}
		if rule.AltDurationUnit != nil {
			durationUnit = *rule.AltDurationUnit
		}
		if rule.AltRuleCode != nil {
			out.Rule.RuleRef = *rule.AltRuleCode
		}
	}

	// Zero-duration non-court-determined rules are "filed at the same
	// time as parent" markers: effectively mean "due on the trigger
	// date itself".
	if durationValue == 0 {
		out.OriginalDate = params.TriggerDate
		out.DueDate = params.TriggerDate
		return out, nil
	}

	defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
	country, regime, err := courts.CountryRegime(params.CourtID, defaultCountry, defaultRegime)
	if err != nil {
		return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err)
	}

	timing := ""
	if rule.Timing != nil {
		timing = *rule.Timing
	}
	endDate, adjusted, wasAdj, reason := ApplyDuration(
		triggerDate, durationValue, durationUnit, timing, country, regime, holidays,
	)
	out.OriginalDate = endDate.Format("2006-01-02")
	out.DueDate = adjusted.Format("2006-01-02")
	out.WasAdjusted = wasAdj
	out.AdjustmentReason = reason

	return out, nil
}

// resolveRule resolves CalcRuleParams to a rule + its proceeding type.
// Accepts either RuleID (UUID) or (ProceedingCode, RuleLocalCode). The
// frontend uses the latter form (it has the pill context) and the
// programmatic / test caller can use the former.
func resolveRule(ctx context.Context, params CalcRuleParams, catalog Catalog) (*Rule, *ProceedingType, error) {
	if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") {
		return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required")
	}

	if params.RuleID != "" {
		rule, err := catalog.LoadRuleByID(ctx, params.RuleID)
		if err != nil {
			return nil, nil, err
		}
		if rule.ProceedingTypeID == nil {
			return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID)
		}
		pt, err := catalog.LoadProceedingByID(ctx, *rule.ProceedingTypeID)
		if err != nil {
			return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err)
		}
		return rule, pt, nil
	}

	rule, pt, err := catalog.LoadRuleByCode(ctx, params.ProceedingCode, params.RuleLocalCode)
	if err != nil {
		return nil, nil, err
	}
	return rule, pt, nil
}

// ApplyRuleOverrides replaces rules whose ID appears in `overrides`
// with the override row, and appends any override whose ID isn't in
// the source list (net-new drafts the rule editor wants to preview).
//
// Used by the Slice 11a (t-paliad-191) preview endpoint: the editor
// passes the draft as an override so Calculate runs against the
// proposed shape without writing to the DB. Empty overrides slice =
// pass-through.
func ApplyRuleOverrides(src, overrides []Rule) []Rule {
	if len(overrides) == 0 {
		return src
	}
	byID := make(map[uuid.UUID]Rule, len(overrides))
	for _, o := range overrides {
		byID[o.ID] = o
	}
	out := make([]Rule, 0, len(src)+len(overrides))
	seen := make(map[uuid.UUID]bool, len(overrides))
	for _, r := range src {
		if ov, ok := byID[r.ID]; ok {
			out = append(out, ov)
			seen[ov.ID] = true
			continue
		}
		out = append(out, r)
	}
	for _, o := range overrides {
		if seen[o.ID] {
			continue
		}
		out = append(out, o)
	}
	return out
}

// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional)
// pair from the unified priority enum so the wire shape stays
// pixel-identical. Mapping mirrors mig 083's backfill (per design §2.3):
//
//	'mandatory'     → (true,  false)
//	'optional'      → (true,  true)
//	'recommended'   → (false, false)
//	'informational' → (false, false)
//	(unknown)       → (true,  false)
func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
	switch priority {
	case "mandatory":
		return true, false
	case "optional":
		return true, true
	case "recommended":
		return false, false
	case "informational":
		return false, false
	default:
		return true, false
	}
}

// AllFlagsSet is retained as a tiny utility for callers that have a
// flat list of flag strings + a flag-set lookup. The new condition_expr
// gate is the canonical evaluator; this helper exists for forward-
// compat with any future caller that wants the legacy AND-over-list
// semantic without rebuilding the jsonb.
func AllFlagsSet(required []string, set map[string]struct{}) bool {
	return allFlagsSet(required, set)
}

// WireFlagsFromPriority is the public form of wireFlagsFromPriority so
// the paliad-side test suite (which historically asserted the mapping
// directly) can still test the contract.
func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
	return wireFlagsFromPriority(priority)
}

// topoSortByParentDepth returns a copy of `rules` ordered so every rule
// appears after its parent_id ancestor. Ties (rules at the same depth)
// preserve their input order — which the catalog returns in
// sequence_order. Used by Calculate to ensure the parent-state checks
// (courtSet[parent], computed[parent_code]) see populated entries even
// when sequence_order lists a "before"-timed child BEFORE its parent
// (e.g. upc.inf.cfi.translation_request at seq=45 with parent
// upc.inf.cfi.oral at seq=50 — m/paliad#135).
//
// Rules whose parent_id is missing from the rule slice (cross-tree
// references that the per-proceeding filter dropped) are treated as
// depth 0 — they walk in their original sequence position.
//
// The algorithm is depth-via-memoised-recursion. Cycle protection: a
// rule chain that revisits a node is broken at depth 0; production
// data shouldn't contain cycles, but a corrupted catalog mustn't hang
// the calculator.
func topoSortByParentDepth(rules []Rule) []Rule {
	byID := make(map[uuid.UUID]Rule, len(rules))
	inSlice := make(map[uuid.UUID]bool, len(rules))
	for _, r := range rules {
		byID[r.ID] = r
		inSlice[r.ID] = true
	}

	depth := make(map[uuid.UUID]int, len(rules))
	var resolve func(id uuid.UUID, seen map[uuid.UUID]bool) int
	resolve = func(id uuid.UUID, seen map[uuid.UUID]bool) int {
		if d, ok := depth[id]; ok {
			return d
		}
		if seen[id] {
			depth[id] = 0
			return 0
		}
		seen[id] = true
		r, ok := byID[id]
		if !ok || r.ParentID == nil || !inSlice[*r.ParentID] {
			depth[id] = 0
			return 0
		}
		d := resolve(*r.ParentID, seen) + 1
		depth[id] = d
		return d
	}
	for _, r := range rules {
		resolve(r.ID, map[uuid.UUID]bool{})
	}

	out := make([]Rule, len(rules))
	copy(out, rules)
	sort.SliceStable(out, func(i, j int) bool {
		return depth[out[i].ID] < depth[out[j].ID]
	})
	return out
}
