// Shared core for Fristenrechner-style proceeding-timeline rendering.
//
// Both /tools/fristenrechner (deadline determination) and
// /tools/verfahrensablauf (abstract browse — t-paliad-179 Slice 1) call
// POST /api/tools/fristenrechner and paint the result with the same
// renderers. The module is pure-functional: no shared mutable state, all
// language / overrides / editability flow in through args so the two
// pages can wire their own per-page concerns (Akte save, anchor edits,
// Pathway B etc. on fristenrechner; variant chips, compare etc. coming
// to verfahrensablauf in later slices) without leaking into each other.

import { t, tDyn, getLang } from "../i18n";

export interface AdjustmentHoliday {
  Date: string;
  Name: string;
  IsVacation: boolean;
  IsClosure: boolean;
}

export interface AdjustmentReason {
  kind: "weekend" | "public_holiday" | "vacation";
  holidays?: AdjustmentHoliday[];
  vacation_name?: string;
  vacation_start?: string;
  vacation_end?: string;
  original_weekday?: string;
}

export interface CalculatedDeadline {
  // ruleId is the sequencing_rule.id UUID, used by the P3 per-rule
  // selection deviations (`rule:<uuid>` keys in projects.scenario_flags).
  // Empty on synthetic UI markers like the appeal trigger row that the
  // engine prepends — those carry no real rule_id.
  ruleId?: string;
  code: string;
  name: string;
  nameEN: string;
  party: string;
  // Priority is the canonical 4-way enum (Slice 8 made it canonical;
  // Slice 9 dropped the legacy isMandatory / isOptional pair from the
  // wire). priorityRendering(d) below branches on it.
  priority: "mandatory" | "recommended" | "optional" | "informational";
  ruleRef: string;
  legalSource?: string;
  // legalSourceDisplay is the pretty form ("UPC RoP R.220(1)") produced
  // by FormatLegalSourceDisplay on the backend. Renderer prefers this
  // over ruleRef when set; falls back to ruleRef otherwise.
  legalSourceDisplay?: string;
  // 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;
  notes?: string;
  notesEN?: string;
  dueDate: string;
  originalDate: string;
  wasAdjusted: boolean;
  adjustmentReason?: AdjustmentReason;
  isRootEvent: boolean;
  isCourtSet: boolean;
  isCourtSetIndirect?: boolean;
  isOverridden?: boolean;
  // conditionExpr surfaces the jsonb gate predicate (design §2.4) so
  // the rule-editor + admin views can render the rule's gating shape.
  // Frontend save-modal logic doesn't read this; the rule editor
  // (Slice 11) is the consumer. Unknown shape on this side — pass-through.
  conditionExpr?: unknown;
  // choicesOffered (t-paliad-265): declares which per-card choice-kinds
  // this rule offers on the Verfahrensablauf timeline. Object shape:
  // { appellant?: string[], include_ccr?: [true,false], skip?: [true,false] }.
  // null/undefined = no caret affordance.
  choicesOffered?: Record<string, unknown>;
  // appellantContext (t-paliad-265): the per-decision appellant pick
  // that applies to descendants of the closest ancestor decision card
  // with a per-card appellant set. Empty = no per-card override (the
  // page-level appellant axis still applies in that case). The bucketer
  // reads this in preference to the page-level appellant.
  appellantContext?: string;
  // isHidden (t-paliad-290 / m/paliad#122): server-side flag set when
  // a previously-hidden card is re-surfaced via the "Ausgeblendete
  // anzeigen" toggle. The renderer fades the card and exposes an
  // inline "Wieder einblenden" chip that deletes the skip choice.
  isHidden?: boolean;
  // isConditional (t-paliad-289): the rule's anchor is uncertain, so
  // no concrete date is projected. Set by the calculator when the rule
  // depends on a court-set ancestor without override, when a backward-
  // anchored rule's forward anchor isn't set, or for optional rules
  // whose true triggering event sits outside the rule data (e.g.
  // R.262(2) Erwiderung auf Vertraulichkeitsantrag — anchored on SoC
  // in the data, but the real trigger is the opposing party's
  // confidentiality motion). The renderer drops the date column entry
  // and shows an "abhängig von <parentRuleName>" chip instead.
  isConditional?: boolean;
  // parentRuleCode / parentRuleName / parentRuleNameEN surface the
  // parent rule's identity so the renderer can label the
  // "abhängig von <parent>" chip on conditional rows. Populated for
  // every rule with a parent (not just conditional ones), so the
  // dependency-footer logic can reuse it. Empty for root rules.
  parentRuleCode?: string;
  parentRuleName?: string;
  parentRuleNameEN?: string;
  // durationValue / durationUnit / timing surface the rule's arithmetic
  // so the timeline card can show "2 Mo. nach" on hover (and inline when
  // the "Dauern anzeigen" toggle is on). Zero-duration rules (root
  // event, court-set) carry durationValue=0 and the renderer suppresses
  // the affordance — those don't have an explainable interval.
  // (m/paliad#133, t-paliad-302)
  durationValue?: number;
  durationUnit?: string;
  timing?: string;
  // appealRole carries the rule's appeal-filer identity when the
  // server computed the timeline under an appeal_target filter:
  // "appellant" (Berufungskläger files this rule), "appellee"
  // (Berufungsbeklagter files this rule), or empty for court events
  // and non-appeal timelines. The column bucketer reads this in
  // preference to primary_party='both' so a user-perspective `?side=`
  // pick can split appeal filings into the user's column vs the
  // opponent's, instead of routing every "both" rule into the
  // user's column. (t-paliad-307 / m/paliad#136 Bug 1)
  appealRole?: "appellant" | "appellee" | "";
  // isTriggerEvent marks the synthetic row the engine prepends to the
  // timeline when computing an appeal: a court-set decision dated to
  // the trigger date with the per-appeal-target label
  // (Endentscheidung / Kostenentscheidung / Anordnung / …). The row
  // carries no real rule_id — it's a UI marker so the timeline reads
  // decision → appeal filings → next decision. (t-paliad-307 /
  // m/paliad#136 Bug 2)
  isTriggerEvent?: boolean;
}

// stripLeadingDurationFromNotes drops the leading
// "Frist N <unit> <preposition> <subject>." (DE) /
// "N <unit> <preposition> <subject>." (EN) prefix from a rule's
// deadline_notes so it doesn't duplicate the new duration affordance
// added in m/paliad#133 (t-paliad-307 Bug 4).
//
// The duration affordance now renders the same prose as a badge on
// the card ("4 Monate nach Endentscheidung (R.118)"); a free-text
// notes string that opens with the same prose reads as a verbatim
// duplicate. Only the leading-prefix shape is stripped — anything
// after the first sentence is preserved (the editorial commentary
// the lawyers actually want to read).
//
// Conservative: composite-duration prefaces with "ODER" /
// "whichever is the longer" don't match and stay untouched — those
// are the follow-up editorial cleanup (option b in the issue brief).
//
// Examples:
//   "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag …"
//     → "Antrag …"
//   "Frist 15 Tage ab Zustellung der Kostenentscheidung"
//     → ""
//   "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens …"
//     → "Spätestens …"
//   "1-month period from service of the main decision"
//     → ""
//   "1 month BEFORE the oral hearing (R.109.1). Request for …"
//     → "Request for …"
//   "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest …"
//     → "Latest …"
//   "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung …"
//     → unchanged (composite — option b follow-up)
export function stripLeadingDurationFromNotes(notes: string, lang: "de" | "en"): string {
  if (!notes) return notes;
  // Terminator `(?:\.\s+|$)` matches the FIRST sentence boundary
  // (period followed by whitespace) OR end of input. Embedded dots
  // inside parenthesised citations (R.109.1, § 123(2), Rule 136(1))
  // are skipped because the char right after them isn't whitespace.
  // `[^]*?` is the JS-portable form of `.*?` with the dotAll flag —
  // any character including newlines, non-greedy.
  const re = lang === "en"
    ? /^(?:Period\s+is\s+)?\d+(?:[-\s]\S+)?\s+(?:\S+\s+)?(?:before|from|after|since)\b[^]*?(?:\.\s+|$)/i
    : /^Frist\s+(?:beträgt\s+)?\d+\s+\S+\s+(?:VOR|vor|nach|ab|seit)\b[^]*?(?:\.\s+|$)/;
  return notes.replace(re, "");
}

// formatDurationLabel renders the per-rule duration label for the
// Verfahrensablauf card affordance: "2 Monate nach Endentscheidung",
// "1 Monat vor Mündlicher Verhandlung", …
// (m/paliad#133, t-paliad-302; parent-name append: t-paliad-307 /
// m/paliad#136 Bug 3).
//
// Returns empty string for rules without a usable duration so the
// caller can skip the tooltip / inline span entirely. Pluralisation
// key naming mirrors the Fristenrechner event-mode renderer
// (deadlines.event.unit.<unit>.{one,many}) — the unit and timing
// translations already exist for /tools/fristenrechner's
// "Was kommt nach…" mode and are reused here as the single source
// of truth.
//
// `parentLabel` is the rule's anchor name (parent rule's name when
// the rule has a parent_id; otherwise the proceeding's
// triggerEventLabel from the wire). Empty falls back to bare
// "<n> <unit> <timing>" — bare phrasing is the pre-fix shape and
// remains the default for fixtures / tests that omit a parent.
export function formatDurationLabel(dl: CalculatedDeadline, parentLabel: string = ""): string {
  const value = dl.durationValue ?? 0;
  const unit = dl.durationUnit || "";
  if (value <= 0 || !unit) return "";
  const unitKey = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
  const unitStr = tDyn(unitKey);
  const timing = dl.timing || "";
  const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : "";
  const head = timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
  if (!timingStr || !parentLabel) return head;
  return `${head} ${parentLabel}`;
}

// priorityRendering returns the per-priority UX hints the save-modal
// uses. Maps the unified Priority enum to:
//   - preChecked: whether the save-modal pre-checks the row
//   - hideSave:   whether the row renders without a save button at all
//                 (informational = notice card, no save action)
//
// Phase 3 Slice 9 (t-paliad-195) dropped the legacy
// (isMandatory, isOptional) fallback that pre-Slice-8 backends
// emitted. The backend now always populates `priority`; an unknown
// value falls back to "render as mandatory" (safe default — never
// silently drop a rule).
export function priorityRendering(
  d: CalculatedDeadline,
): { preChecked: boolean; hideSave: boolean } {
  switch (d.priority) {
    case "mandatory":
    case "recommended":
      return { preChecked: true, hideSave: false };
    case "optional":
      return { preChecked: false, hideSave: false };
    case "informational":
      return { preChecked: false, hideSave: true };
  }
  // Unknown priority value: pre-Slice-8 backend or a forward-compat
  // future value. Safe default: render as mandatory so the rule is
  // surfaced + saved. Never silently drop.
  return { preChecked: true, hideSave: false };
}

export interface DeadlineResponse {
  proceedingType: string;
  proceedingName: string;
  // proceedingNameEN: English label of the picked proceeding. Empty
  // when not populated server-side; frontend falls back to
  // proceedingName. Used for the "Trigger event" fallback when the
  // timeline has no root rule. (m/paliad#58)
  proceedingNameEN?: string;
  triggerDate: string;
  deadlines: CalculatedDeadline[];
  // contextualNote / contextualNoteEN render as a banner above the
  // timeline. Populated when the picked proceeding is a sub-track of
  // another proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
  // with_ccr) — the server routes to the parent's rules but keeps the
  // picked proceeding's identity in the response, and the note
  // explains the framing. (m/paliad#58)
  contextualNote?: string;
  contextualNoteEN?: string;
  // triggerEventLabel / triggerEventLabelEN: optional caption for the
  // "Auslösendes Ereignis" / "Triggering event" field on
  // /tools/verfahrensablauf. Populated from paliad.proceeding_types
  // when set (mig 121). The page prefers this over the proceedingName
  // fallback that fires when no rule has isRootEvent=true. UPC Appeal
  // uses this so the field reads "Anfechtbare Entscheidung" /
  // "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
  // (m/paliad#81)
  triggerEventLabel?: string;
  triggerEventLabelEN?: string;
  // hiddenCount (t-paliad-290 / m/paliad#122): number of rules that
  // would have been hidden in this projection (i.e. their
  // submission_code is in skipRules and they passed the condition_expr
  // gate). Surfaces the "Ausgeblendete (N)" badge on the toggle even
  // when the toggle is OFF — so users know there's something to
  // re-surface.
  hiddenCount?: number;
  // rulesAwaitingAnchor (t-paliad-348 / yoUPC#178): number of rules the
  // engine suppressed because their `trigger_event_id` anchor wasn't
  // supplied via CalcParams.triggerEventAnchors. Mirrors the Go
  // Timeline.RulesAwaitingAnchor counter — a single integer surface for
  // "N rules waiting on an anchor" UI affordances.
  rulesAwaitingAnchor?: number;
}

export interface CourtRow {
  id: string;
  code: string;
  nameDE: string;
  nameEN: string;
  country: string;
  regime?: string;
  courtType: string;
}

export interface CalcParams {
  proceedingType: string;
  triggerDate: string;
  priorityDate?: string;
  flags?: string[];
  anchorOverrides?: Record<string, string>;
  courtId?: string;
  // t-paliad-265: per-event-card choices. Either pass `projectId` for
  // server-side lookup against paliad.project_event_choices, OR pass
  // an inline list (for the unbound /tools/verfahrensablauf surface).
  // When both are supplied the inline list wins server-side.
  projectId?: string;
  perCardChoices?: Array<{
    submission_code: string;
    choice_kind: string;
    choice_value: string;
  }>;
  // includeHidden (t-paliad-290): when true the calculator returns
  // previously-skipped rules as faded cards instead of dropping them.
  // Sent only when the page-level "Ausgeblendete anzeigen" toggle is
  // ON.
  includeHidden?: boolean;
  // Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung
  // (upc.apl) timeline to the rule subset whose applies_to_target
  // contains the requested slug. Empty = no filter. Valid values:
  // endentscheidung | kostenentscheidung | anordnung |
  // schadensbemessung | bucheinsicht.
  appealTarget?: string;
  // t-paliad-348 / yoUPC#178 — surface the engine's two new CalcOptions
  // axes to the HTTP boundary:
  //
  //   includeOptional: when true, the engine returns priority='optional'
  //     rules in the timeline. Default false matches the engine default
  //     (mandatory backbone only). The /tools/procedures detailgrad
  //     toggle ("all_options" mode) drives this to true so the dimmed
  //     optional cards can be rendered for the lawyer to opt into.
  //   triggerEventAnchors: per-event-code anchor dates the engine
  //     consults for rules carrying trigger_event_id. Empty/omitted =
  //     no anchors → such rules render as IsConditional (the engine
  //     refuses to fabricate a date off the proceeding's trigger date).
  includeOptional?: boolean;
  triggerEventAnchors?: Record<string, string>;
}

const PARTY_CLASS: Record<string, string> = {
  claimant: "party-claimant",
  defendant: "party-defendant",
  court: "party-court",
  both: "party-both",
};

// ─── small helpers ─────────────────────────────────────────────────────────

export function escAttr(s: string): string {
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

// Pure-string HTML escape — keeps the module testable in bun test
// (plain Node, no jsdom). Used to be backed by document.createElement,
// which forced fixtures to leave any field that flowed through it
// empty just to exercise unrelated branches; the regex form is safe
// for arbitrary text including the per-rule name strings that the
// conditional-row chip ("abhängig von <parent>") now exposes.
// (t-paliad-289)
export function escHtml(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

export function formatDate(dateStr: string): string {
  if (!dateStr) return "—";
  const d = new Date(dateStr + "T00:00:00");
  if (getLang() === "en") {
    const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
    const yyyy = d.getFullYear();
    const mm = String(d.getMonth() + 1).padStart(2, "0");
    const dd = String(d.getDate()).padStart(2, "0");
    return `${weekday}, ${yyyy}-${mm}-${dd}`;
  }
  return d.toLocaleDateString("de-DE", {
    weekday: "short",
    day: "2-digit",
    month: "2-digit",
    year: "numeric",
  });
}

function formatDateSpan(startISO: string, endISO: string): string {
  const start = new Date(startISO + "T00:00:00");
  const end = new Date(endISO + "T00:00:00");
  if (getLang() === "en") {
    const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
    return `${fmt(start)} – ${fmt(end)}`;
  }
  const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
  return `${fmt(start)}–${fmt(end)}`;
}

function localizeWeekday(en: string): string {
  if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
  if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
  return en;
}

// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
// vacation"). Not translated — they're proper names of court-set closures.
function localizeVacationName(name: string): string {
  return name;
}

function renderAdjustmentReason(r: AdjustmentReason): string {
  if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
    const span = formatDateSpan(r.vacation_start, r.vacation_end);
    return tDyn("deadlines.adjusted.vacation")
      .replace("{name}", localizeVacationName(r.vacation_name))
      .replace("{span}", span);
  }
  if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
    return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
  }
  if (r.kind === "weekend" && r.original_weekday) {
    return localizeWeekday(r.original_weekday);
  }
  return t("deadlines.adjusted.weekend");
}

function formatAdjustedNote(dl: CalculatedDeadline): string {
  const arrow = `${formatDate(dl.originalDate)} → ${formatDate(dl.dueDate)}`;
  const reason = dl.adjustmentReason
    ? renderAdjustmentReason(dl.adjustmentReason)
    : t("deadlines.adjusted.reason");
  if (getLang() === "en") {
    return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
  }
  return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
}

export function partyBadge(party: string): string {
  const cls = PARTY_CLASS[party] || "party-both";
  return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
}

// ─── card + body renderers ────────────────────────────────────────────────

export interface CardOpts {
  showParty: boolean;
  // editable=true wires the click-to-edit affordance: data-rule-code,
  // role=button, tabindex, hover hint. Fristenrechner enables it; the
  // verfahrensablauf abstract-browse surface keeps editable=false because
  // there's no anchor-override state on that page in Slice 1.
  editable?: boolean;
  // showNotes controls how the per-rule descriptive notes render:
  //   true  → expanded `<div class="timeline-notes">…</div>` below the card
  //   false → compact ⓘ icon next to the meta line, full text on hover
  //           (browser-native `title` attribute) and screen-reader-readable
  // Page shells expose a toggle ("Hinweise anzeigen") that flips this and
  // re-renders. Default false — notes are noisy on long timelines.
  showNotes?: boolean;
  // showDurations controls per-rule duration rendering on event cards
  // (m/paliad#133, t-paliad-302):
  //   true  → inline `<span class="timeline-duration">2 Mo. nach</span>`
  //           next to the date.
  //   false → hover-only tooltip on the date span (browser-native
  //           `title` attribute). Cards without a usable
  //           `durationValue > 0` get neither — court-set and trigger-
  //           event cards have no explainable interval.
  // /tools/verfahrensablauf exposes a toggle ("Dauern anzeigen") that
  // flips this and re-renders; persisted via the localStorage key
  // `paliad.verfahrensablauf.durations-show`. Default false.
  showDurations?: boolean;
  // triggerEventLabel: per-language label of the proceeding's anchor
  // event ("Endentscheidung (R.118)" for an Endentscheidung appeal;
  // "Klageerhebung" for upc.inf.cfi; …). Used by formatDurationLabel
  // as the parent-name fallback when a rule is a root rule (no
  // parent_id) but carries a non-zero duration — e.g. the
  // Berufungseinlegung 2 months after Endentscheidung. Pages pass the
  // already-language-resolved string. (t-paliad-307 / m/paliad#136
  // Bug 3)
  triggerEventLabel?: string;
}

export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
  const wantsEditable = !!opts.editable;
  const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
  const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
  // Parent name for the duration label (t-paliad-307 / m/paliad#136
  // Bug 3): use the rule's parent if set, else fall back to the
  // proceeding's trigger event label (e.g. "Endentscheidung (R.118)"
  // for an Endentscheidung appeal; "Klageerhebung" for upc.inf.cfi).
  // Empty for rules whose anchor isn't surface-able — the duration
  // label degrades to the bare "<n> <unit> <timing>" form in that case.
  const parentLabelForDuration = (getLang() === "en"
    ? (dl.parentRuleNameEN || dl.parentRuleName)
    : (dl.parentRuleName || dl.parentRuleNameEN)) || opts.triggerEventLabel || "";
  // Duration affordance (m/paliad#133, t-paliad-302). Computed once so
  // both the date-span tooltip and the inline meta-row span pull from
  // the same string. Empty for rules without a usable duration.
  const durationLabel = formatDurationLabel(dl, parentLabelForDuration);
  // Hover affordance on the date span: prefer the duration tooltip when
  // we have one, else fall back to the edit-hint when the cell is
  // click-to-edit. The edit affordance still works either way — the
  // title is purely advisory.
  const dateTitle = durationLabel
    ? durationLabel
    : (editable ? t("deadlines.date.edit.hint") : "");
  const editAttrs = editable
    ? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0"${dateTitle ? ` title="${escAttr(dateTitle)}"` : ""}`
    : (dateTitle ? ` title="${escAttr(dateTitle)}"` : "");
  // Conditional rows (t-paliad-289) replace the date column with an
  // "abhängig von <parent>" chip. The chip remains click-to-edit so
  // the user can pin a real date once known (e.g. once the oral
  // hearing date is set, or the opposing party's Vertraulichkeits-
  // antrag arrives) — the same data-rule-code wiring fires the
  // existing inline date editor. IsConditional wins over IsCourtSet:
  // they overlap (court-set ancestor without override produces both),
  // and "abhängig von <parent>" is the clearer user-facing signal.
  const parentLabel = (getLang() === "en"
    ? (dl.parentRuleNameEN || dl.parentRuleName)
    : dl.parentRuleName) || "";
  let dateStr: string;
  if (dl.isConditional) {
    const chipText = parentLabel
      ? tDyn("deadlines.conditional.depends_on").replace("{parent}", escHtml(parentLabel))
      : t("deadlines.conditional.unset");
    dateStr = `<span class="timeline-conditional frist-date-edit"${editAttrs}>${chipText}</span>`;
  } else if (dl.isCourtSet) {
    const courtLabelKey = dl.isCourtSetIndirect
      ? "deadlines.court.indirect"
      : "deadlines.court.set";
    dateStr = `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`;
  } else {
    dateStr = `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
  }

  // t-paliad-293 — iconified state markers. The card surface speaks
  // "cut the tree of possibilities": each card carries 0–N small icons
  // in the title row that summarise its decision state at a glance.
  // The text "optional" badge that used to sit inline next to the name
  // is now a ⊙ icon (state.optional). Hidden cards get a 👁⃠ eye-slash
  // marker. Conditional cards already have the date-column chip; the
  // marker is redundant in the title row. CCR-included / appellant
  // picks remain on the chip row (event-card-choices-chip) — see below.
  // Tooltips are i18n-driven so they read in the user's language.
  const stateIcons: string[] = [];
  if (dl.priority === "optional") {
    stateIcons.push(
      `<span class="timeline-state-icon timeline-state-icon--optional" role="img" aria-label="${escAttr(t("state.optional.tooltip"))}" title="${escAttr(t("state.optional.tooltip"))}">⊙</span>`,
    );
  }
  if (dl.isHidden) {
    stateIcons.push(
      `<span class="timeline-state-icon timeline-state-icon--hidden" role="img" aria-label="${escAttr(t("state.hidden.tooltip"))}" title="${escAttr(t("state.hidden.tooltip"))}">👁⃠</span>`,
    );
  }
  const stateIconsHtml = stateIcons.join("");

  // t-paliad-265 — caret affordance + chip indicator when this rule
  // offers per-card choices and the user has made a pick. The popover
  // open/commit lifecycle lives in client/views/event-card-choices.ts;
  // the data-* attributes here are the wire contract between the two.
  //
  // t-paliad-293 — hidden cards always expose the caret so the user
  // can un-hide via the popover's "Wieder einblenden" entry. Normally
  // a hidden card was hidden via a skip choice, so `choicesOffered.skip`
  // is present. Defensive fallback: if a rule's `choices_offered` was
  // edited away after the skip entry was saved, the user would lose
  // the un-hide path entirely. Synthesize a `{skip:[true,false]}`
  // offer for the popover in that edge case so the prominent
  // "Wieder einblenden" button still renders.
  const offeredForCaret = (dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0)
    ? dl.choicesOffered
    : (dl.isHidden ? { skip: [true, false] } : null);
  const showCaret = dl.code !== "" && offeredForCaret !== null;
  const choicesHtml = showCaret
    ? `<button type="button" class="event-card-choices-caret"
         data-submission-code="${escAttr(dl.code)}"
         data-choices-offered="${escAttr(JSON.stringify(offeredForCaret))}"
         data-is-hidden="${dl.isHidden ? "1" : "0"}"
         aria-label="${escAttr(t("choices.caret.title"))}"
         title="${escAttr(t("choices.caret.title"))}">▾</button>`
    : "";

  const dlName = getLang() === "en" ? dl.nameEN : dl.name;

  const adjustedNote = dl.wasAdjusted
    ? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
    : "";

  // Prefer the structured legalSource (pretty display + youpc.org link
  // when hosted there) over the bare rule_code fallback. UPC.RoP rules
  // link to /laws/UPCRoP/<n>; DE / EPA / EU bodies have no youpc home
  // yet so we render display text plain.
  const legalDisplay = dl.legalSourceDisplay || "";
  const legalURL = dl.legalSourceURL || "";
  let ruleRef = "";
  if (legalDisplay && legalURL) {
    ruleRef = `<a class="timeline-rule timeline-rule--link" href="${escAttr(legalURL)}" target="_blank" rel="noopener noreferrer">${escHtml(legalDisplay)}</a>`;
  } else if (legalDisplay) {
    ruleRef = `<span class="timeline-rule">${escHtml(legalDisplay)}</span>`;
  } else if (dl.ruleRef) {
    ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
  }

  const rawNoteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
  // Strip the leading-duration prefix so the new duration affordance
  // doesn't duplicate what the lawyer wrote verbatim into deadline_notes
  // for those legacy rule rows that still carry it.
  // (t-paliad-307 / m/paliad#136 Bug 4)
  const noteText = rawNoteText
    ? stripLeadingDurationFromNotes(rawNoteText, getLang() === "en" ? "en" : "de")
    : rawNoteText;
  const showNotes = opts.showNotes === true;
  const notesBlock = noteText && showNotes
    ? `<div class="timeline-notes">${noteText}</div>`
    : "";
  const noteHint = noteText && !showNotes
    ? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
    : "";

  // Inline duration affordance (m/paliad#133, t-paliad-302). Only
  // emitted when the "Dauern anzeigen" toggle is on AND the rule has a
  // usable duration; the default-off hover-tooltip path is wired
  // separately on the date span itself.
  const showDurations = opts.showDurations === true;
  const durationInline = showDurations && durationLabel
    ? `<span class="timeline-duration">${escHtml(durationLabel)}</span>`
    : "";

  const meta = (opts.showParty || ruleRef || noteHint || durationInline)
    ? `<div class="timeline-meta">
        ${opts.showParty ? partyBadge(dl.party) : ""}
        ${durationInline}
        ${ruleRef}
        ${noteHint}
      </div>`
    : "";

  // Chip indicator surfaces the active per-card pick (t-paliad-265).
  // The popover module rehydrates this on commit so it stays in sync.
  const chipHtml = dl.code !== ""
    ? `<span class="event-card-choices-chip"
         data-submission-code="${escAttr(dl.code)}"
         data-empty="true"></span>`
    : "";

  // m/paliad#149 Phase 2 P3 — Aufnehmen / Entfernen chip on optional /
  // recommended rules (when the detail-mode filter is in "all_options"
  // or "selected"). The detail-mode filter tags unselected rules with
  // __detailUnselected; the renderer picks that up to render the chip
  // in its "Aufnehmen" state. Mandatory rules never get the chip — the
  // user can't deselect them.
  const detailUnselected = (dl as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected === true;
  let selectionChip = "";
  if (dl.ruleId && dl.priority !== "mandatory" && !dl.isRootEvent) {
    if (detailUnselected) {
      selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--add"
        data-rule-id="${escAttr(dl.ruleId)}"
        data-priority="${escAttr(dl.priority)}"
        data-action="aufnehmen"
        title="${escAttr(t("deadlines.detail.optional_unselected_hint"))}">
        ${escHtml(t("deadlines.detail.aufnehmen"))}
      </button>`;
    } else if (dl.priority === "recommended" || dl.priority === "optional") {
      // The rule IS in the active scenario but can be removed. Renders
      // as a discreet [Entfernen] chip on optional / recommended cards.
      selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--remove"
        data-rule-id="${escAttr(dl.ruleId)}"
        data-priority="${escAttr(dl.priority)}"
        data-action="entfernen">
        ${escHtml(t("deadlines.detail.entfernen"))}
      </button>`;
    }
  }

  return `<div class="timeline-item-header${detailUnselected ? " timeline-item-header--unselected" : ""}">
      <span class="timeline-name">
        ${dlName}
        ${stateIconsHtml}
        ${chipHtml}
      </span>
      ${dateStr}
      ${selectionChip}
      ${choicesHtml}
    </div>
    ${meta}
    ${adjustedNote}
    ${notesBlock}`;
}

// ─── inline date editor (click-to-edit per-rule due date) ────────────────
//
// The renderer emits `<span class="frist-date-edit" data-rule-code="…"
// data-current-date="YYYY-MM-DD" role="button" tabindex="0">…</span>` when
// CardOpts.editable is true. Pages call wireDateEditClicks() on their
// result container once, and the delegated click/keydown handlers swap a
// clicked span for a `<input type="date">` editor via openInlineDateEditor.
// The caller's onCommit callback receives (ruleCode, newValue) — an empty
// newValue means "revert" (clear the anchor override and let the calculator
// re-project). The actual recompute is the caller's job — they own the
// anchor-overrides map + the calc dispatch.

export function openInlineDateEditor(
  span: HTMLElement,
  onCommit: (ruleCode: string, newValue: string) => void,
): void {
  const ruleCode = span.dataset.ruleCode || "";
  if (!ruleCode) return;
  const current = span.dataset.currentDate || "";
  const editor = document.createElement("input");
  editor.type = "date";
  editor.className = "frist-date-edit-input";
  editor.value = current;

  let done = false;
  const cancel = () => {
    if (done) return;
    done = true;
    editor.replaceWith(span);
  };
  const commit = (newValue: string) => {
    if (done) return;
    done = true;
    onCommit(ruleCode, newValue);
  };

  editor.addEventListener("blur", () => {
    if (editor.value !== current) commit(editor.value);
    else cancel();
  });
  editor.addEventListener("keydown", (e) => {
    const ke = e as KeyboardEvent;
    if (ke.key === "Enter") {
      e.preventDefault();
      editor.blur();
    } else if (ke.key === "Escape") {
      e.preventDefault();
      cancel();
    }
  });

  span.replaceWith(editor);
  editor.focus();
  if (editor.value) editor.select();
}

// wireDateEditClicks attaches delegated click + keyboard handlers to the
// timeline result container so click-to-edit survives every innerHTML
// rewrite the page does on recalc. Idempotent — re-calling on the same
// container does nothing (the dataset flag short-circuits).
export function wireDateEditClicks(
  container: HTMLElement,
  onCommit: (ruleCode: string, newValue: string) => void,
): void {
  if (container.dataset.dateEditWired === "1") return;
  container.dataset.dateEditWired = "1";
  container.addEventListener("click", (e) => {
    const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
    if (!target || !target.dataset.ruleCode) return;
    openInlineDateEditor(target, onCommit);
  });
  container.addEventListener("keydown", (e) => {
    const ke = e as KeyboardEvent;
    if (ke.key !== "Enter" && ke.key !== " ") return;
    const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
    if (!target || !target.dataset.ruleCode) return;
    e.preventDefault();
    openInlineDateEditor(target, onCommit);
  });
}

// pickTriggerEventLabel returns the per-language trigger event label
// from a DeadlineResponse, used as the parent-fallback for root-rule
// duration labels. Mirrors the precedence the page-level
// triggerEventLabelFor uses (curated server label > proceedingName
// fallback). Distinct from the page helper in that it stays language-
// scoped to the current getLang() — root-rule duration labels render
// in the user's current language. (t-paliad-307 / m/paliad#136 Bug 3)
export function pickTriggerEventLabel(data: DeadlineResponse): string {
  const lang = getLang();
  const curated = lang === "en"
    ? (data.triggerEventLabelEN || data.triggerEventLabel || "")
    : (data.triggerEventLabel || data.triggerEventLabelEN || "");
  if (curated) return curated;
  return lang === "en"
    ? (data.proceedingNameEN || data.proceedingName || "")
    : (data.proceedingName || data.proceedingNameEN || "");
}

export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
  // Resolve the trigger event label once so the duration affordance on
  // root rules (no parent) can read it as the anchor fallback. Caller-
  // provided value wins (lets the page override for sub-track flows).
  const cardOpts: CardOpts = {
    ...opts,
    triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
  };
  let html = '<div class="timeline">';
  for (const dl of data.deadlines) {
    const itemClasses = [
      "timeline-item",
      dl.isRootEvent ? "timeline-root" : "",
      // t-paliad-290: re-surfaced hidden cards render faded via the
      // shared timeline-item--hidden modifier (same modifier the columns
      // view uses; see fr-col-item--hidden below).
      dl.isHidden ? "timeline-item--hidden" : "",
      // t-paliad-289: dotted-border + faded styling for conditional rows
      // so the "abhängig von <parent>" state is visually distinct from
      // both anchored deadlines and direct court-set rows.
      dl.isConditional ? "timeline-item--conditional" : "",
    ].filter(Boolean).join(" ");
    html += `
      <div class="${itemClasses}">
        <div class="timeline-dot-col">
          <div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
          <div class="timeline-line"></div>
        </div>
        <div class="timeline-content">
          ${deadlineCardHtml(dl, cardOpts)}
        </div>
      </div>
    `;
  }
  html += "</div>";
  return html;
}

// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
//
// The columns are user-perspective ("WE are always on the left", per
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
// Klägerseite is sometimes proactive (filing the claim) and sometimes
// reactive (responding to a counterclaim), so the static "Proaktiv =
// Klägerseite" label-pair was wrong half the time. The new axis is
// "ours vs opponent" — the side toggle picks who WE are in this
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
// placement re-resolves around that pick.
//
// Column assignment per deadline (default opts.side === null keeps
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
//
//   - party=claimant  → ours when side ∈ {null,"claimant"}, else opponent
//   - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
//   - party=court     → court (independent of side)
//   - party=both      → BOTH ours AND opponent (mirror)
//
// When `opts.appellant` is set (claimant|defendant), "both" rows
// collapse to a single row in the appellant's column — the intent is
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
// really means "either party files, depending on who initiated".
// Appellant axis is independent of `side`: in an Appeal CoA, the
// appellant selector pins which party appealed; the side toggle
// still picks which of those is us.
export type Side = "claimant" | "defendant" | null;

// Internal column-position alias. "ours" is always rendered in the
// left grid column ("Unsere Seite"); "opponent" is always the right
// column ("Gegnerseite"). Field names mirror the labels so the
// bucketing primitive reads as a direct mapping.
type ColumnPosition = "ours" | "opponent";

export interface ColumnsBodyOpts {
  editable?: boolean;
  showNotes?: boolean;
  // Forwarded to deadlineCardHtml — see CardOpts.showDurations.
  // (m/paliad#133, t-paliad-302)
  showDurations?: boolean;
  // side: which side the user is on. Drives column placement;
  // does NOT filter rows. Default null = claimant-on-the-left
  // (i.e. "ours = claimant", legacy default).
  side?: Side;
  // appellant: which side initiated the appeal / counterclaim.
  // When set, party=both rows go to the appellant's column ONLY
  // (no mirror). Default null = mirror "both" into both cells
  // (legacy behaviour). Independent of `side`.
  appellant?: Side;
  // appealAware: forwarded to bucketDeadlinesIntoColumns when the
  // page is rendering an appeal_target-filtered timeline. Routes
  // each rule to its filer-perspective column via dl.appealRole
  // instead of the legacy primary_party='both' collapse.
  // (t-paliad-307 / m/paliad#136 Bug 1)
  appealAware?: boolean;
  // triggerEventLabel: forwarded to deadlineCardHtml — see CardOpts.
  // (t-paliad-307 / m/paliad#136 Bug 3)
  triggerEventLabel?: string;
}

// ColumnsRow is the per-due-date bucket the renderer consumes. Public
// so unit tests can hit the pure routing logic without going through
// document.createElement (no jsdom in this repo).
export interface ColumnsRow {
  key: string;
  ours: CalculatedDeadline[];
  court: CalculatedDeadline[];
  opponent: CalculatedDeadline[];
}

export interface BucketingOpts {
  side?: Side;
  appellant?: Side;
  // appealAware: when true, rules carrying a `dl.appealRole` of
  // "appellant" / "appellee" route via the appeal role + user side
  // axis instead of the legacy primary_party='both' collapse. With
  // `side=null` the bucketer keeps the mirror semantic (both columns
  // render every appeal rule); with `side` set, "appellant" rules
  // land in the user's column when the user IS the appellant, in
  // the opponent's column otherwise — mirror for "appellee" rules.
  // (t-paliad-307 / m/paliad#136 Bug 1)
  appealAware?: boolean;
}

// bucketDeadlinesIntoColumns is the pure routing primitive that
// renderColumnsBody uses. Extracted as its own export so the per-row
// column placement (including the side-swap + appellant-collapse
// logic from m/paliad#81 and the user-perspective re-frame from
// m/paliad#88) is unit-testable without a DOM. The returned rows are
// sorted: dated rows ascending by dueDate, then unscheduled rows in
// declaration order (each keyed by sequence).
export function bucketDeadlinesIntoColumns(
  deadlines: CalculatedDeadline[],
  opts: BucketingOpts = {},
): ColumnsRow[] {
  const userSide: Side = opts.side ?? null;
  // Default (side=null) treats the user as claimant — keeps the
  // legacy claimant-on-the-left layout when no perspective is picked.
  const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
  const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
  const appellantColumn: ColumnPosition | null =
    opts.appellant === "claimant" ? claimantColumn
    : opts.appellant === "defendant" ? defendantColumn
    : null;

  const UNSCHEDULED_PREFIX = "__unscheduled__";
  const rowsMap = new Map<string, ColumnsRow>();
  const ensureRow = (key: string): ColumnsRow => {
    let r = rowsMap.get(key);
    if (!r) {
      r = { key, ours: [], court: [], opponent: [] };
      rowsMap.set(key, r);
    }
    return r;
  };

  const appealAware = opts.appealAware === true;

  deadlines.forEach((dl, idx) => {
    const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
    const row = ensureRow(key);
    switch (dl.party) {
      case "claimant":
        row[claimantColumn].push(dl);
        break;
      case "defendant":
        row[defendantColumn].push(dl);
        break;
      case "court":
        row.court.push(dl);
        break;
      case "both":
        // t-paliad-265: a per-card appellant set on a decision
        // ancestor propagates as appellantContext on this rule. When
        // present, it overrides the page-level appellant for the
        // collapse decision on THIS row. Falls through to page-level
        // when empty.
        if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
          const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
          row[perCardCol].push(dl);
        } else if (
          appealAware &&
          (dl.appealRole === "appellant" || dl.appealRole === "appellee")
        ) {
          // Appeal-aware routing (t-paliad-307 / m/paliad#136 Bug 1).
          // With no side picked, mirror to both columns so every rule
          // is visible regardless of which side the user is on. With
          // a side picked, route by (filer matches user) → ours
          // column, else opponent column. side=claimant maps the
          // user to "appellant" (Berufungskläger); side=defendant
          // maps the user to "appellee" (Berufungsbeklagter).
          if (userSide === null) {
            row.ours.push(dl);
            row.opponent.push(dl);
          } else {
            const userIsAppellant = userSide === "claimant";
            const filerIsAppellant = dl.appealRole === "appellant";
            row[filerIsAppellant === userIsAppellant ? "ours" : "opponent"].push(dl);
          }
        } else if (appellantColumn !== null) {
          // Role-swap collapse: appellant initiated → both → one row
          // in appellant's column. Mirror suppressed.
          row[appellantColumn].push(dl);
        } else if (userSide !== null) {
          // Side picked but no appellant axis (first-instance Inf, Rev,
          // …): the user has committed to a perspective, so the mirror
          // is visual noise — the same card appears twice on the same
          // row, once in "Unsere Seite" and once in "Gegnerseite".
          // Collapse into ours; the "↔ beide Seiten" indicator on the
          // card already conveys that the rule applies to both parties.
          // (m/paliad#135 / t-paliad-304)
          row.ours.push(dl);
        } else {
          // No perspective picked → keep the legacy mirror so neither
          // axis is privileged. Pinned by the "default (no opts)" test.
          row.ours.push(dl);
          row.opponent.push(dl);
        }
        break;
      default:
        row.court.push(dl);
    }
  });

  const datedKeys: string[] = [];
  const unscheduledKeys: string[] = [];
  for (const k of rowsMap.keys()) {
    if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
    else datedKeys.push(k);
  }
  datedKeys.sort();
  unscheduledKeys.sort();
  return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
}

export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
  const userSide: Side = opts.side ?? null;
  const rows = bucketDeadlinesIntoColumns(data.deadlines, {
    side: userSide,
    appellant: opts.appellant,
    appealAware: opts.appealAware,
  });
  const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";

  const cardOpts: CardOpts = {
    showParty: false,
    editable: opts.editable,
    showNotes: opts.showNotes,
    showDurations: opts.showDurations,
    triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
  };

  // Collapsed "both" rows lose their mirror tag — there's no longer
  // a sibling row to mirror to, so the "↔ beide Seiten" hint would
  // be misleading. Both collapse paths suppress it:
  //   - appellantPinned: role-swap collapse into appellant's column
  //   - userSide !== null without appellantPinned: perspective-locked
  //     collapse into ours (m/paliad#135 / t-paliad-304).
  // Legacy mirror path (no side, no appellant) keeps the tag — both
  // sibling rows still render so the tag has a visual referent.
  const sideCollapse = userSide !== null;
  const showMirrorTag = !appellantPinned && !sideCollapse;

  const renderCell = (items: CalculatedDeadline[]): string => {
    if (items.length === 0) {
      return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
    }
    const cards = items
      .map((dl) => {
        const mirrorTag = showMirrorTag && dl.party === "both"
          ? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
          : "";
        const itemClasses = [
          "fr-col-item",
          dl.isRootEvent ? "fr-col-root" : "",
          // t-paliad-290: re-surfaced hidden cards render faded via the
          // shared fr-col-item--hidden modifier.
          dl.isHidden ? "fr-col-item--hidden" : "",
          // t-paliad-289: same conditional treatment as the linear
          // timeline-item — dotted border + faded styling.
          dl.isConditional ? "fr-col-item--conditional" : "",
        ].filter(Boolean).join(" ");
        // data-rule-id on the card root lets the Litigation Builder
        // overlay per-card state (planned/filed/skipped) + action
        // affordances onto cards rendered through this shared body
        // without re-implementing the columns renderer. Empty on
        // synthetic rows (appeal trigger marker etc.); the Builder
        // skips state lookup when missing.
        const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
        const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
        return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
          ${deadlineCardHtml(dl, cardOpts)}
          ${mirrorTag}
        </div>`;
      })
      .join("");
    return `<div class="fr-col-cell">${cards}</div>`;
  };

  const headerCell = (label: string, cls: string) =>
    `<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;

  // Column-header labels have two modes (m/paliad#127):
  //   - side picked  → "Unsere Seite" / "Gegnerseite" (the columns
  //                    truthfully describe whose filings sit there,
  //                    because the bucketer routed the user's side into
  //                    `ours`).
  //   - side === null → "Proaktiv" / "Reaktiv" (semantic-neutral). The
  //                    user-perspective labels would lie here: we don't
  //                    know yet which party is "us", so calling the left
  //                    column "Unsere Seite" presumes a pick the user
  //                    hasn't made. The neutral Proaktiv/Reaktiv pair
  //                    keeps the spatial axis ("who initiates vs who
  //                    responds") legible while the hint chip on the
  //                    page nudges the user to pick a side.
  //
  // Note: the COLUMN PROJECTION does not change — the bucketing primitive
  // still routes claimant→left, defendant→right when side=null (legacy
  // claimant-on-the-left fallback). Only the HEADER label changes.
  const leftLabel = userSide === null ? t("deadlines.col.proactive") : t("deadlines.col.ours");
  const rightLabel = userSide === null ? t("deadlines.col.reactive") : t("deadlines.col.opponent");
  let html = '<div class="fr-columns-view">';
  html += headerCell(leftLabel, "fr-col-ours");
  html += headerCell(t("deadlines.col.court"), "fr-col-court");
  html += headerCell(rightLabel, "fr-col-opponent");

  for (const row of rows) {
    html += renderCell(row.ours);
    html += renderCell(row.court);
    html += renderCell(row.opponent);
  }
  html += "</div>";
  return html;
}

// ─── calculate fetch wrapper ──────────────────────────────────────────────

export async function calculateDeadlines(params: CalcParams): Promise<DeadlineResponse | null> {
  if (!params.proceedingType || !params.triggerDate) return null;
  try {
    const resp = await fetch("/api/tools/fristenrechner", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        proceedingType: params.proceedingType,
        triggerDate: params.triggerDate,
        priorityDate: params.priorityDate || undefined,
        flags: params.flags && params.flags.length > 0 ? params.flags : undefined,
        anchorOverrides: params.anchorOverrides && Object.keys(params.anchorOverrides).length > 0
          ? params.anchorOverrides
          : undefined,
        courtId: params.courtId || undefined,
        projectId: params.projectId || undefined,
        perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
          ? params.perCardChoices
          : undefined,
        includeHidden: params.includeHidden ? true : undefined,
        appealTarget: params.appealTarget || undefined,
        includeOptional: params.includeOptional ? true : undefined,
        triggerEventAnchors: params.triggerEventAnchors && Object.keys(params.triggerEventAnchors).length > 0
          ? params.triggerEventAnchors
          : undefined,
      }),
    });
    if (!resp.ok) {
      const err = await resp.json().catch(() => ({}));
      console.error("API error:", err);
      return null;
    }
    return (await resp.json()) as DeadlineResponse;
  } catch (e) {
    console.error("Fetch error:", e);
    return null;
  }
}

// ─── court picker ─────────────────────────────────────────────────────────

const courtCache = new Map<string, CourtRow[]>();

export function courtTypesFor(proceedingType: string): string[] {
  if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
    return ["UPC-CoA"];
  }
  if (proceedingType === "upc.rev.cfi") {
    return ["UPC-CD", "UPC-LD"];
  }
  if (proceedingType.startsWith("upc.")) {
    return ["UPC-LD"];
  }
  return [];
}

export function defaultCourtFor(proceedingType: string): string {
  if (proceedingType === "upc.apl.merits" || proceedingType === "upc.apl.order" || proceedingType === "upc.apl.cost") {
    return "upc-coa-luxembourg";
  }
  if (proceedingType === "upc.rev.cfi") {
    return "upc-cd-paris";
  }
  return "upc-ld-muenchen";
}

export async function fetchCourts(courtType: string): Promise<CourtRow[]> {
  if (courtCache.has(courtType)) return courtCache.get(courtType)!;
  try {
    const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
    if (!resp.ok) return [];
    const rows = (await resp.json()) as CourtRow[];
    courtCache.set(courtType, rows);
    return rows;
  } catch {
    return [];
  }
}

// populateCourtPicker fills the <select> for the proceeding's compatible
// court types. The row + select IDs are passed in so each page can own
// its own DOM scope. Visible only when the proceeding has ≥2 compatible
// courts; otherwise hidden (server resolves the jurisdiction default).
export async function populateCourtPicker(
  rowId: string,
  selectId: string,
  proceedingType: string,
): Promise<void> {
  const row = document.getElementById(rowId);
  const select = document.getElementById(selectId) as HTMLSelectElement | null;
  if (!row || !select) return;

  const types = courtTypesFor(proceedingType);
  if (types.length === 0) {
    row.style.display = "none";
    select.innerHTML = "";
    return;
  }

  const lists = await Promise.all(types.map((c) => fetchCourts(c)));
  const courts = lists.flat();
  if (courts.length <= 1) {
    row.style.display = "none";
    select.innerHTML = "";
    return;
  }

  const lang = getLang();
  const defaultID = defaultCourtFor(proceedingType);
  select.innerHTML = courts.map((c) => {
    const name = lang === "en" ? c.nameEN : c.nameDE;
    return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
  }).join("");
  row.style.display = "";
}
