// /tools/verfahrensablauf client (t-paliad-179 Slice 1)
//
// Abstract-browse surface: pick a proceeding, pick a trigger date,
// see the typical timeline. No Akte, no save-to-project, no anchor
// override editing, no Pathway B cascade. Variant chips + lane view
// (Slice 3) and compare (Slice 4) layer on top of this in later
// slices. Court picker + view toggle + calc fetch + renderers all
// come from ./views/verfahrensablauf-core, which fristenrechner.ts
// shares.

import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import {
  type DeadlineResponse,
  calculateDeadlines,
  escHtml,
  formatDate,
  populateCourtPicker,
  renderColumnsBody,
  renderTimelineBody,
  wireDateEditClicks,
} from "./views/verfahrensablauf-core";
import {
  attachEventCardChoices,
  reseedChips,
  type EventChoice,
} from "./views/event-card-choices";
import {
  APPEAL_TARGETS,
  SCENARIO_KEYS,
  type AppealTarget,
  type Side,
  type StorageLike,
  applyFiltersToSearch,
  makeMemoryStorage,
  parseAppealTargetFromSearch,
  parseProceedingFromSearch,
  parseSideFromSearch,
  parseTriggerDateFromSearch,
  readBoolFlag,
  readCourtId,
  readEventChoices,
  writeBoolFlag,
  writeCourtId,
  writeEventChoices,
} from "./views/verfahrensablauf-state";

let selectedType = "";
let lastResponse: DeadlineResponse | null = null;

// Perspective state. URL-driven so the view is shareable + survives
// reload:
//   ?side=claimant|defendant — swaps which column owns the user's
//                              side (proactive vs reactive label).
//                              Default null = claimant-on-the-left.
//
// t-paliad-301 / m/paliad#132 collapsed the duplicate ?side= +
// ?appellant= selectors into the single proactive-side picker above.
// For role-swap proceedings (Appeal / EPA Opposition / DE Revision /
// DPMA Appeal) the picker's labels swap to per-proceeding role
// strings (Berufungskläger / Berufungsbeklagter, …) via ROLE_LABELS
// below — but the underlying claimant/defendant value the engine
// consumes is unchanged.
let currentSide: Side = null;

// Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the
// page is opened with ?project=<id> and that project has our_side set,
// the side row renders as a read-only chip instead of the radio cluster.
// The user can flip to free-pick via the "Andere Seite wählen" override
// link, which clears this flag (radio cluster takes over again).
let sidePrefilledFromProject = false;

// Role-swap proceedings — the side picker doubles as the appellant
// axis. After t-paliad-301 collapsed the duplicate selectors, the
// engine reads "appellant" from the single side value for these
// proceedings (so a row with primary_party=both renders only in the
// chosen side's column). For first-instance proceedings (Inf, Rev,
// …) the side picker still narrows columns but doesn't collapse
// the "both" rows.
//
// upc.apl.unified is NOT in this set since t-paliad-307: appeal
// timelines route via per-rule appealRole (engine-stamped under
// appeal_target) instead of the page-level appellant axis collapse.
// Adding upc.apl.unified here would short-circuit the appealAware
// path and re-introduce the dead side selector on upc.apl.unified
// (m/paliad#136 Bug 1).
const APPELLANT_AXIS_PROCEEDINGS = new Set([
  "de.inf.olg",
  "de.inf.bgh",
  "de.null.bgh",
  "dpma.appeal.bpatg",
  "dpma.appeal.bgh",
  "epa.opp.boa",
]);

// Per-proceeding role labels (t-paliad-301 / m/paliad#132 Bug A).
// Mirrors paliad.proceeding_types.role_*_label_* — the canonical
// definition lives in the DB; this map is the frontend's view of
// it. Proceedings absent from the map fall back to the generic
// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
//
// Keep in sync with mig 137's backfill. Adding a row here without a
// matching DB row is fine (the DB col is NULL → still falls back to
// default; UI shows the override). Adding to the DB without here
// means the UI uses defaults — harmless but inconsistent.
type RoleLabels = { proDE: string; reDE: string; proEN: string; reEN: string };
const ROLE_LABELS: Record<string, RoleLabels> = {
  "upc.apl.unified": {
    proDE: "Berufungskläger",
    reDE:  "Berufungsbeklagter",
    proEN: "Appellant",
    reEN:  "Appellee",
  },
  "upc.rev.cfi": {
    proDE: "Antragsteller (Nichtigkeit)",
    reDE:  "Antragsgegner (Nichtigkeit)",
    proEN: "Revocation claimant",
    reEN:  "Revocation defendant",
  },
  "epa.opp.opd": {
    proDE: "Einsprechende(r)",
    reDE:  "Patentinhaber(in)",
    proEN: "Opponent",
    reEN:  "Patentee",
  },
  "epa.opp.boa": {
    proDE: "Einsprechende(r)",
    reDE:  "Patentinhaber(in)",
    proEN: "Opponent",
    reEN:  "Patentee",
  },
};

// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
// Proceedings that surface the appeal-target chip group. Currently
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
// can opt in by adding the code here.
//
// APPEAL_TARGETS itself lives in ./views/verfahrensablauf-state so the
// pure URL parser and this page share the same canonical list.
const APPEAL_TARGET_PROCEEDINGS = new Set([
  "upc.apl.unified",
]);

function hasAppealTarget(proceedingType: string): boolean {
  return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
}

function hasAppellantAxis(proceedingType: string): boolean {
  return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
}

// Scenario storage — real localStorage in the browser, in-memory
// fallback when localStorage throws (private mode, disabled storage,
// etc.). All scenario writes go through this single handle so a
// failure mode is isolated to one try/catch path.
const scenarioStorage: StorageLike = makeScenarioStorage();

function makeScenarioStorage(): StorageLike {
  try {
    const probe = "__paliad_va_probe__";
    window.localStorage.setItem(probe, "1");
    window.localStorage.removeItem(probe);
    return window.localStorage;
  } catch {
    return makeMemoryStorage();
  }
}

// URL writers — all four chip params route through this single helper
// so the canonical query-string shape (no empty values, no trailing
// `?`) is enforced in one place.
function applyURLFilters(filters: {
  proceeding?: string;
  side?: Side;
  target?: AppealTarget;
  triggerDate?: string;
}): void {
  const url = new URL(window.location.href);
  const nextSearch = applyFiltersToSearch(url.search, filters);
  window.history.replaceState(null, "", url.pathname + nextSearch + url.hash);
}

// t-paliad-301 / m/paliad#132: applies ROLE_LABELS to the side-row
// radio labels for the currently selected proceeding. Proceedings
// without an entry fall back to the existing
// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
function applyRoleLabels(proceedingType: string) {
  const lang = getLang() === "en" ? "en" : "de";
  const claimantSpan = document.querySelector<HTMLElement>(
    "input[type=radio][name=side][value=claimant] + span"
  );
  const defendantSpan = document.querySelector<HTMLElement>(
    "input[type=radio][name=side][value=defendant] + span"
  );
  if (!claimantSpan || !defendantSpan) return;

  const labels = ROLE_LABELS[proceedingType];
  if (labels) {
    claimantSpan.textContent = lang === "en" ? labels.proEN : labels.proDE;
    defendantSpan.textContent = lang === "en" ? labels.reEN : labels.reDE;
  } else {
    // Default — let i18n drive via data-i18n attribute. Reset to the
    // canonical i18n value so a previous override doesn't stick when
    // switching from upc.apl.unified back to upc.inf.cfi.
    claimantSpan.textContent = t("deadlines.side.claimant");
    defendantSpan.textContent = t("deadlines.side.defendant");
  }
}

// Default target on first picker entry into upc.apl. m: Endentscheidung
// is the most-common appeal target; the chip group also defaults
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
// sync so the URL-less default render hits the same code path.
let currentAppealTarget: AppealTarget = "";

// Per-rule anchor overrides set by the click-to-edit affordance on
// timeline / column date cells. Posted as `anchorOverrides` to the
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
// user's chosen date. Cleared whenever the trigger changes (proceeding,
// trigger date, flag toggle) so a fresh calc starts unanchored — same
// semantic as /tools/fristenrechner.
const anchorOverrides = new Map<string, string>();
function clearAnchorOverrides() { anchorOverrides.clear(); }

// Per-event-card choices (t-paliad-265). Unbound on this page (no
// project context). Persistence moved from URL → localStorage under
// SCENARIO_KEYS.eventChoices (t-paliad-308 / m/paliad#137) — these
// are per-user scenario tweaks, not the timeline kind, so a shared
// link should NOT leak them into the recipient's view.
let perCardChoices: EventChoice[] = readEventChoices(scenarioStorage);

// Show-hidden toggle (t-paliad-290 / m/paliad#122). When ON, the
// calculator re-surfaces cards whose submission_code is in the active
// skipRules set; they render faded with a "Wieder einblenden" chip.
// Persistence moved from URL → localStorage (t-paliad-308) — it's a
// per-user UX preference, not scenario state worth sharing in a link.
let showHidden = readBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden);

type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";

// Notes toggle — when off (default), per-rule descriptive notes render
// as a compact ⓘ icon next to the meta line (hover for full text). When
// on, the full notes block expands under each card. Choice persists in
// localStorage so a reload or recalc keeps the user's preference.
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
function readNotesPref(): boolean {
  try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
}
function writeNotesPref(on: boolean): void {
  try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showNotes = readNotesPref();

// Durations toggle (m/paliad#133, t-paliad-302) — when off (default),
// the per-rule duration label ("2 Mo. nach") only shows on hover via
// the date span's `title` attribute. When on, the label renders inline
// in the timeline meta row of every event card. Persisted in
// localStorage under its own key so the preference is independent of
// "Hinweise anzeigen".
const DURATIONS_PREF_KEY = "paliad.verfahrensablauf.durations-show";
function readDurationsPref(): boolean {
  try { return localStorage.getItem(DURATIONS_PREF_KEY) === "1"; } catch { return false; }
}
function writeDurationsPref(on: boolean): void {
  try { localStorage.setItem(DURATIONS_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
}
let showDurations = readDurationsPref();

// Jurisdiction display prefix for the proceeding-summary chip + the
// trigger-event placeholder. Same forum slugs the .proceeding-group
// `data-forum` attribute carries in verfahrensablauf.tsx /
// fristenrechner.tsx (upc / de / epa / dpma). Disambiguates the
// 4 redundancies in the corpus (UPC Verletzungsverfahren vs DE
// Verletzungsklage etc.) once the picker collapses.
const FORUM_LABEL: Record<string, string> = {
  upc: "UPC",
  de: "DE",
  epa: "EPA",
  dpma: "DPMA",
};

function jurisdictionFor(btn: HTMLButtonElement): string {
  const group = btn.closest<HTMLElement>(".proceeding-group");
  const forum = group?.dataset.forum || "";
  return FORUM_LABEL[forum] || "";
}

function proceedingDisplayName(btn: HTMLButtonElement): string {
  const name = btn.querySelector("strong")?.textContent || "";
  const jur = jurisdictionFor(btn);
  return jur ? `${jur} ${name}` : name;
}

function activeProceedingButton(): HTMLButtonElement | null {
  return document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
}

// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
// so rapid input changes never let a stale response overwrite a fresh
// one.
let calcSeq = 0;
let calcTimer: ReturnType<typeof setTimeout> | null = null;

function scheduleCalc(delayMs = 200) {
  if (calcTimer !== null) clearTimeout(calcTimer);
  calcTimer = setTimeout(() => {
    calcTimer = null;
    void doCalc();
  }, delayMs);
}

function showStep(n: number) {
  for (let i = 1; i <= 3; i++) {
    const el = document.getElementById(`step-${i}`);
    if (el) el.style.display = i <= n ? "block" : "none";
  }
}

// Read the proceeding-specific flag checkboxes and assemble the
// payload the calculator expects. Mirrors fristenrechner.ts so the
// gating semantics stay identical: with_amend on upc.inf.cfi is
// nested under with_ccr (R.30 is only available with a CCR);
// upc.rev.cfi exposes with_amend + with_cci as two independent
// gates. R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18
// call): it's just an always-available optional submission, so it
// has no checkbox.
function readFlags(): string[] {
  const ccr      = document.getElementById("ccr-flag")        as HTMLInputElement | null;
  const infAmend = document.getElementById("inf-amend-flag")  as HTMLInputElement | null;
  const revAmend = document.getElementById("rev-amend-flag")  as HTMLInputElement | null;
  const revCci   = document.getElementById("rev-cci-flag")    as HTMLInputElement | null;
  const flags: string[] = [];
  if (selectedType === "upc.inf.cfi") {
    if (ccr?.checked)                       flags.push("with_ccr");
    if (ccr?.checked && infAmend?.checked)  flags.push("with_amend");
  }
  if (selectedType === "upc.rev.cfi") {
    if (revAmend?.checked) flags.push("with_amend");
    if (revCci?.checked)   flags.push("with_cci");
  }
  return flags;
}

async function doCalc() {
  const seq = ++calcSeq;
  const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
  const triggerDate = dateInput?.value || "";
  if (!triggerDate || !selectedType) return;

  const courtPickerRow = document.getElementById("court-picker-row");
  const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
  const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
    ? courtPicker.value
    : "";

  const overrides: Record<string, string> = {};
  for (const [code, date] of anchorOverrides) overrides[code] = date;

  // Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung,
  // default to "endentscheidung" when no chip pick is stored in URL.
  // For non-appeal proceedings the engine ignores opts.AppealTarget.
  const appealTarget = hasAppealTarget(selectedType)
    ? (currentAppealTarget || "endentscheidung")
    : "";

  const data = await calculateDeadlines({
    proceedingType: selectedType,
    triggerDate,
    flags: readFlags(),
    anchorOverrides: overrides,
    courtId,
    perCardChoices,
    includeHidden: showHidden,
    appealTarget,
  });
  if (seq !== calcSeq) return;
  if (!data) return;
  lastResponse = data;
  renderResults(data);
  syncHiddenBadge(data.hiddenCount ?? 0);
  showStep(3);
}

// syncHiddenBadge updates the "Ausgeblendete (N)" count next to the
// toggle. Visible regardless of toggle state so the user knows whether
// there's anything to re-surface even when the toggle is OFF. Hides the
// whole row when the projection has zero hidden cards — no clutter on
// a project that's never used the skip feature. (t-paliad-290)
function syncHiddenBadge(count: number) {
  const row = document.getElementById("show-hidden-row");
  const badge = document.getElementById("show-hidden-count");
  if (!row || !badge) return;
  if (count <= 0) {
    row.style.display = "none";
    return;
  }
  row.style.display = "";
  badge.textContent = tDyn("choices.show_hidden.count").replace("{n}", String(count));
}

// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. Precedence:
//
//   1. Server-supplied triggerEventLabel from proceeding_types
//      (mig 121, m/paliad#81). UPC Appeal sets this to
//      "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
//      all carry a non-zero duration off the trigger date so none is
//      the root, and the proceedingName fallback ("Berufungsverfahren")
//      misnamed the input as the proceeding itself.
//   2. Root rule (isRootEvent=true) — the first event in the
//      proceeding, e.g. Klageerhebung for upc.inf.cfi,
//      Nichtigkeitsklage for upc.rev.cfi.
//   3. Active proceeding name — last-resort fallback. Language-aware
//      (m/paliad#58: prior code rendered DE on EN for sub-track
//      proceedings like upc.ccr.cfi which had no rules → no root).
function triggerEventLabelFor(data: DeadlineResponse): string {
  const lang = getLang();
  const curated = lang === "en"
    ? (data.triggerEventLabelEN || data.triggerEventLabel)
    : (data.triggerEventLabel || data.triggerEventLabelEN);
  if (curated) return curated;
  const root = data.deadlines.find((d) => d.isRootEvent);
  if (root) {
    return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
  }
  if (lang === "en") {
    return data.proceedingNameEN || data.proceedingName || "";
  }
  return data.proceedingName || data.proceedingNameEN || "";
}

function syncTriggerEventLabel() {
  const triggerEventEl = document.getElementById("trigger-event");
  if (!triggerEventEl) return;
  if (lastResponse) {
    triggerEventEl.textContent = triggerEventLabelFor(lastResponse);
  } else {
    triggerEventEl.textContent = "—";
  }
}

function renderResults(data: DeadlineResponse) {
  const container = document.getElementById("timeline-container");
  if (!container) return;
  const printBtn = document.getElementById("fristen-print-btn");
  const toggle = document.getElementById("fristen-view-toggle");

  // Header shows the picked proceeding with its jurisdiction prefix
  // so the user can tell UPC Verletzungsverfahren apart from DE
  // Verletzungsklage once the picker collapses.
  const activeBtn = activeProceedingButton();
  const procName = activeBtn ? proceedingDisplayName(activeBtn)
    : tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
  const headerHtml = `<div class="timeline-header">
    <strong>${procName}</strong>
    <span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
  </div>`;

  // Sub-track contextual note (m/paliad#58). Surfaces above the
  // timeline body when the server routed the user-picked proceeding
  // through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr).
  // Plain-text banner — server-side copy is plain text per the
  // SubTrackRouting contract.
  const noteText = getLang() === "en"
    ? (data.contextualNoteEN || data.contextualNote || "")
    : (data.contextualNote || data.contextualNoteEN || "");
  const noteHtml = noteText
    ? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
    : "";

  const bodyHtml = procedureView === "columns"
    ? renderColumnsBody(data, {
        editable: true,
        showNotes,
        showDurations,
        side: currentSide,
        // t-paliad-301: the appellant axis collapses into the single
        // side picker. For role-swap proceedings, currentSide IS the
        // appellant pick (so a row with primary_party=both renders only
        // in the picked side's column). For non-role-swap proceedings,
        // the appellant axis is irrelevant — pass null.
        appellant: hasAppellantAxis(selectedType) ? currentSide : null,
        // Appeal-target proceedings get per-rule appealRole routing
        // instead of the page-level appellant collapse, so the side
        // selector actually splits Berufungskläger vs Berufungs-
        // beklagter filings across columns. (t-paliad-307 /
        // m/paliad#136 Bug 1)
        appealAware: hasAppealTarget(selectedType),
      })
    : renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations });

  container.innerHTML = headerHtml + noteHtml + bodyHtml;
  if (printBtn) printBtn.style.display = "block";
  if (toggle) toggle.style.display = "";

  syncTriggerEventLabel();

  // t-paliad-265: rehydrate per-event-card chip indicators after every
  // re-render so the popover-driven active state survives the
  // innerHTML rewrite the timeline body just did.
  reseedChips(container);
}

function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
  const groups = document.querySelectorAll<HTMLElement>(".proceeding-group");
  const summary = document.getElementById("proceeding-summary") as HTMLElement | null;
  const summaryName = document.getElementById("proceeding-summary-name");
  groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; });
  if (summary) summary.style.display = collapsed ? "" : "none";
  if (summaryName && displayName) summaryName.textContent = displayName;
}

// syncFlagRows shows/hides the proceeding-specific checkbox rows
// based on selectedType. Same disposition as fristenrechner.ts —
// the with_amend nested-under-ccr semantic is enforced via
// syncInfAmendEnabled().
function syncFlagRows() {
  const show = (id: string, when: boolean) => {
    const el = document.getElementById(id);
    if (el) el.style.display = when ? "" : "none";
  };
  show("ccr-flag-row",       selectedType === "upc.inf.cfi");
  show("inf-amend-flag-row", selectedType === "upc.inf.cfi");
  show("rev-amend-flag-row", selectedType === "upc.rev.cfi");
  show("rev-cci-flag-row",   selectedType === "upc.rev.cfi");
  syncInfAmendEnabled();
}

// R.30 amendment-application is only available with a CCR — disable
// (and clear) the nested inf-amend checkbox while ccr is off so the
// calc payload stays coherent. Mirrors fristenrechner.ts.
function syncInfAmendEnabled() {
  const ccr      = document.getElementById("ccr-flag")       as HTMLInputElement | null;
  const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
  if (!ccr || !infAmend) return;
  infAmend.disabled = !ccr.checked;
  if (!ccr.checked) infAmend.checked = false;
}

function selectProceeding(btn: HTMLButtonElement, opts: { writeURL?: boolean } = {}) {
  document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
  btn.classList.add("active");
  const nextType = btn.dataset.code || "";
  // Different proceeding tree → previously-set overrides reference
  // rule codes that don't exist in the new tree. Clear before the
  // next calc so the fresh proceeding starts unanchored.
  if (selectedType !== nextType) clearAnchorOverrides();
  selectedType = nextType;

  // Persist the picked proceeding to ?proceeding= so a refresh / shared
  // link reproduces the same tile. writeURL=false on the load-time
  // hydration path so we don't churn history.replaceState when the
  // URL already carries the canonical value.
  if (opts.writeURL !== false) {
    applyURLFilters({ proceeding: selectedType });
  }

  // Trigger-event label fires from the calc response (root rule).
  // Until step 3 renders, fall back to an em-dash placeholder.
  lastResponse = null;
  syncTriggerEventLabel();

  syncFlagRows();
  syncAppealTargetRowVisibility();
  applyRoleLabels(selectedType);
  // Restore flags from localStorage BEFORE the initial calc so the
  // first /api/tools/fristenrechner POST already carries the user's
  // stored flag state. Court_id is async (populateCourtPicker fetches
  // courts from the API) so it restores via the .then() below + a
  // follow-up recalc when the picker is ready.
  restoreFlagsForProceeding();

  setProceedingPickerCollapsed(true, proceedingDisplayName(btn));

  showStep(2);
  scheduleCalc(0);

  void populateCourtPicker("court-picker-row", "court-picker", selectedType).then(() => {
    if (restoreCourtForProceeding()) scheduleCalc(0);
  });
}

// restoreFlagsForProceeding seeds the proceeding-specific flag
// checkboxes from localStorage. Mirrors syncFlagRows in scope — only
// flags currently visible for the active proceeding are meaningful
// (the hidden checkboxes still write to localStorage if toggled, but
// that's impossible because they're not in the DOM as visible
// controls). syncInfAmendEnabled enforces the upc.inf.cfi inf-amend
// gating after the restore.
function restoreFlagsForProceeding(): void {
  const flagPairs: Array<[string, string]> = [
    ["ccr-flag",       SCENARIO_KEYS.ccr],
    ["inf-amend-flag", SCENARIO_KEYS.infAmend],
    ["rev-amend-flag", SCENARIO_KEYS.revAmend],
    ["rev-cci-flag",   SCENARIO_KEYS.revCci],
  ];
  for (const [domId, storageKey] of flagPairs) {
    const cb = document.getElementById(domId) as HTMLInputElement | null;
    if (!cb) continue;
    cb.checked = readBoolFlag(scenarioStorage, storageKey);
  }
  syncInfAmendEnabled();
}

// restoreCourtForProceeding tries to apply the localStorage court_id
// to the picker after populateCourtPicker resolves. Returns true iff
// a value actually changed (so the caller can schedule a follow-up
// calc). Skips silently when the picker is hidden, the stored ID isn't
// in the options list (court rotated since last visit), or the picker
// already happens to be on the stored value.
function restoreCourtForProceeding(): boolean {
  const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
  const storedCourtId = readCourtId(scenarioStorage);
  if (!courtPicker || !storedCourtId) return false;
  const has = Array.from(courtPicker.options).some((o) => o.value === storedCourtId);
  if (!has) return false;
  if (courtPicker.value === storedCourtId) return false;
  courtPicker.value = storedCourtId;
  return true;
}

// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
// syncAppealTargetRowVisibility shows the appeal-target chip group
// when the unified upc.apl Berufung tile is selected, hides it
// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears
// state + URL when hiding so a stale ?target= can't leak.
function syncAppealTargetRowVisibility() {
  const row = document.getElementById("appeal-target-row");
  if (!row) return;
  const visible = hasAppealTarget(selectedType);
  row.style.display = visible ? "" : "none";
  if (!visible && currentAppealTarget !== "") {
    currentAppealTarget = "";
    applyURLFilters({ target: "" });
    syncRadioGroup("appeal-target", "endentscheidung");
  }
}

function syncRadioGroup(name: string, value: string) {
  document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
    input.checked = input.value === value;
  });
}

// Project context (t-paliad-279 / m/paliad#111). When the page is opened
// with ?project=<id> and the project carries an our_side value, the side
// row renders as a read-only chip with an "Andere Seite wählen" override
// link. The proceeding picker + appellant axis stay untouched — only the
// side selector pre-fills.
interface ProjectOurSide {
  id: string;
  our_side?:
    | "claimant"
    | "defendant"
    | "applicant"
    | "appellant"
    | "respondent"
    | "third_party"
    | "other"
    | null;
}

function readProjectFromURL(): string {
  return new URLSearchParams(window.location.search).get("project") || "";
}

// ourSideToSide maps the project-level our_side enum (t-paliad-222) onto
// the side-selector's two-value axis. Active roles (claimant / applicant /
// appellant) collapse to "claimant"; reactive roles (defendant /
// respondent) collapse to "defendant"; everything else (third_party /
// other / NULL) returns null = no pre-fill. Mirrors fristenrechner.ts
// ourSideToPerspective() so projects render consistently across both
// surfaces.
function ourSideToSide(os: ProjectOurSide["our_side"] | undefined): Side {
  switch (os) {
    case "claimant":
    case "applicant":
    case "appellant":
      return "claimant";
    case "defendant":
    case "respondent":
      return "defendant";
    default:
      return null;
  }
}

async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide | null> {
  try {
    const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}`, {
      credentials: "same-origin",
    });
    if (!resp.ok) return null;
    return (await resp.json()) as ProjectOurSide;
  } catch {
    return null;
  }
}

function sideLabelI18n(s: Side): string {
  if (s === "claimant") return t("deadlines.side.claimant");
  if (s === "defendant") return t("deadlines.side.defendant");
  return t("deadlines.side.undefined");
}

// syncSideHintVisibility shows the "pick a side" hint chip only while
// currentSide is unset (m/paliad#120). When the user has picked
// claimant / defendant the columns are already focused, so the prompt
// would be misleading.
function syncSideHintVisibility() {
  const hint = document.getElementById("side-hint");
  if (!hint) return;
  hint.style.display = currentSide === null ? "" : "none";
}

// renderSideChip swaps the radio cluster for a read-only chip showing
// the auto-filled side + an "Andere Seite wählen" override link. Called
// after fetchProjectOurSide resolves to a side. The override link clears
// the prefilled flag and swaps back to the radio cluster — the user can
// then pick any side freely.
function renderSideChip(side: Side) {
  const cluster = document.getElementById("side-radio-cluster");
  const chip = document.getElementById("side-chip");
  const value = document.getElementById("side-chip-value");
  if (!cluster || !chip || !value) return;
  cluster.style.display = "none";
  chip.style.display = "";
  value.textContent = sideLabelI18n(side);
}

function showSideRadioCluster() {
  const cluster = document.getElementById("side-radio-cluster");
  const chip = document.getElementById("side-chip");
  if (!cluster || !chip) return;
  cluster.style.display = "";
  chip.style.display = "none";
  // Cluster re-appears after override → re-evaluate hint visibility so
  // we don't leave a stale "pick a side" prompt above a checked radio.
  syncSideHintVisibility();
}

// applySidePrefill takes a project's our_side, maps it to the side axis,
// and locks the side row to a read-only chip if a mapping exists. URL
// wins — if ?side= is already explicit, the user (or shared link) has
// already chosen and we never overwrite. When we do prefill, write the
// derived side to the URL so reload + back/forward round-trip cleanly.
function applySidePrefill(os: ProjectOurSide["our_side"] | undefined) {
  if (parseSideFromSearch(window.location.search) !== null) return;
  const next = ourSideToSide(os);
  if (next === null) return;
  currentSide = next;
  applyURLFilters({ side: next });
  syncRadioGroup("side", next);
  sidePrefilledFromProject = true;
  renderSideChip(next);
  if (lastResponse) renderResults(lastResponse);
}

function clearSidePrefill() {
  sidePrefilledFromProject = false;
  showSideRadioCluster();
  // Drop ?project= from the URL so a reload doesn't re-lock the side.
  // ?side= stays — that's the user's last pick at this point.
  const url = new URL(window.location.href);
  url.searchParams.delete("project");
  window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}

async function initProjectAutofill() {
  const projectID = readProjectFromURL();
  if (!projectID) return;
  const project = await fetchProjectOurSide(projectID);
  if (!project) return;
  applySidePrefill(project.our_side);
}

function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
  // Mirrors the events.ts pattern (body.events-view-*). The print
  // stylesheet keys `body.verfahrensablauf-view-timeline` to
  // `@page paliad-landscape`, so flipping this class is what lets a
  // user print the horizontal timeline in landscape without affecting
  // the columns view (which stays portrait).
  document.body.classList.toggle("verfahrensablauf-view-timeline", view === "timeline");
  document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns");
}

function initViewToggle() {
  const toggle = document.getElementById("fristen-view-toggle");
  if (!toggle) return;

  const initial = new URLSearchParams(window.location.search).get("view");
  if (initial === "timeline") procedureView = "timeline";
  applyVerfahrensablaufViewBodyClass(procedureView);

  toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
    input.checked = input.value === procedureView;
    input.addEventListener("change", () => {
      if (!input.checked) return;
      procedureView = input.value === "columns" ? "columns" : "timeline";
      applyVerfahrensablaufViewBodyClass(procedureView);
      const url = new URL(window.location.href);
      if (procedureView === "columns") {
        url.searchParams.delete("view");
      } else {
        url.searchParams.set("view", procedureView);
      }
      history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
      if (lastResponse) renderResults(lastResponse);
    });
  });

  toggle.style.display = "none";
}

// initPerspectiveControls hydrates side+appellant from the URL,
// reflects state into the radio inputs, and wires onchange handlers
// that update state + URL + re-render. Re-render path skips the
// /api/tools/fristenrechner round-trip — perspective is a pure
// projection of the last response, no backend involved.
function initPerspectiveControls() {
  currentSide = parseSideFromSearch(window.location.search);
  currentAppealTarget = parseAppealTargetFromSearch(window.location.search);
  syncRadioGroup("side", currentSide ?? "");
  syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
  syncSideHintVisibility();

  document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
    input.addEventListener("change", () => {
      if (!input.checked) return;
      const v = input.value;
      currentSide = (v === "claimant" || v === "defendant") ? v : null;
      applyURLFilters({ side: currentSide });
      syncSideHintVisibility();
      if (lastResponse) renderResults(lastResponse);
    });
  });

  // Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler.
  // Each chip change re-fetches with the new target slug so the
  // timeline re-renders against the matching rule subset.
  document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appeal-target]").forEach((input) => {
    input.addEventListener("change", () => {
      if (!input.checked) return;
      const v = input.value;
      if ((APPEAL_TARGETS as readonly string[]).includes(v)) {
        currentAppealTarget = v as AppealTarget;
      } else {
        currentAppealTarget = "";
      }
      applyURLFilters({ target: currentAppealTarget });
      scheduleCalc(0);
    });
  });
}

document.addEventListener("DOMContentLoaded", () => {
  initI18n();
  initSidebar();

  document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
    btn.addEventListener("click", () => selectProceeding(btn));
  });

  document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
    setProceedingPickerCollapsed(false);
  });

  document.getElementById("calculate-btn")?.addEventListener("click", () => scheduleCalc(0));

  const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
  if (dateInput) {
    // Hydrate trigger_date from URL on first paint so a refresh /
    // shared link reproduces the same dated timeline. URL wins over
    // the verfahrensablauf.tsx today-default that the <input> renders
    // with. parseTriggerDateFromSearch validates the shape so a
    // malformed link silently falls back to the today-default.
    const urlDate = parseTriggerDateFromSearch(window.location.search);
    if (urlDate) dateInput.value = urlDate;
    const persistDate = () => {
      applyURLFilters({ triggerDate: dateInput.value });
    };
    dateInput.addEventListener("change", () => { persistDate(); scheduleCalc(); });
    dateInput.addEventListener("input", () => { persistDate(); scheduleCalc(); });
    dateInput.addEventListener("keydown", (e) => {
      if ((e as KeyboardEvent).key === "Enter") { persistDate(); scheduleCalc(0); }
    });
  }

  const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
  if (courtPicker) courtPicker.addEventListener("change", () => {
    writeCourtId(scenarioStorage, courtPicker.value);
    scheduleCalc(0);
  });

  // Flag-checkbox listeners — each flip triggers a fresh calc so the
  // timeline re-projects with the new gating. ccr-flag additionally
  // enables/disables the nested inf-amend row. Each flip also writes
  // through to localStorage so the choice survives a reload (URL stays
  // clean; flags are scenario state, not filter chips — t-paliad-308).
  const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
  if (ccrFlag) ccrFlag.addEventListener("change", () => {
    writeBoolFlag(scenarioStorage, SCENARIO_KEYS.ccr, ccrFlag.checked);
    syncInfAmendEnabled();
    // Disabling ccr also unchecks inf-amend (see syncInfAmendEnabled).
    // Mirror that into storage so the next reload doesn't repopulate a
    // disabled checkbox as checked.
    const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
    if (infAmend) writeBoolFlag(scenarioStorage, SCENARIO_KEYS.infAmend, infAmend.checked);
    scheduleCalc(0);
  });
  const flagStorageKeys: Record<string, string> = {
    "inf-amend-flag": SCENARIO_KEYS.infAmend,
    "rev-amend-flag": SCENARIO_KEYS.revAmend,
    "rev-cci-flag":   SCENARIO_KEYS.revCci,
  };
  for (const [id, storageKey] of Object.entries(flagStorageKeys)) {
    const cb = document.getElementById(id) as HTMLInputElement | null;
    if (cb) cb.addEventListener("change", () => {
      writeBoolFlag(scenarioStorage, storageKey, cb.checked);
      scheduleCalc(0);
    });
  }

  document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());

  // Click-to-edit on timeline / column date cells — same delegated
  // pattern as /tools/fristenrechner. Survives renderResults()'s
  // innerHTML rewrites because the listener lives on the container.
  const timelineContainer = document.getElementById("timeline-container");
  if (timelineContainer) {
    wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
      if (newValue === "") {
        anchorOverrides.delete(ruleCode);
      } else {
        anchorOverrides.set(ruleCode, newValue);
      }
      scheduleCalc(0);
    });
  }

  // Notes toggle — restores last preference on load + re-renders when
  // the user flips it. Lives in the same toggle bar as the view picker.
  const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
  if (notesShowCb) {
    notesShowCb.checked = showNotes;
    notesShowCb.addEventListener("change", () => {
      showNotes = notesShowCb.checked;
      writeNotesPref(showNotes);
      if (lastResponse) renderResults(lastResponse);
    });
  }

  // Durations toggle (m/paliad#133, t-paliad-302) — sibling of the
  // notes toggle. Hover-only labels (default) become inline labels when
  // the user opts in.
  const durationsShowCb = document.getElementById("verfahrensablauf-durations-show") as HTMLInputElement | null;
  if (durationsShowCb) {
    durationsShowCb.checked = showDurations;
    durationsShowCb.addEventListener("change", () => {
      showDurations = durationsShowCb.checked;
      writeDurationsPref(showDurations);
      if (lastResponse) renderResults(lastResponse);
    });
  }

  // t-paliad-290 — show-hidden toggle. Hydrated from localStorage at
  // module load (showHidden); each flip writes back to localStorage
  // and triggers a recalc (the backend reshapes the response — we
  // can't just re-render lastResponse since the hidden rows aren't
  // in it when the toggle was OFF).
  const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
  if (showHiddenCb) {
    showHiddenCb.checked = showHidden;
    showHiddenCb.addEventListener("change", () => {
      showHidden = showHiddenCb.checked;
      writeBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden, showHidden);
      scheduleCalc(0);
    });
  }

  initViewToggle();
  initPerspectiveControls();

  // t-paliad-265 — per-event-card choices. Unbound surface; persistence
  // is localStorage-only (t-paliad-308) so a shared link doesn't carry
  // the recipient's per-card tweaks. The popover module owns the
  // popover lifecycle; this page owns the recalc + storage plumbing.
  const timelineEl = document.getElementById("timeline-container");
  if (timelineEl) {
    attachEventCardChoices({
      container: timelineEl,
      initial: perCardChoices,
      commit: (choice) => {
        perCardChoices = perCardChoices.filter(
          (c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
        );
        perCardChoices.push(choice);
        writeEventChoices(scenarioStorage, perCardChoices);
        scheduleCalc(0);
      },
      remove: (submissionCode, kind) => {
        perCardChoices = perCardChoices.filter(
          (c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
        );
        writeEventChoices(scenarioStorage, perCardChoices);
        scheduleCalc(0);
      },
    });
  }

  // t-paliad-279 — override link on the prefilled side chip — swaps back
  // to the radio cluster and clears ?project= from the URL.
  document.getElementById("side-chip-override")?.addEventListener("click", clearSidePrefill);

  // Project autofill — runs after the radio cluster has its URL-driven
  // state so we never clobber an explicit ?side= pick. Fire-and-forget;
  // the chip swap happens once the project resolves.
  void initProjectAutofill();


  onLangChange(() => {
    // Active-button name updates with language change (the data-i18n
    // pass swaps the inner <strong>'s text). Re-collapse the summary
    // chip and re-derive the trigger event label from the lang-current
    // calc response.
    const activeBtn = activeProceedingButton();
    if (activeBtn) {
      const summary = document.getElementById("proceeding-summary-name");
      if (summary) summary.textContent = proceedingDisplayName(activeBtn);
    }
    // Side-chip label tracks language so a DE/EN flip while the chip is
    // visible re-renders the inferred side in the active language.
    if (sidePrefilledFromProject) {
      const value = document.getElementById("side-chip-value");
      if (value) value.textContent = sideLabelI18n(currentSide);
    }
    if (lastResponse) renderResults(lastResponse);
    syncTriggerEventLabel();
  });

  // Pre-select the proceeding tile. URL wins: if ?proceeding= is set
  // and points at a known tile, that tile is selected without rewriting
  // the URL. Otherwise fall back to the first tile so users see a
  // timeline immediately on landing — matches /tools/fristenrechner
  // behaviour. The auto-pick does NOT write the URL so the default
  // landing stays clean (`?proceeding=` only appears once the user
  // makes an explicit choice). (t-paliad-308 / m/paliad#137)
  const urlProceeding = parseProceedingFromSearch(window.location.search);
  let initialBtn: HTMLButtonElement | null = null;
  let urlHit = false;
  if (urlProceeding) {
    initialBtn = document.querySelector<HTMLButtonElement>(
      `.proceeding-btn[data-code="${urlProceeding.replace(/"/g, '\\"')}"]`,
    );
    urlHit = initialBtn !== null;
  }
  if (!initialBtn) {
    initialBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
  }
  if (initialBtn) {
    // writeURL=false when the URL either already carries this code
    // (no churn) or has no proceeding (auto-default → don't pollute
    // the clean URL). Only an unknown / stale ?proceeding= triggers
    // a rewrite so the URL converges on the resolved tile.
    const writeURL = urlProceeding !== "" && !urlHit;
    selectProceeding(initialBtn, { writeURL });
  }
});
