package litigationplanner

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

	"github.com/google/uuid"
	"github.com/lib/pq"
)

// NullableJSON is a jsonb column that may be NULL. json.RawMessage
// (and *json.RawMessage) doesn't implement sql.Scanner, so a NULL value
// from Postgres breaks the row scan with "unsupported Scan, storing
// driver.Value type <nil> into type *json.RawMessage" — exactly the
// error that hid every approval_request from the inbox when m's first
// "create" lifecycle row arrived with NULL pre_image (m's dogfood
// 2026-05-08 20:35). Using NullableJSON on every nullable jsonb column
// fixes the scan and preserves inline JSON output (no base64 cast).
type NullableJSON []byte

// Scan implements sql.Scanner.
func (n *NullableJSON) Scan(value any) error {
	if value == nil {
		*n = nil
		return nil
	}
	switch v := value.(type) {
	case []byte:
		*n = append((*n)[:0], v...)
		return nil
	case string:
		*n = []byte(v)
		return nil
	}
	return fmt.Errorf("NullableJSON: unsupported scan type %T", value)
}

// Value implements driver.Valuer.
func (n NullableJSON) Value() (driver.Value, error) {
	if len(n) == 0 {
		return nil, nil
	}
	return []byte(n), nil
}

// MarshalJSON emits the raw JSON bytes (or "null").
func (n NullableJSON) MarshalJSON() ([]byte, error) {
	if len(n) == 0 {
		return []byte("null"), nil
	}
	return []byte(n), nil
}

// UnmarshalJSON consumes raw JSON bytes (literal "null" maps to nil).
func (n *NullableJSON) UnmarshalJSON(data []byte) error {
	if string(data) == "null" {
		*n = nil
		return nil
	}
	*n = append((*n)[:0], data...)
	return nil
}

// Rule is one rule in the proceeding-rule tree (UPC R.023, etc.).
//
// JSON + db tags are intentionally identical to the historical
// paliad.deadline_rules row shape — sqlx scans onto Rule directly and
// the wire bytes the frontend reads are unchanged from the pre-extract
// shape.
type Rule struct {
	ID               uuid.UUID  `db:"id"                 json:"id"`
	ProceedingTypeID *int       `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
	ParentID         *uuid.UUID `db:"parent_id"          json:"parent_id,omitempty"`
	SubmissionCode   *string    `db:"submission_code"    json:"submission_code,omitempty"`
	Name             string     `db:"name"               json:"name"`
	NameEN           string     `db:"name_en"            json:"name_en"`
	Description      *string    `db:"description"        json:"description,omitempty"`
	PrimaryParty     *string    `db:"primary_party"      json:"primary_party,omitempty"`
	EventType        *string    `db:"event_type"         json:"event_type,omitempty"`
	DurationValue    int        `db:"duration_value"     json:"duration_value"`
	DurationUnit     string     `db:"duration_unit"      json:"duration_unit"`
	Timing           *string    `db:"timing"             json:"timing,omitempty"`
	RuleCode         *string    `db:"rule_code"          json:"rule_code,omitempty"`
	DeadlineNotes    *string    `db:"deadline_notes"     json:"deadline_notes,omitempty"`
	DeadlineNotesEn  *string    `db:"deadline_notes_en"  json:"deadline_notes_en,omitempty"`
	SequenceOrder    int        `db:"sequence_order"     json:"sequence_order"`
	AltDurationValue *int       `db:"alt_duration_value" json:"alt_duration_value,omitempty"`
	AltDurationUnit  *string    `db:"alt_duration_unit"  json:"alt_duration_unit,omitempty"`
	AltRuleCode      *string    `db:"alt_rule_code"      json:"alt_rule_code,omitempty"`
	AnchorAlt        *string    `db:"anchor_alt"         json:"anchor_alt,omitempty"`
	ConceptID        *uuid.UUID `db:"concept_id"         json:"concept_id,omitempty"`
	// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
	// this rule's concept (joined via paliad.deadline_concept_event_types
	// where is_default = true). Lets the deadline create form auto-populate
	// the Typ chip when the user picks this rule. Hydrated by the service
	// layer; not a column. NULL when the concept has no mapped event_type.
	ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
	LegalSource               *string    `db:"legal_source"       json:"legal_source,omitempty"`
	IsSpawn                   bool       `db:"is_spawn"           json:"is_spawn"`
	SpawnLabel                *string    `db:"spawn_label"        json:"spawn_label,omitempty"`
	IsActive                  bool       `db:"is_active"          json:"is_active"`
	CreatedAt                 time.Time  `db:"created_at"         json:"created_at"`
	UpdatedAt                 time.Time  `db:"updated_at"         json:"updated_at"`

	// TriggerEventID points at paliad.trigger_events when this rule is
	// event-rooted (Pipeline C unification, design §2.5). NULL on
	// proceeding-rooted rules.
	TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`

	// SpawnProceedingTypeID is the cross-proceeding spawn target —
	// when is_spawn=true and this is non-NULL, the calculator follows
	// the FK and emits the target proceeding's root rule chain.
	SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`

	// CombineOp is 'max' or 'min' for composite-rule arithmetic
	// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
	// NULL = single-anchor arithmetic.
	CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`

	// ConditionExpr is the jsonb gating expression. Grammar:
	//   {"flag": "<name>"}
	//   {"op":"and"|"or", "args":[<node>, ...]}
	//   {"op":"not", "args":[<node>]}
	// NULL or {} = unconditional.
	ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`

	// Priority is the 4-way unified enum: 'mandatory' (default),
	// 'recommended', 'optional', 'informational'.
	Priority string `db:"priority" json:"priority"`

	// IsCourtSet replaces the runtime heuristic (primary_party='court'
	// OR event_type IN ('hearing','decision','order')).
	IsCourtSet bool `db:"is_court_set" json:"is_court_set"`

	// LifecycleState drives the rule-editor flow:
	// 'draft' | 'published' | 'archived'.
	LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`

	// DraftOf points at the published rule this draft will replace on
	// publish. NULL on published / archived rows.
	DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`

	// PublishedAt records when the row entered LifecycleState='published'.
	PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`

	// ChoicesOffered declares which per-event-card choice-kinds this
	// rule offers on the Verfahrensablauf timeline (mig 129,
	// t-paliad-265). NULL = no caret affordance (default).
	ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`

	// AppliesToTarget is the per-rule applies-to set for the unified
	// UPC Berufung proceeding type (Slice B1, mig 134, m/paliad#124
	// §18.1). Each element ∈ AppealTargets. NULL on rules outside
	// the appeal proceeding. The engine filters by this when
	// CalcOptions.AppealTarget is set.
	AppliesToTarget pq.StringArray `db:"applies_to_target" json:"appliesToTarget,omitempty"`
}

// ProceedingType is one of the litigation conceptual codes (INF/REV/CCR
// /APM/APP/AMD/ZPO_CIVIL — matter management) or the lowercase dot-
// separated fristenrechner codes (upc.*.*, de.*.*, epa.*.*, dpma.*.*) —
// see docs/design-proceeding-code-taxonomy-2026-05-18.md.
type ProceedingType struct {
	ID           int     `db:"id"             json:"id"`
	Code         string  `db:"code"           json:"code"`
	Name         string  `db:"name"           json:"name"`
	NameEN       string  `db:"name_en"        json:"name_en"`
	Description  *string `db:"description"    json:"description,omitempty"`
	Jurisdiction *string `db:"jurisdiction"   json:"jurisdiction,omitempty"`
	Category     *string `db:"category"       json:"category,omitempty"`
	DefaultColor string  `db:"default_color"  json:"default_color"`
	SortOrder    int     `db:"sort_order"     json:"sort_order"`
	IsActive     bool    `db:"is_active"      json:"is_active"`
	// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
	// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
	// that fires when no rule has IsRootEvent=true.
	TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
	TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`

	// AppealTarget is the top-level appeal-target marker (Slice B1, mig
	// 134). NULL on non-appeal proceedings. Reserved for future variants
	// — today the unified upc.apl row has this NULL (per-rule targets
	// live on Rule.AppliesToTarget).
	AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`

	// Role label overrides (t-paliad-301 / m/paliad#132, mig 137).
	// NULL = renderer falls back to the language-default labels
	// ("Klägerseite" / "Beklagtenseite" / "Claimant side" / "Defendant side").
	// Set on proceedings where the role-naming diverges from the
	// claimant/defendant default (Appeal → Berufungskläger /
	// Berufungsbeklagter; Revocation → Antragsteller /
	// Antragsgegner Nichtigkeit; EPA Opposition → Einsprechende(r) /
	// Patentinhaber(in)).
	RoleProactiveLabelDE *string `db:"role_proactive_label_de" json:"role_proactive_label_de,omitempty"`
	RoleProactiveLabelEN *string `db:"role_proactive_label_en" json:"role_proactive_label_en,omitempty"`
	RoleReactiveLabelDE  *string `db:"role_reactive_label_de"  json:"role_reactive_label_de,omitempty"`
	RoleReactiveLabelEN  *string `db:"role_reactive_label_en"  json:"role_reactive_label_en,omitempty"`
}

// TriggerEventLabelForAppealTarget returns the per-target
// "Auslösendes Ereignis" label for the unified UPC Berufung
// proceeding (t-paliad-301 / m/paliad#132 Bug B). The trigger event
// for an appeal is the underlying decision, not the appeal
// proceeding itself — these labels override the proceeding's own
// trigger_event_label when appeal_target is set.
//
// lang ∈ {"de", "en"}; any other value falls through to "de" so the
// caller never gets an empty string.
//
// Returns empty when target is empty / unknown (caller must fall
// back to the proceeding's own trigger_event_label).
func TriggerEventLabelForAppealTarget(target, lang string) string {
	if lang != "en" {
		lang = "de"
	}
	switch target {
	case AppealTargetEndentscheidung:
		if lang == "en" {
			return "Final decision (R.118)"
		}
		return "Endentscheidung (R.118)"
	case AppealTargetKostenentscheidung:
		if lang == "en" {
			return "Cost decision"
		}
		return "Kostenentscheidung"
	case AppealTargetAnordnung:
		if lang == "en" {
			return "Order"
		}
		return "Anordnung"
	case AppealTargetSchadensbemessung:
		if lang == "en" {
			return "Damages-assessment decision"
		}
		return "Entscheidung im Schadensbemessungsverfahren"
	case AppealTargetBucheinsicht:
		if lang == "en" {
			return "Book-inspection order"
		}
		return "Anordnung der Bucheinsicht"
	}
	return ""
}

// AdjustmentReason describes why a date was rolled forward / backward
// off a non-working day. Populated by HolidayCalendar implementations
// when AdjustForNonWorkingDaysWithReason moves the date.
//
// Date fields are JSON-serialised as YYYY-MM-DD strings (matching
// TimelineEntry.DueDate / OriginalDate) so the frontend doesn't need a
// separate RFC3339 parser.
type AdjustmentReason struct {
	// Kind is the dominant cause; longest cause wins when several apply
	// (vacation > public_holiday > weekend).
	Kind string `json:"kind"`
	// Holidays collects every named holiday encountered while walking
	// past the non-working run, deduped by (date, name). May be empty
	// when the only cause is a weekend.
	Holidays []HolidayDTO `json:"holidays,omitempty"`
	// VacationName, VacationStart and VacationEnd describe the
	// contiguous vacation block the original date sits in. Populated
	// only when Kind == "vacation". Span boundaries are the first/last
	// vacation day in the block (excludes the weekends that pad it).
	VacationName  string `json:"vacationName,omitempty"`
	VacationStart string `json:"vacationStart,omitempty"`
	VacationEnd   string `json:"vacationEnd,omitempty"`
	// OriginalWeekday is the English weekday name of the original date —
	// "Saturday" / "Sunday" — set only when Kind == "weekend" so the UI
	// can localise it.
	OriginalWeekday string `json:"originalWeekday,omitempty"`
}

// HolidayDTO is the JSON shape for a holiday emitted in
// AdjustmentReason — distinct from a DB-level Holiday row so dates
// serialise as YYYY-MM-DD strings.
type HolidayDTO struct {
	Date       string `json:"date"`
	Name       string `json:"name"`
	IsVacation bool   `json:"isVacation,omitempty"`
	IsClosure  bool   `json:"isClosure,omitempty"`
}

// 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"). Drive condition_expr evaluation + flag-keyed
//     alt-swap.
//   - 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.
//   - 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.
//   - TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
//     rules: when non-nil, the proceedingCode argument is ignored and
//     the engine selects rules WHERE trigger_event_id = *filter.
//   - RuleOverrides substitutes specific rules in the calculator's
//     rule list with caller-supplied in-memory rows. Used by the
//     rule-editor preview.
//   - PerCardAppellant / SkipRules / IncludeCCRFor / IncludeHidden
//     drive per-event-card choice overlays (t-paliad-265, t-paliad-290).
//   - ProjectHint scopes the catalog lookup to a project context
//     (paliad's catalog uses this to merge in project-scoped rules
//     in future slices; v1 catalogs may ignore it).
type CalcOptions struct {
	PriorityDateStr      string
	Flags                []string
	AnchorOverrides      map[string]string
	CourtID              string
	TriggerEventIDFilter *int64
	RuleOverrides        []Rule

	PerCardAppellant map[string]string
	SkipRules        map[string]struct{}
	IncludeCCRFor    map[string]struct{}
	IncludeHidden    bool

	ProjectHint ProjectHint

	// AppealTarget narrows the timeline to rules whose AppliesToTarget
	// contains the requested slug. Empty = no filter. Set to one of
	// AppealTargets for the unified UPC Berufung picker (Slice B1,
	// m/paliad#124 §18.1). Unknown slugs are silently dropped (no
	// filter applied) so a stale frontend chip doesn't break the
	// timeline render — see IsValidAppealTarget.
	AppealTarget string

	// IncludeOptional surfaces rules with priority='optional' in the
	// default timeline (t-paliad-342 / youpcorg#2570). Default false:
	// optional rules don't auto-fire alongside mandatory ones. The
	// caller (paliad /tools/procedures, youpc.org/deadlines) wires this
	// to a user-facing "show optional applications" toggle.
	IncludeOptional bool

	// TriggerEventAnchors supplies concrete dates for procedural events
	// referenced by rules' trigger_event_id (t-paliad-342 / youpcorg#2568).
	// Key = paliad.trigger_events.code (e.g. "oral_hearing"), value =
	// YYYY-MM-DD. When a rule carries an explicit trigger_event_id, that
	// catalog event is the authoritative semantic anchor: arithmetic
	// resolves against TriggerEventAnchors[code] if set, otherwise the
	// rule is suppressed as IsConditional (no fabricated date off the
	// user's trigger date). Empty map = engine never anchors on a
	// trigger event, so every rule with trigger_event_id surfaces as
	// conditional.
	TriggerEventAnchors map[string]string
}

// ProjectHint scopes a Catalog call to a specific project. Paliad's
// catalog uses ProjectID to merge in project-scoped rules in a future
// slice (m/paliad#124 §6 — currently dropped per m's 2026-05-26
// decision; the field stays for forward-compat). Other catalogs (the
// embedded UPC snapshot used by youpc.org) ignore the hint.
//
// Zero value = no project context (the abstract Verfahrensablauf /
// public Fristenrechner case).
type ProjectHint struct {
	ProjectID uuid.UUID
}

// 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.submission_code
	TriggerDate    string   // required — YYYY-MM-DD
	Flags          []string // optional — condition_flag inputs
	CourtID        string   // optional — selects holiday calendar
}

// Timeline is the package's structured return for Calculate. JSON tags
// are aligned with paliad's historical UIResponse so handlers can serve
// it directly — the wire bytes the frontend reads are unchanged.
type Timeline struct {
	ProceedingType      string          `json:"proceedingType"`
	ProceedingName      string          `json:"proceedingName"`
	ProceedingNameEN    string          `json:"proceedingNameEN,omitempty"`
	TriggerDate         string          `json:"triggerDate"`
	Deadlines           []TimelineEntry `json:"deadlines"`
	ContextualNote      string          `json:"contextualNote,omitempty"`
	ContextualNoteEN    string          `json:"contextualNoteEN,omitempty"`
	TriggerEventLabel   string          `json:"triggerEventLabel,omitempty"`
	TriggerEventLabelEN string          `json:"triggerEventLabelEN,omitempty"`
	HiddenCount         int             `json:"hiddenCount"`
	// RulesAwaitingAnchor counts rules suppressed because their
	// trigger_event_id anchor date wasn't supplied via
	// CalcOptions.TriggerEventAnchors (t-paliad-342). Such rules still
	// render in the timeline as IsConditional (no date) — the field
	// gives the caller a single integer for "N rules waiting on an
	// anchor" UI affordances + telemetry.
	RulesAwaitingAnchor int `json:"rulesAwaitingAnchor,omitempty"`
}

// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
// interface (camelCase JSON to keep /tools/fristenrechner byte-identical).
type TimelineEntry struct {
	RuleID             string            `json:"ruleId,omitempty"`
	Code               string            `json:"code"`
	Name               string            `json:"name"`
	NameEN             string            `json:"nameEN"`
	Party              string            `json:"party"`
	Priority           string            `json:"priority"`
	RuleRef            string            `json:"ruleRef"`
	LegalSource        string            `json:"legalSource,omitempty"`
	LegalSourceDisplay string            `json:"legalSourceDisplay,omitempty"`
	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      json.RawMessage   `json:"conditionExpr,omitempty"`
	IsCourtSetIndirect bool              `json:"isCourtSetIndirect,omitempty"`
	// IsConditional signals the rule's anchor is uncertain — no
	// concrete date can be projected. Set when the rule depends on:
	//   - a court-set ancestor whose date isn't anchored (overlaps
	//     with IsCourtSetIndirect; the two are kept distinct because
	//     IsCourtSet wraps a specific UX message "wird vom Gericht
	//     bestimmt", whereas IsConditional is the broader "render as
	//     'abhängig von <parent>'" signal)
	//   - timing='before' rules whose forward anchor isn't set
	//   - optional opposing-side rules whose true triggering event
	//     hasn't been recorded for this project (e.g. R.262(2)
	//     Erwiderung auf Vertraulichkeitsantrag)
	// When true, DueDate and OriginalDate are empty and the frontend
	// renders an "abhängig von <ParentRuleName>" chip in place of a
	// date. Suppressed by an explicit user anchor. (t-paliad-289)
	IsConditional bool `json:"isConditional,omitempty"`
	// ParentRuleCode / ParentRuleName / ParentRuleNameEN surface the
	// parent's identity so the frontend can render
	// "abhängig von <ParentRuleName>" when IsConditional=true.
	// Populated whenever the rule has a parent_id, not only when
	// conditional — keeps the wire shape stable. Empty for root rules.
	// When a rule has a real trigger_event_id, these fields are
	// overridden to point at the trigger_events catalog row instead of
	// the parent_id chain (t-paliad-294 / m/paliad#126).
	ParentRuleCode   string            `json:"parentRuleCode,omitempty"`
	ParentRuleName   string            `json:"parentRuleName,omitempty"`
	ParentRuleNameEN string            `json:"parentRuleNameEN,omitempty"`
	IsOverridden     bool              `json:"isOverridden,omitempty"`
	ChoicesOffered   json.RawMessage   `json:"choicesOffered,omitempty"`
	AppellantContext string            `json:"appellantContext,omitempty"`
	IsHidden         bool              `json:"isHidden,omitempty"`
	// DurationValue / DurationUnit / Timing surface the rule's
	// arithmetic so /tools/verfahrensablauf can show "2 Mo. nach" on
	// each event card (m/paliad#133, t-paliad-302). Source values from
	// the Rule row (not the post-alt-swap arithmetic) — the tooltip
	// reads as a property of the rule, not a recap of which branch
	// fired. Zero-duration rules (root event, court-set) emit
	// DurationValue=0 and the frontend suppresses the affordance.
	// Timing is "before" | "after" — empty when r.Timing is NULL.
	DurationValue int    `json:"durationValue,omitempty"`
	DurationUnit  string `json:"durationUnit,omitempty"`
	Timing        string `json:"timing,omitempty"`

	// AppealRole carries the rule's appeal-filer role (t-paliad-307 /
	// m/paliad#136 Bug 1) when the timeline was computed under an
	// appeal_target filter. One of AppealRoleAppellant /
	// AppealRoleAppellee, or empty for court events / non-appeal
	// timelines. The frontend column-bucketer reads this to route
	// primary_party='both' rules to Berufungskläger vs
	// Berufungsbeklagter columns once the user picks a side.
	AppealRole string `json:"appealRole,omitempty"`

	// IsTriggerEvent marks the synthetic root row that represents the
	// decision being appealed (t-paliad-307 / m/paliad#136 Bug 2).
	// Distinct from IsRootEvent in that the row carries no real rule
	// id — it's a UI marker dated to the trigger date with the
	// per-appeal-target label from TriggerEventLabelForAppealTarget.
	IsTriggerEvent bool `json:"isTriggerEvent,omitempty"`
}

// RuleCalculation is the single-rule calc response that backs the
// result-card click → calc-panel flow. Distinct from TimelineEntry
// (which represents one rendered row inside a full-proceeding
// response): RuleCalculation is self-contained.
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     []string                  `json:"flagsApplied,omitempty"`
	FlagsRequired    []string                  `json:"flagsRequired,omitempty"`
}

// RuleCalculationRule mirrors the small subset of Rule 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"`
}

// FristenrechnerType mirrors the /api/tools/proceeding-types response
// metadata.
//
// ID is the paliad.proceeding_types primary key. Surfaces so frontend
// pickers (Litigation Builder add-proceeding, fristenrechner-wizard
// project prefill) can POST the FK directly without a code→id round
// trip. Historically code-keyed; the Litigation Builder POSTing
// proceeding_type_id (int) to /api/builder/scenarios/{id}/proceedings
// forced surfacing the id (t-paliad-345 — the missing id meant the
// POST silently sent body={} and the "+ Verfahren hinzufügen" button
// did nothing).
type FristenrechnerType struct {
	ID     int    `json:"id"`
	Code   string `json:"code"`
	Name   string `json:"name"`
	NameEN string `json:"nameEN"`
	Group  string `json:"group"`
}

// EventLookupAxes carries the optional filter axes for
// Catalog.LookupEvents (Slice B2, m/paliad#124 §18.2). All fields are
// optional; the empty value (or nil pointer) is "no filter on this
// axis". When multiple axes are set the catalog applies them as AND —
// a rule must match every non-zero axis to be returned. An axis set
// to an unknown value (jurisdiction="XX", party="foo") is treated the
// same as "no filter on this axis" so a stale frontend doesn't
// silently drop the entire result set.
//
// AppealTarget narrows to rules whose AppliesToTarget contains the
// requested slug (same semantic as CalcOptions.AppealTarget). Useful
// for the unified UPC Berufung lookup.
type EventLookupAxes struct {
	// Jurisdiction filters by paliad.proceeding_types.jurisdiction
	// ("UPC" | "DE" | "EPA" | "DPMA"). Empty = any.
	Jurisdiction string
	// ProceedingTypeID narrows to one proceeding. nil = any.
	ProceedingTypeID *int
	// Party filters by paliad.deadline_rules.primary_party
	// ("claimant" | "defendant" | "court" | "both"). Empty = any.
	// Validated against PrimaryParties before the SQL pass; unknown
	// values fall through as "no filter".
	Party string
	// EventCategoryID narrows to rules associated with one
	// event_categories row via the
	// deadline_concept_event_types junction. nil = any.
	EventCategoryID *uuid.UUID
	// AppealTarget filters by Rule.AppliesToTarget containing the
	// requested slug (e.g. "endentscheidung"). Empty = any.
	// Validated against AppealTargets before the SQL pass.
	AppealTarget string
}

// EventLookupDepth controls the sequence-depth of the returned events.
type EventLookupDepth string

const (
	// EventLookupDepthNext returns immediate children of the matched
	// anchor (1 hop downstream via parent_id). Useful for "what comes
	// next from this point?" queries.
	EventLookupDepthNext EventLookupDepth = "next"
	// EventLookupDepthAllFollowing returns the entire downstream
	// chain (parent_id walk to leaves). Useful for "show me the
	// whole sequence from here onward" queries.
	EventLookupDepthAllFollowing EventLookupDepth = "all-following"
)

// EventMatch is one result row from Catalog.LookupEvents.
type EventMatch struct {
	// Rule carries the full deadline-rule row including parent_id,
	// duration_value/_unit, condition_expr, applies_to_target, etc.
	Rule Rule `json:"rule"`
	// ProceedingType is the owning proceeding metadata. Lets the
	// frontend render the "from <proceeding>" badge without a second
	// roundtrip.
	ProceedingType ProceedingType `json:"proceedingType"`
	// Priority surfaces Rule.Priority at the top level for
	// convenience — the four-value vocab (mandatory / recommended /
	// optional / informational).
	Priority string `json:"priority"`
	// DepthFromAnchor is 1 for the immediate match, 2+ for deeper
	// descendants returned under EventLookupDepthAllFollowing.
	// Always >= 1 for any returned row.
	DepthFromAnchor int `json:"depthFromAnchor"`
	// ParentRuleID is the parent rule's UUID when that parent is
	// itself in the returned result set (so the frontend can render
	// a tree). nil when the parent is outside the returned set.
	ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"`
}

// TriggerEvent is a UPC procedural event referenced by deadline rules
// whose semantic anchor is an event rather than a parent rule (the
// classic case: R.262(2) Erwiderung auf Vertraulichkeitsantrag is
// triggered by the opposing party's confidentiality application, not
// by the SoC parent rule). The conditional-rendering branch reads
// this when stamping ParentRule* on the wire.
type TriggerEvent struct {
	ID          int64     `db:"id"          json:"id"`
	Code        string    `db:"code"        json:"code"`
	Name        string    `db:"name"        json:"name"`
	NameDE      string    `db:"name_de"     json:"name_de"`
	Description string    `db:"description" json:"description"`
	IsActive    bool      `db:"is_active"   json:"is_active"`
	CreatedAt   time.Time `db:"created_at"  json:"created_at"`
}

// Sentinel errors surfaced by Calculate / CalculateRule / Catalog
// implementations. Handlers map these to HTTP statuses.
var (
	ErrUnknownProceedingType = errors.New("unknown proceeding type")
	ErrUnknownRule           = errors.New("unknown rule")
)

// AppealTarget* are the canonical slugs for the unified UPC Berufung
// proceeding type's appeal-target discriminator (Slice B1, m/paliad#124
// §18.1). The verfahrensablauf picker renders one "Berufung" entry;
// the user then picks one of these five targets and the engine filters
// rules whose AppliesToTarget contains the requested slug.
//
// Schadensbemessung + Bucheinsicht have no rule rows in migration 134;
// per m's 2026-05-26 decision they are distinct from the merits track
// and their rule sets will be seeded in a follow-up slice (paired with
// t-paliad-193 orphan-concept-seed or editorial via /admin/rules).
// CalcOptions.AppealTarget="schadensbemessung" or "bucheinsicht"
// currently returns an empty timeline.
const (
	AppealTargetEndentscheidung    = "endentscheidung"
	AppealTargetKostenentscheidung = "kostenentscheidung"
	AppealTargetAnordnung          = "anordnung"
	AppealTargetSchadensbemessung  = "schadensbemessung"
	AppealTargetBucheinsicht       = "bucheinsicht"
)

// AppealTargets is the canonical ordered list for UI chip rendering +
// validation. Order matches the design doc + the frontend's i18n key
// ordering — do not reorder without coordinating with the chip-group
// renderer.
var AppealTargets = []string{
	AppealTargetEndentscheidung,
	AppealTargetKostenentscheidung,
	AppealTargetAnordnung,
	AppealTargetSchadensbemessung,
	AppealTargetBucheinsicht,
}

// PrimaryParty* are the canonical four-value vocabulary for
// paliad.deadline_rules.primary_party (Slice B3, m/paliad#124 §18.3,
// mig 135). The DB CHECK constraint enforces the same set; the
// application-layer helper IsValidPrimaryParty lets the rule editor
// surface a friendly 400 before the DB error fires.
//
// NULL is also valid in the DB (for the 78 orphan cross-cutting
// concept seeds — Wiedereinsetzung, Versäumnisurteil-Einspruch,
// Schriftsatznachreichung, Weiterbehandlung). The helper treats the
// empty string as "no value supplied" = valid; non-empty strings must
// match one of the four canonical values.
const (
	PrimaryPartyClaimant  = "claimant"
	PrimaryPartyDefendant = "defendant"
	PrimaryPartyCourt     = "court"
	PrimaryPartyBoth      = "both"
)

// PrimaryParties is the canonical ordered list for validation +
// admin-UI rendering. Order matches the rule-editor select; do not
// reorder without coordinating with the frontend.
var PrimaryParties = []string{
	PrimaryPartyClaimant,
	PrimaryPartyDefendant,
	PrimaryPartyCourt,
	PrimaryPartyBoth,
}

// IsValidPrimaryParty returns true for empty (NULL-equivalent) or any
// of the four canonical values. Used by the rule-editor to validate
// writes before they hit the DB CHECK — produces a user-friendly 400
// instead of a raw constraint-violation error.
func IsValidPrimaryParty(s string) bool {
	if s == "" {
		return true
	}
	for _, p := range PrimaryParties {
		if p == s {
			return true
		}
	}
	return false
}

// IsValidAppealTarget returns true for empty (no filter requested) or
// any of the five canonical slugs. The engine uses this to gate the
// CalcOptions.AppealTarget filter — an unknown slug is silently
// dropped (no filter applied) rather than producing an error, so a
// stale frontend chip doesn't break the timeline render.
func IsValidAppealTarget(s string) bool {
	if s == "" {
		return true
	}
	for _, t := range AppealTargets {
		if t == s {
			return true
		}
	}
	return false
}
