package services

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

	"github.com/google/uuid"

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

// FristenrechnerService renders the Paliad public Fristenrechner's response
// shape from DB-stored rules. It sits on top of DeadlineRuleService and
// HolidayService and produces the bilingual, rule-code + notes-rich payload
// that /tools/fristenrechner's client expects.
//
// The UI-facing response is distinct from the plain calculator in
// DeadlineCalculator: it adds IsRootEvent, IsCourtSet, RuleRef, Notes,
// party color classes, and keeps the result ordered by sequence_order
// within each proceeding type.
type FristenrechnerService struct {
	rules    *DeadlineRuleService
	holidays *HolidayService
	courts   *CourtService
}

// NewFristenrechnerService wires the service to its dependencies.
func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService {
	return &FristenrechnerService{rules: rules, holidays: holidays, courts: courts}
}

// UIDeadline matches the frontend's CalculatedDeadline TypeScript interface
// (camelCase JSON to keep /tools/fristenrechner byte-identical).
//
// Phase 3 Slice 9 (t-paliad-195) dropped the legacy IsMandatory +
// IsOptional fields — Priority is the canonical wire signal. The
// frontend reads priorityRendering(d) which since Slice 8 has
// priority as the primary input; Slice 9 removes the legacy fallback
// branch from the frontend too.
type UIDeadline struct {
	RuleID           string            `json:"ruleId,omitempty"`
	Code             string            `json:"code"`
	Name             string            `json:"name"`
	NameEN           string            `json:"nameEN"`
	Party            string            `json:"party"`
	// Priority is the 4-way enum the rule-editor + save-modal logic
	// reads: 'mandatory' | 'recommended' | 'optional' | 'informational'.
	// Informational rules render as notice cards (no save button, no
	// checkbox) — the visible UX win of Phase 3 on today's F/F rules.
	Priority         string            `json:"priority"`
	RuleRef          string            `json:"ruleRef"`
	LegalSource      string            `json:"legalSource,omitempty"`
	// LegalSourceDisplay is the pretty form (e.g. "UPC RoP R.220(1)")
	// of LegalSource, produced by FormatLegalSourceDisplay. Frontend
	// renders this in the deadline card meta line; falls back to
	// RuleRef when LegalSource is empty.
	LegalSourceDisplay string          `json:"legalSourceDisplay,omitempty"`
	// LegalSourceURL is the youpc.org/laws permalink when the cited
	// body is hosted there (UPCRoP / UPCA / UPCS today). Empty for
	// DE/EPA/EU bodies — the renderer shows display text without a link.
	LegalSourceURL   string            `json:"legalSourceURL,omitempty"`
	Notes            string            `json:"notes,omitempty"`
	NotesEN          string            `json:"notesEN,omitempty"`
	DueDate          string            `json:"dueDate"`
	OriginalDate     string            `json:"originalDate"`
	WasAdjusted      bool              `json:"wasAdjusted"`
	AdjustmentReason *AdjustmentReason `json:"adjustmentReason,omitempty"`
	IsRootEvent      bool              `json:"isRootEvent"`
	IsCourtSet       bool              `json:"isCourtSet"`
	// ConditionExpr is the jsonb gate predicate (design §2.4 long
	// form) emitted verbatim so the rule editor (Slice 11) + admin
	// surfaces can show the rule's gating shape. NULL / empty when
	// the rule is unconditional. Frontend reads this to render the
	// "Mit Nichtigkeitswiderklage" hint chips.
	ConditionExpr    json.RawMessage   `json:"conditionExpr,omitempty"`
	// IsCourtSetIndirect is true when IsCourtSet is true because the
	// rule chains off a court-determined parent (e.g. RoP.151
	// Kostenentscheidung is "1 Monat ab Hauptentscheidung", and the
	// Hauptentscheidung itself is the court-set anchor). Direct
	// court-determined rules (Urteil / Beschluss / Anordnung
	// themselves) keep IsCourtSet=true with IsCourtSetIndirect=false.
	// The frontend uses this to render "unbestimmt" for indirect
	// cases instead of "wird vom Gericht bestimmt", which is only
	// strictly correct for the direct ones — the indirect deadline
	// is computed off a parent date that the COURT sets, not by the
	// court itself.
	IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
	IsOverridden     bool              `json:"isOverridden,omitempty"`
}

// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
type UIResponse struct {
	ProceedingType string       `json:"proceedingType"`
	ProceedingName string       `json:"proceedingName"`
	TriggerDate    string       `json:"triggerDate"`
	Deadlines      []UIDeadline `json:"deadlines"`
}

// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
var ErrUnknownProceedingType = errors.New("unknown proceeding type")

// CalcOptions carries optional inputs for Calculate. Callers can leave fields
// empty/nil for the legacy behaviour.
//
//   - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with anchor_alt =
//     'priority_date' (e.g. epa.grant.exa.ep_grant.publish per Art. 93 EPÜ) use
//     this date as their base instead of the parent's adjusted date / the
//     trigger date.
//   - Flags: lowercase string flags from the UI (e.g. "with_ccr",
//     "with_amend", "with_cci"). A rule with a non-empty condition_flag
//     array renders iff EVERY element of that array is in Flags. When all
//     are present AND alt_duration_value is non-NULL, the calculator
//     swaps to alt_*; when set + flags not satisfied, the rule is
//     suppressed entirely (skipped from the result list).
//   - AnchorOverrides: rule_code → YYYY-MM-DD. Per-rule user overrides
//     of the computed deadline date. When a child rule chains off a
//     parent whose code is in AnchorOverrides, the override date is
//     used as the anchor instead of the parent's calculated date. Lets
//     the user set a real court-extended deadline, or a court-set
//     decision date once known, and have downstream rules re-flow.
type CalcOptions struct {
	PriorityDateStr string
	Flags           []string
	AnchorOverrides map[string]string
	// CourtID picks the forum the proceeding is filed in (e.g. "upc-ld-paris",
	// "de-bgh"). The calculator resolves it to (country, regime) for non-
	// working-day computation. Empty falls back to UPC München (DE/UPC) for
	// UPC-flavoured proceedings, DE for everything else — preserves legacy
	// behaviour for callers that don't yet send a court.
	CourtID string
	// TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
	// rules: when non-nil, the proceedingCode argument is ignored and the
	// service selects rules WHERE trigger_event_id = *TriggerEventIDFilter
	// instead of WHERE proceeding_type_id = .... Set by
	// EventDeadlineService.Calculate so the unified backend can serve the
	// "Was kommt nach…" surface after Phase 3 Slice 3. The pointer width
	// matches paliad.trigger_events.id (bigint, mig 028). See design
	// §3.D (calculator unification).
	TriggerEventIDFilter *int64
	// RuleOverrides substitutes specific rules in the calculator's
	// rule list with caller-supplied in-memory rows. Used by the
	// rule-editor preview (Slice 11a, t-paliad-191): the admin's
	// draft replaces its published peer (matched by rule.ID) so the
	// editor sees "what would this rule do?" without writing to the
	// DB. Net-new drafts (no draft_of peer) get appended to the rule
	// list so their effect lights up on a fresh evaluation.
	//
	// Empty / nil = no override (default). Overrides apply equally to
	// the proceeding-tree and trigger-event branches.
	RuleOverrides []models.DeadlineRule
}

// 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"). When a
//     rule's condition_flag array is non-empty, the rule renders iff
//     EVERY element is in opts.Flags; rules that fail this gate are
//     suppressed entirely (used by Phase B1 cross-flow rules that should
//     only appear with their flag).
//   - opts.PriorityDateStr overrides the anchor for rules with anchor_alt
//     set (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. Used for court-extended deadlines and for entering
//     court-set decision dates post-hoc.
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) {
	// Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven
	// branch (Pipeline-C unified rules; mig 085 moved 77 rows out of
	// paliad.event_deadlines into paliad.deadline_rules carrying a
	// non-NULL trigger_event_id). proceedingCode is ignored on this
	// path. EventDeadlineService.Calculate is the sole caller today;
	// future "event-trigger" surfaces (design §5) plug in here too.
	if opts.TriggerEventIDFilter != nil {
		return s.calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts)
	}

	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{}{}
	}

	// 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
	}

	// Look up proceeding type metadata.
	var pt struct {
		ID           int     `db:"id"`
		Code         string  `db:"code"`
		Name         string  `db:"name"`
		NameEN       string  `db:"name_en"`
		Jurisdiction *string `db:"jurisdiction"`
	}
	err = s.rules.db.GetContext(ctx, &pt,
		`SELECT id, code, name, name_en, jurisdiction
		   FROM paliad.proceeding_types
		  WHERE code = $1 AND is_active = true`, proceedingCode)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrUnknownProceedingType
	}
	if err != nil {
		return nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err)
	}

	// Resolve (country, regime) for non-working-day adjustment. Court wins
	// when supplied; otherwise default by proceeding regime. UPC proceedings
	// default to UPC München (DE+UPC) — most common HLC venue. DPMA / EPA /
	// DE proceedings default to DE (no supranational regime).
	defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
	country, regime, err := s.courts.CountryRegime(opts.CourtID, defaultCountry, defaultRegime)
	if err != nil {
		return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
	}

	rules, err := s.rules.List(ctx, &pt.ID)
	if err != nil {
		return nil, err
	}
	if len(opts.RuleOverrides) > 0 {
		rules = applyRuleOverrides(rules, opts.RuleOverrides)
	}

	// Walk the rule list in sequence_order (already sorted by the query) and
	// compute each entry, keeping a code→date map so RelativeTo / parent_id
	// references resolve to the adjusted predecessor date.
	computed := make(map[string]time.Time, len(rules))
	courtSet := make(map[uuid.UUID]bool, len(rules))
	deadlines := make([]UIDeadline, 0, len(rules))

	for _, r := range rules {
		// 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
		// (legacy "swap-on-flag" pattern, e.g. with_ccr).
		gateMet := evalConditionExpr([]byte(r.ConditionExpr), flagSet)
		if !gateMet && r.AltDurationValue == nil {
			continue
		}

		d := UIDeadline{
			RuleID:        r.ID.String(),
			Name:          r.Name,
			NameEN:        r.NameEN,
			Priority:      r.Priority,
			ConditionExpr: json.RawMessage(r.ConditionExpr),
		}
		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
		}

		// 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 (which they can
		// once they know the real decision date).
		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 (Zwischenverfahren /
		//      Mündliche Verhandlung / Entscheidung etc.)
		//   3. parent set, court-determined → IsCourtSet (waypoint)
		//   4. parent set, NOT court-determined → "filed-with-parent"
		//      semantic: rule is filed AT THE SAME TIME as its parent
		//      (e.g. upc.rev.cfi.rev.app_to_amend, rev.cc_inf — R.49(2) says
		//      Application to amend / Counterclaim for infringement are
		//      INCLUDED in the Defence to revocation). Use the parent's
		//      computed date.
		//
		// 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 {
			// User override always wins.
			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
				}
			}

			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 parent is court-set, we have nothing to inherit —
				// fall through to court-set marking.
				if parentIsCourtSet {
					// Indirect: this rule isn't itself court-determined,
					// it's blocked because its parent is. UI should say
					// "unbestimmt", not "wird vom Gericht bestimmt".
					d.IsCourtSet = true
					d.IsCourtSetIndirect = 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 — shouldn't
						// happen given sequence_order). Treat as indirect
						// court-set: the date is unknown but the rule
						// itself isn't a court action.
						d.IsCourtSet = true
						d.IsCourtSetIndirect = true
						d.DueDate = ""
						d.OriginalDate = ""
						courtSet[r.ID] = true
					}
				}
			} else {
				// Buckets 2 + 3: court-determined directly (the rule
				// itself is a hearing / decision / order or has
				// primary_party='court'). The label "wird vom Gericht
				// bestimmt" is strictly correct here — keep
				// IsCourtSetIndirect=false.
				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. The user can re-run
		// with the actual decision date once the court issues it (or
		// supplied via AnchorOverrides).
		//
		// This is the RoP.151 case (Antrag auf Kostenentscheidung is
		// "1 Monat ab Hauptentscheidung") — the rule has a real
		// duration but its anchor is the court-set parent. The UI
		// should say "unbestimmt", not "wird vom Gericht bestimmt":
		// the date isn't directly determined by the court, it's
		// derived from a date the court sets.
		if parentIsCourtSet {
			d.IsCourtSet = true
			d.IsCourtSetIndirect = true
			d.DueDate = ""
			d.OriginalDate = ""
			courtSet[r.ID] = true
			deadlines = append(deadlines, d)
			continue
		}

		// Anchor: prefer alt-anchor (e.g. priority_date for epa.grant.exa publish)
		// when supplied, then parent's computed date (or user override),
		// then trigger date.
		baseDate := triggerDate
		if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
			baseDate = *priorityDate
		} else if r.ParentID != nil {
			// Linear scan is fine — rule trees are < 20 entries.
			for _, prev := range rules {
				if prev.ID == *r.ParentID {
					if prev.SubmissionCode != nil {
						// User override on the parent rule wins over
						// the calculated date — lets the user redirect
						// downstream from a real (court-extended,
						// court-set) date.
						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. Mutually
		// exclusive in the live corpus today (no rule sets both).
		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. Downstream rules that chain off this rule will
		// see the override via the parent-anchor lookup above.
		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, s.holidays,
		)

		// combine_op composite: compute the alt leg too, apply max/min.
		// No proceeding-tree rules carry combine_op today (it's a
		// future-friendly column the rule editor will surface). When
		// present, the gate-met / alt-swap branch above has been
		// skipped, so the comparison is between the unmodified base
		// (durationValue/Unit) and the alt (AltDurationValue/Unit).
		if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
			altOrig, altAdj, altWasAdj, altReason := applyDuration(
				baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.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
		if r.SubmissionCode != nil {
			computed[*r.SubmissionCode] = adjusted
		}
		deadlines = append(deadlines, d)
	}

	return &UIResponse{
		ProceedingType: pt.Code,
		ProceedingName: pt.Name,
		TriggerDate:    triggerDateStr,
		Deadlines:      deadlines,
	}, nil
}

// ErrUnknownRule is returned when CalculateRule can't resolve the
// (proceedingCode, ruleLocalCode) pair or rule UUID to an active rule.
var ErrUnknownRule = errors.New("unknown rule")

// CalcRuleParams identifies a single rule and the inputs needed to
// compute one deadline from it. Caller supplies either RuleID OR the
// (ProceedingCode, RuleLocalCode) pair — whichever the frontend has on
// hand from the concept-card pill it just received a click on.
type CalcRuleParams struct {
	RuleID         string   // optional — UUID
	ProceedingCode string   // optional — used with RuleLocalCode
	RuleLocalCode  string   // optional — paliad.deadline_rules.code
	TriggerDate    string   // required — YYYY-MM-DD
	Flags          []string // optional — condition_flag inputs
	CourtID        string   // optional — selects holiday calendar; defaults via proceeding's jurisdiction
}

// RuleCalculation is the v4 (t-paliad-136 Phase B) single-rule calc
// response that backs the result-card click → calc-panel flow. Distinct
// from UIDeadline (which represents one rendered timeline row inside a
// full-proceeding response): RuleCalculation is self-contained — caller
// gets the rule metadata + the computed date in one payload, no separate
// proceeding-types lookup needed.
//
// Trigger semantics: TriggerDate is the immediate parent event's
// effective date — i.e. when the user clicks "Duplik" in the card and
// types "2026-05-05", they mean "I received the Replik on 2026-05-05".
// We do NOT walk the parent chain; callers wanting the full timeline
// for a proceeding still go through Calculate.
type RuleCalculation struct {
	Rule       RuleCalculationRule       `json:"rule"`
	Proceeding RuleCalculationProceeding `json:"proceeding"`
	TriggerDate      string               `json:"triggerDate"`
	OriginalDate     string               `json:"originalDate"`
	DueDate          string               `json:"dueDate"`
	WasAdjusted      bool                 `json:"wasAdjusted"`
	AdjustmentReason *AdjustmentReason    `json:"adjustmentReason,omitempty"`
	IsCourtSet       bool                 `json:"isCourtSet"`
	// FlagsApplied lists the condition_flag values from the rule that
	// the caller's Flags satisfied. Empty when the rule has no
	// condition_flag, OR when the caller didn't satisfy the gate. Lets
	// the frontend show "Mit Nichtigkeitswiderklage angewandt" hints.
	FlagsApplied []string `json:"flagsApplied,omitempty"`
	// FlagsRequired is the rule's condition_flag in canonical order so
	// the frontend can render checkboxes for each flag the rule gates on.
	FlagsRequired []string `json:"flagsRequired,omitempty"`
}

// RuleCalculationRule mirrors the small subset of DeadlineRule the
// frontend needs to render the calc panel.
type RuleCalculationRule struct {
	ID                 string  `json:"id"`
	LocalCode          string  `json:"localCode,omitempty"`
	NameDE             string  `json:"nameDE"`
	NameEN             string  `json:"nameEN"`
	RuleRef            string  `json:"ruleRef,omitempty"`
	LegalSource        string  `json:"legalSource,omitempty"`
	LegalSourceDisplay string  `json:"legalSourceDisplay,omitempty"`
	LegalSourceURL     string  `json:"legalSourceURL,omitempty"`
	DurationValue      int     `json:"durationValue"`
	DurationUnit       string  `json:"durationUnit"`
	Party              string  `json:"party,omitempty"`
	IsMandatory        bool    `json:"isMandatory"`
	NotesDE            string  `json:"notesDE,omitempty"`
	NotesEN            string  `json:"notesEN,omitempty"`
}

// RuleCalculationProceeding identifies the proceeding context for the
// rule. Used by the frontend for display + by the add-to-project flow.
type RuleCalculationProceeding struct {
	Code   string `json:"code"`
	NameDE string `json:"nameDE"`
	NameEN string `json:"nameEN"`
}

// 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 (primary_party='court' or event_type
// ∈ {hearing, decision, order}), DueDate is empty and IsCourtSet=true;
// the caller should disable the "Add to project" CTA in that case.
//
// When the rule has condition_flag and the caller's Flags satisfy every
// element AND alt_duration_value is non-NULL, the calc swaps to alt_*
// (matches the existing flag-conditional semantics in Calculate).
//
// When the rule has condition_flag and the caller's Flags do NOT satisfy
// every element, the calc still proceeds with the base duration_value
// and surfaces FlagsRequired so the frontend can render the gating
// checkboxes. The result IS the date the rule would be due if the user
// confirmed the flag — letting the user toggle the checkbox and see the
// duration change live.
func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*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 := s.resolveRule(ctx, params)
	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 (Slice 4). Same semantics as Calculate: gate met + alt
	// values present → swap to alt; otherwise use base values.
	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 (upc.rev.cfi.app_to_amend, upc.rev.cfi.cc_inf):
	// effectively mean "due on the trigger date itself". The card-click
	// flow doesn't need to surface those as a calc panel — but if it
	// does, returning the trigger date is the right answer.
	if durationValue == 0 {
		out.OriginalDate = params.TriggerDate
		out.DueDate = params.TriggerDate
		return out, nil
	}

	defaultCountry, defaultRegime := DefaultsForJurisdiction(pt.Jurisdiction)
	country, regime, err := s.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, s.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 (s *FristenrechnerService) resolveRule(ctx context.Context, params CalcRuleParams) (*models.DeadlineRule, *models.ProceedingType, error) {
	if params.RuleID == "" && (params.ProceedingCode == "" || params.RuleLocalCode == "") {
		return nil, nil, fmt.Errorf("CalcRuleParams: either RuleID or (ProceedingCode + RuleLocalCode) is required")
	}

	const ptCols = `id, code, name, name_en, description, jurisdiction,
		category, default_color, sort_order, is_active`

	var rule models.DeadlineRule
	var pt models.ProceedingType
	if params.RuleID != "" {
		err := s.rules.db.GetContext(ctx, &rule,
			`SELECT `+ruleColumns+`
			   FROM paliad.deadline_rules
			  WHERE id = $1 AND is_active = true`, params.RuleID)
		if errors.Is(err, sql.ErrNoRows) {
			return nil, nil, ErrUnknownRule
		}
		if err != nil {
			return nil, nil, fmt.Errorf("resolve rule by id %q: %w", params.RuleID, err)
		}
		if rule.ProceedingTypeID == nil {
			return nil, nil, fmt.Errorf("rule %q has no proceeding_type_id", params.RuleID)
		}
		err = s.rules.db.GetContext(ctx, &pt,
			`SELECT `+ptCols+`
			   FROM paliad.proceeding_types
			  WHERE id = $1`, *rule.ProceedingTypeID)
		if err != nil {
			return nil, nil, fmt.Errorf("resolve proceeding for rule %q: %w", params.RuleID, err)
		}
		return &rule, &pt, nil
	}

	err := s.rules.db.GetContext(ctx, &pt,
		`SELECT `+ptCols+`
		   FROM paliad.proceeding_types
		  WHERE code = $1 AND is_active = true`, params.ProceedingCode)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil, ErrUnknownProceedingType
	}
	if err != nil {
		return nil, nil, fmt.Errorf("resolve proceeding %q: %w", params.ProceedingCode, err)
	}
	err = s.rules.db.GetContext(ctx, &rule,
		`SELECT `+ruleColumns+`
		   FROM paliad.deadline_rules
		  WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
		pt.ID, params.RuleLocalCode)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil, ErrUnknownRule
	}
	if err != nil {
		return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", params.RuleLocalCode, params.ProceedingCode, err)
	}
	return &rule, &pt, nil
}

// ListFristenrechnerTypes returns the proceeding types that populate the
// Fristenrechner UI (category = 'fristenrechner'), ordered by sort_order.
func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]FristenrechnerType, error) {
	rows, err := s.rules.db.QueryxContext(ctx, `
		SELECT code, name, name_en, jurisdiction
		  FROM paliad.proceeding_types
		 WHERE category = 'fristenrechner' AND is_active = true
		 ORDER BY sort_order`)
	if err != nil {
		return nil, fmt.Errorf("list fristenrechner types: %w", err)
	}
	defer rows.Close()

	var out []FristenrechnerType
	for rows.Next() {
		var t FristenrechnerType
		var juris sql.NullString
		if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
			return nil, err
		}
		if juris.Valid {
			t.Group = juris.String
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

// FristenrechnerType mirrors the /api/tools/proceeding-types response metadata.
type FristenrechnerType struct {
	Code   string `json:"code"`
	Name   string `json:"name"`
	NameEN string `json:"nameEN"`
	Group  string `json:"group"`
}

// allFlagsSet returns true when every element of `required` is present in
// `set`. Empty `required` returns true (no condition). Retained as the
// fallback predicate used by evalConditionExpr when condition_expr is
// NULL but the legacy condition_flag text[] is set — preserves
// transition-window behaviour for any row Slice 2 missed (it shouldn't,
// but defensive).
func allFlagsSet(required []string, set map[string]struct{}) bool {
	for _, f := range required {
		if _, ok := set[f]; !ok {
			return false
		}
	}
	return true
}

// evalConditionExpr returns true iff the rule's gate predicate is
// satisfied for the caller's flag set. Drives flag-conditional rendering
// + flag-conditional alt-swap throughout the calculator.
//
// Grammar (design §2.4 long form, mig 084 backfill):
//
//	{"flag": "<name>"}              — leaf: true iff <name> ∈ flags
//	{"op": "and", "args": [<n>...]} — true iff every arg evaluates true
//	{"op": "or",  "args": [<n>...]} — true iff any arg evaluates true
//	{"op": "not", "args": [<one>]}  — true iff the single arg is false
//
// NULL / empty / "null" expression → true (unconditional). Malformed
// JSON → true (defensive: the rule still renders, the lawyer sees
// it even if the gate is broken).
//
// Slice 9 (t-paliad-195, mig 091) dropped the legacy condition_flag
// text[] column; the fallback that AND'd over it is gone. Any future
// row needing array-of-flags semantics writes the equivalent
// {"op":"and","args":[{"flag":"<a>"},...]} jsonb directly.
func evalConditionExpr(expr []byte, flags map[string]struct{}) bool {
	if len(expr) == 0 || string(expr) == "null" {
		return true
	}
	return evalConditionExprNode(expr, flags)
}

// evalConditionExprNode walks one node of the condition_expr jsonb
// tree. Recursion depth is bounded by the editor (Slice 11 caps tree
// depth + arg count); pre-Slice-11 backfilled rows have at most a
// 2-arg AND (mig 084).
func evalConditionExprNode(raw []byte, flags map[string]struct{}) bool {
	var node struct {
		Flag string            `json:"flag"`
		Op   string            `json:"op"`
		Args []json.RawMessage `json:"args"`
	}
	if err := json.Unmarshal(raw, &node); err != nil {
		// Malformed → unconditional. The Slice 11 editor's validation
		// will block such writes; in the live corpus today mig 084's
		// jsonb_build_object output is well-formed by construction.
		return true
	}
	if node.Flag != "" {
		_, ok := flags[node.Flag]
		return ok
	}
	switch node.Op {
	case "and":
		for _, a := range node.Args {
			if !evalConditionExprNode(a, flags) {
				return false
			}
		}
		return true
	case "or":
		for _, a := range node.Args {
			if evalConditionExprNode(a, flags) {
				return true
			}
		}
		return false
	case "not":
		if len(node.Args) != 1 {
			// Malformed NOT — fall through to unconditional rather
			// than risk suppressing a rule the lawyer expects to see.
			return true
		}
		return !evalConditionExprNode(node.Args[0], flags)
	}
	// Unknown op (forward-compat with editor extensions): treat as
	// unconditional so the rule still renders.
	return true
}

// hasConditionExpr returns true when the rule carries a non-empty,
// non-"null" jsonb gate. Slice 9 (t-paliad-195) replacement for the
// pre-drop `len(r.ConditionFlag) > 0` predicate that guarded the
// flag-keyed alt-swap branch. Same intent: "this rule has a gate;
// when the gate flips to met, swap to alt".
func hasConditionExpr(expr models.NullableJSON) bool {
	if len(expr) == 0 {
		return false
	}
	s := string(expr)
	return s != "null" && s != "{}"
}

// extractFlagsFromExpr walks the jsonb gate and returns the unique
// flag names referenced as {"flag":"<name>"} leaves. Used by
// CalculateRule's response (FlagsRequired) so the result-card calc
// panel can render flag checkboxes for each gate input. Replaces the
// dropped condition_flag text[] enumeration. Returns nil on a NULL
// expression or one that contains no flag leaves.
func extractFlagsFromExpr(expr models.NullableJSON) []string {
	if !hasConditionExpr(expr) {
		return nil
	}
	seen := make(map[string]struct{})
	walkFlagLeaves([]byte(expr), seen)
	if len(seen) == 0 {
		return nil
	}
	out := make([]string, 0, len(seen))
	for f := range seen {
		out = append(out, f)
	}
	return out
}

func walkFlagLeaves(raw []byte, into map[string]struct{}) {
	var node struct {
		Flag string            `json:"flag"`
		Op   string            `json:"op"`
		Args []json.RawMessage `json:"args"`
	}
	if err := json.Unmarshal(raw, &node); err != nil {
		return
	}
	if node.Flag != "" {
		into[node.Flag] = struct{}{}
		return
	}
	for _, a := range node.Args {
		walkFlagLeaves(a, into)
	}
}

// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional)
// pair from the unified priority enum so the wire shape stays
// pixel-identical through Slice 4. Slice 8 will swap the wire to
// emit priority directly. Mapping is the exact reverse of mig 083's
// backfill (per design §2.3):
//
//	'mandatory'     → (true,  false)   — statutory must, ☑ pre-checked
//	'optional'      → (true,  true)    — RoP.151 case: strict but opt-in,
//	                                     ☐ pre-unchecked save modal
//	'recommended'   → (false, false)   — situational filing, save by default
//	                                     with override (legacy F/F semantic)
//	'informational' → (false, false)   — never saves; today no live rows
//	                                     carry it. Future: surfaces as a
//	                                     notice card in the timeline.
//	(unknown)       → (true,  false)   — safe default; treat as mandatory
//	                                     so we never silently drop a rule.
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
	}
}

// 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 (Calculate's existing behaviour for non-preview
// callers). The override slice is small (1 row in practice — the
// draft being previewed) so the linear scan is fine.
func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule {
	if len(overrides) == 0 {
		return src
	}
	byID := make(map[uuid.UUID]models.DeadlineRule, len(overrides))
	for _, o := range overrides {
		byID[o.ID] = o
	}
	out := make([]models.DeadlineRule, 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
}

// applyDuration is the unified date-arithmetic helper used by every
// calculator path (Pipeline-A proceeding-tree, Pipeline-C trigger-event,
// CalculateRule single-rule). Phase 3 Slice 4 (t-paliad-185) replaces
// the prior split between addDuration (proceeding-tree, no timing /
// working_days) and applyDurationOnCalendar (Pipeline-C, full support).
//
// Returns (raw, adjusted, didAdjust, reason):
//
//   - raw: the date strictly implied by the rule before rollover.
//   - adjusted: post-rollover for calendar units. 'working_days' lands
//     on a working day by construction so raw == adjusted there.
//   - didAdjust: true iff rollover moved the date.
//   - reason: populated when didAdjust is true; nil otherwise.
//
// timing='before' negates the sign. timing='after' (or any other value
// including the empty string) keeps it positive — preserves the
// pre-Slice-4 behaviour for proceeding-tree rules whose Timing field
// is sometimes NULL (mig 003 defaults to 'after' but legacy callers
// pass r.Timing dereferenced).
func applyDuration(
	base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService,
) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) {
	sign := 1
	if timing == "before" {
		sign = -1
	}
	switch unit {
	case "days":
		raw = base.AddDate(0, 0, sign*value)
	case "weeks":
		raw = base.AddDate(0, 0, sign*value*7)
	case "months":
		raw = base.AddDate(0, sign*value, 0)
	case "working_days":
		raw = addWorkingDays(base, sign*value, country, regime, holidays)
		// Working-day arithmetic lands on a working day by construction
		// — the per-step skip loop in addWorkingDays already passes over
		// weekends and holidays. No post-rollover required.
		return raw, raw, false, nil
	default:
		raw = base
	}
	adjusted, _, didAdjust, reason = holidays.AdjustForNonWorkingDaysWithReason(raw, country, regime)
	return raw, adjusted, didAdjust, reason
}

// addWorkingDays advances from `from` by `n` working days, skipping
// weekends and holidays applicable to the given country/regime. Negative
// n walks backward. n=0 keeps the input date as-is (caller decides
// whether to roll forward via AdjustForNonWorkingDays).
//
// Bounded by an inner 30-step skip per advance — vacation runs in our
// holiday tables are < 14 consecutive days, so 30 is a safety margin.
func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time {
	if n == 0 {
		return from
	}
	step := 1
	if n < 0 {
		step = -1
		n = -n
	}
	cur := from
	for i := 0; i < n; i++ {
		cur = cur.AddDate(0, 0, step)
		for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ {
			cur = cur.AddDate(0, 0, step)
		}
	}
	return cur
}

// 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.
//
// UIResponse.ProceedingType / ProceedingName stay empty — EventDeadlineService
// owns the trigger-event metadata (it's the caller that needed it
// pre-Slice-3 and continues to load it for the legacy CalculateResponse
// shape). Callers that don't need those fields can ignore them.
func (s *FristenrechnerService) calculateByTriggerEvent(
	ctx context.Context, triggerEventID int64, triggerDateStr string, opts CalcOptions,
) (*UIResponse, 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 := s.courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC)
	if err != nil {
		return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
	}

	rules, err := s.rules.ListByTriggerEvent(ctx, triggerEventID)
	if err != nil {
		return nil, err
	}
	if len(opts.RuleOverrides) > 0 {
		rules = applyRuleOverrides(rules, opts.RuleOverrides)
	}

	deadlines := make([]UIDeadline, 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, s.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, s.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
				}
			}
		}

		// Slice 9 (t-paliad-195) wire-shape cleanup: trigger-event
		// path emits Priority + ConditionExpr directly. The legacy
		// IsMandatory/IsOptional pair was retired with the column
		// drop; frontend reads priorityRendering(d) which now branches
		// on priority alone.
		d := UIDeadline{
			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,
		}
		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 &UIResponse{
		// 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
}

// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple a
// holiday lookup should default to when the caller didn't pass an explicit
// CourtID. UPC proceedings get DE+UPC (München LD is HLC's most common
// venue, German federal holidays plus UPC vacations apply); DE / DPMA / EPA
// get DE-only (German federal). Future EPA-specific closures will require
// callers to pick an EPA court explicitly so the EPO regime kicks in.
//
// Helper kept tiny and stateless — when a caller passes a real CourtID,
// these defaults are bypassed entirely and the court's actual country +
// regime are used.
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
	if jurisdiction == nil {
		return CountryDE, ""
	}
	switch *jurisdiction {
	case "UPC":
		return CountryDE, RegimeUPC
	default:
		return CountryDE, ""
	}
}
