import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { escapeHtml, cssEscape } from "../lib/docforge-editor/dom";
import { fetchVariableCatalogue, labelMap } from "../lib/docforge-editor/catalogue";

// t-paliad-238 Slice A — client bundle for the dedicated
// Submissions/Schriftsätze editor at
//   /projects/{id}/submissions/{code}/draft
//   /projects/{id}/submissions/{code}/draft/{draft_id}
//
// Reads (project_id, submission_code, optional draft_id) from the URL,
// loads the editor payload (draft row + resolved bag + merged bag +
// HTML preview), and wires the sidebar / preview / autosave / export.
//
// Autosave is debounced 500ms after the lawyer stops typing in any
// variable input. Each PATCH returns a fresh editor payload, so the
// preview pane stays in lockstep with the variable overrides.

interface SubmissionDraftJSON {
  id: string;
  project_id: string | null;
  submission_code: string;
  user_id: string;
  name: string;
  // t-paliad-276 — per-draft output language ("de" or "en"). Drives the
  // template-variant lookup and language-aware variable resolution.
  language: string;
  variables: Record<string, string>;
  selected_parties: string[];
  last_exported_at?: string | null;
  last_exported_sha?: string | null;
  last_imported_at?: string | null;
  // t-paliad-313 Composer Slice A — base reference + Composer-side
  // metadata. base_id is null on pre-Composer drafts (the v1 render
  // path stays the fallback). composer_meta carries the seed-time
  // section order in later slices.
  base_id?: string | null;
  // t-paliad-349 slice 7 — pinned uploaded docforge template version.
  // Mutually exclusive with base_id in practice (export checks this first).
  template_version_id?: string | null;
  composer_meta?: Record<string, unknown>;
  created_at: string;
  updated_at: string;
}

// t-paliad-313 Composer Slice A — per-draft section row, surfaced
// read-only in the editor body. Slice B adds inline edit + PATCH.
interface SubmissionSectionJSON {
  id: string;
  section_key: string;
  order_index: number;
  kind: string;
  label_de: string;
  label_en: string;
  included: boolean;
  content_md_de: string;
  content_md_en: string;
}

// t-paliad-313 Composer Slice A — base catalog row, surfaced in the
// sidebar picker dropdown.
interface SubmissionBaseRow {
  id: string;
  slug: string;
  firm?: string | null;
  proceeding_family?: string | null;
  label_de: string;
  label_en: string;
  description_de?: string | null;
  description_en?: string | null;
  gitea_path: string;
  is_default_for: string[];
  is_active: boolean;
  section_count: number;
}

// t-paliad-349 slice 7 — an uploaded docforge template offered in the
// picker for generation. version_id is what a draft pins.
interface PickerTemplate {
  id: string;
  name_de: string;
  name_en: string;
  firm?: string | null;
  version: number;
  version_id?: string;
}

interface AvailablePartyJSON {
  id: string;
  name: string;
  role?: string;
  representative?: string;
}

interface SubmissionRuleSummary {
  name: string;
  name_en: string;
  submission_code: string;
  primary_party?: string;
  event_type?: string;
  legal_source?: string;
  legal_source_pretty?: string;
}

interface SubmissionDraftView {
  draft: SubmissionDraftJSON;
  rule?: SubmissionRuleSummary;
  resolved_bag: Record<string, string>;
  merged_bag: Record<string, string>;
  preview_html: string;
  lang: string;
  has_template: boolean;
  template_missing?: boolean;
  available_parties: AvailablePartyJSON[];
  // t-paliad-276 — template-tier metadata used to surface the
  // "Fallback: universelles Skelett" notice when the requested draft
  // language has no per-firm language-matched template.
  template_tier?: string;
  language_fallback?: boolean;
  // t-paliad-313 Composer Slice A — per-draft section stack. Empty
  // for pre-Composer drafts where no rows have been seeded.
  sections: SubmissionSectionJSON[];
}

interface SubmissionDraftListResponse {
  project_id: string;
  submission_code: string;
  drafts: SubmissionDraftJSON[];
}

interface ParsedPath {
  // Project-scoped path: /projects/{id}/submissions/{code}/draft[/{draft_id}].
  // Global path: /submissions/draft/{draft_id} — projectID + submissionCode are derived
  // from the loaded draft row after fetch.
  projectID: string | null;
  submissionCode: string | null;
  draftID?: string;
  // mode tracks the URL shape we were entered from. Affects redirect
  // semantics when we create a new draft or navigate away.
  mode: "project" | "global";
}

const PROJECT_PATH_RE = /^\/projects\/([0-9a-fA-F-]{36})\/submissions\/([^/]+)\/draft(?:\/([0-9a-fA-F-]{36}))?\/?$/;
const GLOBAL_PATH_RE = /^\/submissions\/draft\/([0-9a-fA-F-]{36})\/?$/;

function parsePath(): ParsedPath | null {
  let m = PROJECT_PATH_RE.exec(window.location.pathname);
  if (m) {
    return {
      projectID: m[1],
      submissionCode: decodeURIComponent(m[2]),
      draftID: m[3],
      mode: "project",
    };
  }
  m = GLOBAL_PATH_RE.exec(window.location.pathname);
  if (m) {
    return {
      projectID: null,
      submissionCode: null,
      draftID: m[1],
      mode: "global",
    };
  }
  return null;
}

function isEN(): boolean {
  return document.documentElement.lang === "en";
}

// escapeHtml + cssEscape now come from ../lib/docforge-editor/dom (the
// shared editor utilities); the local copies were removed in slice 5.

// ─────────────────────────────────────────────────────────────────────
// Variable contract — DE/EN labels per dotted-path placeholder.
// Labels come from the Go-side catalogue (GET /api/docforge/variables),
// fetched once on boot into state.varLabels. The frontend keeps only the
// presentation grouping (VARIABLE_GROUPS) — which keys to show and how to
// section them — not the label data itself, so labels can't drift from the
// resolvers that produce the values (t-paliad-349 slice 5).
// ─────────────────────────────────────────────────────────────────────

interface VariableLabel {
  de: string;
  en: string;
}

interface VariableGroup {
  id: string;
  label: VariableLabel;
  keys: string[];
  // t-paliad-287 — render with a click-to-toggle disclosure caret; the
  // initial state is collapsed iff collapsedByDefault. Used for the
  // Frist section which lawyers rarely need to override (the variables
  // stay resolvable in the bag for the few templates that still want
  // them, but render no body content by default).
  collapsible?: boolean;
  collapsedByDefault?: boolean;
}


// t-paliad-287 — variable groups restructured into four lawyer-facing
// sections: Mandant/Verfahren up top (the case identity), then Parteien
// (where the picker UI lives — this group only carries the manual
// {{parties.*}} overrides for power-users), then Frist collapsed by
// default (the deadline.* keys still resolve in the bag but the default
// templates don't render them in the body any more), then Sonstiges for
// the firm/date/user trim. The legacy procedural_event/rule namespaces
// fold into Mandant/Verfahren so the lawyer reads them in their natural
// context.
const VARIABLE_GROUPS: VariableGroup[] = [
  {
    id: "mandant_verfahren",
    label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
    keys: [
      "project.title",
      "project.case_number",
      "project.court",
      "project.patent_number",
      "project.patent_number_upc",
      "project.filing_date",
      "project.grant_date",
      "project.our_side",
      "project.proceeding.name",
      "project.client_number",
      "project.matter_number",
      "project.reference",
      "project.instance_level",
      "procedural_event.name",
      "procedural_event.legal_source_pretty",
      "procedural_event.primary_party",
      "procedural_event.event_kind",
      "procedural_event.code",
    ],
  },
  {
    id: "parties",
    label: { de: "Parteien (Variablen)", en: "Parties (variables)" },
    // Manual overrides for {{parties.<role>.*}} placeholders — power-
    // user escape hatch when the lawyer wants the rendered string to
    // differ from the picker selection (e.g. honourific prefix on
    // representative). Collapsed by default because the picker above
    // is the canonical surface; these rows exist only as a safety
    // valve.
    collapsible: true,
    collapsedByDefault: true,
    keys: [
      "parties.claimant.name",
      "parties.claimant.representative",
      "parties.defendant.name",
      "parties.defendant.representative",
      "parties.other.name",
      "parties.other.representative",
    ],
  },
  {
    id: "deadline",
    label: { de: "Frist (intern)", en: "Deadline (internal)" },
    // t-paliad-287 — the {{deadline.*}} placeholders no longer render
    // in the default skeleton body (internal context that doesn't
    // belong in a court-bound submission). The values still resolve
    // here so a custom template can pick them up if needed; collapsed
    // because most drafts never touch them.
    collapsible: true,
    collapsedByDefault: true,
    keys: [
      "deadline.due_date",
      "deadline.due_date_long_de",
      "deadline.due_date_long_en",
      "deadline.computed_from",
      "deadline.title",
      "deadline.original_due_date",
    ],
  },
  {
    id: "sonstiges",
    label: { de: "Sonstiges", en: "Other" },
    keys: [
      "firm.name",
      "firm.signature_block",
      "user.display_name",
      "user.email",
      "user.office",
      "today.long_de",
      "today.long_en",
    ],
  },
];

function labelFor(key: string): string {
  const entry = state.varLabels[key];
  if (!entry) return key;
  return isEN() ? entry.en : entry.de;
}

// ─────────────────────────────────────────────────────────────────────
// Module state
// ─────────────────────────────────────────────────────────────────────

interface State {
  parsed: ParsedPath;
  view: SubmissionDraftView | null;
  drafts: SubmissionDraftJSON[];
  saveTimer: number | null;
  pendingOverrides: Record<string, string> | null;
  inFlight: AbortController | null;
  // t-paliad-287 — per-section collapse memory. Sticky across repaints
  // so autosave (which calls paintVariables) doesn't snap an open
  // section shut. Seeded lazily from VARIABLE_GROUPS.collapsedByDefault.
  collapsedGroups: Record<string, boolean>;
  // t-paliad-287 — which side the Add-Party panel is currently open for
  // (one panel can be open at a time; clicking the other side's button
  // toggles). null means closed.
  addPartyOpen: PartySide | null;
  addPartyMode: "manual" | "search";
  addPartySearchHits: PartySearchHit[];
  addPartyBusy: boolean;
  // t-paliad-313 Composer Slice A — base catalog fetched once on boot.
  // Picker hidden until populated; empty array (after the fetch
  // completes) keeps the picker hidden permanently for this load.
  bases: SubmissionBaseRow[];
  basesLoaded: boolean;
  // t-paliad-349 slice 7 — uploaded templates offered in the picker.
  templates: PickerTemplate[];
  templatesLoaded: boolean;
  // t-paliad-349 slice 5 — variable labels fetched once on boot from the
  // Go catalogue (GET /api/docforge/variables), the single source of
  // truth. Empty until the fetch lands; labelFor falls back to the raw
  // key, so a failed fetch degrades gracefully rather than breaking the
  // form.
  varLabels: Record<string, VariableLabel>;
}

type PartySide = "claimant" | "defendant" | "other";

interface PartySearchHit {
  id: string;
  project_id: string;
  project_title: string;
  project_reference?: string | null;
  name: string;
  role?: string;
  representative?: string;
}

const state: State = {
  parsed: null as unknown as ParsedPath,
  view: null,
  drafts: [],
  saveTimer: null,
  pendingOverrides: null,
  inFlight: null,
  collapsedGroups: {},
  addPartyOpen: null,
  addPartyMode: "manual",
  addPartySearchHits: [],
  addPartyBusy: false,
  bases: [],
  basesLoaded: false,
  templates: [],
  templatesLoaded: false,
  varLabels: {},
};

// ─────────────────────────────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────────────────────────────

async function boot(): Promise<void> {
  initI18n();
  initSidebar();

  const parsed = parsePath();
  if (!parsed) {
    showNotFound();
    return;
  }
  state.parsed = parsed;

  // t-paliad-313 Composer Slice A — kick the base catalog fetch in
  // parallel with the view load. The picker hydrates when both land;
  // either failing leaves the picker hidden but the editor functional.
  loadBases().catch(err => {
    console.warn("submission-draft: base catalog fetch failed", err);
    state.basesLoaded = true;
  });
  // t-paliad-349 slice 7 — uploaded-template catalog for the picker.
  loadTemplates().catch(err => {
    console.warn("submission-draft: template catalog fetch failed", err);
    state.templatesLoaded = true;
  });

  // t-paliad-349 slice 5 — load the variable-label catalogue (Go SSOT)
  // before the first paint so the sidebar form labels render. Awaited
  // because labelFor needs it at paint time; a failure leaves varLabels
  // empty and labelFor falls back to the raw key (degraded but usable).
  try {
    state.varLabels = labelMap(await fetchVariableCatalogue());
  } catch (err) {
    console.warn("submission-draft: variable catalogue fetch failed", err);
  }

  try {
    if (parsed.mode === "global") {
      // Global path: we have a draft_id, fetch by id alone. Drafts
      // list (the sidebar switcher) is scoped to the same project +
      // submission_code AFTER we've loaded the draft.
      if (!parsed.draftID) {
        showNotFound();
        return;
      }
      const view = await fetchGlobalView(parsed.draftID);
      state.view = view;
      // Backfill parsed.* from the loaded draft so the sidebar
      // switcher can list peers; project-less drafts get no peer list
      // beyond themselves (no useful (project, code) cross-section).
      state.parsed = {
        ...parsed,
        projectID: view.draft.project_id,
        submissionCode: view.draft.submission_code,
      };
      if (view.draft.project_id) {
        try {
          const list = await fetchDrafts(state.parsed);
          state.drafts = list.drafts;
        } catch { state.drafts = [view.draft]; }
      } else {
        state.drafts = [view.draft];
      }
      paint();
      return;
    }

    // Project-scoped path: same logic as before.
    if (!parsed.projectID || !parsed.submissionCode) {
      showNotFound();
      return;
    }
    const list = await fetchDrafts(parsed);
    state.drafts = list.drafts;
    let draft: SubmissionDraftJSON | null = null;
    if (parsed.draftID) {
      draft = state.drafts.find((d) => d.id === parsed.draftID) ?? null;
      if (!draft) {
        showNotFound();
        return;
      }
    } else if (state.drafts.length > 0) {
      draft = state.drafts[0];
      // Redirect to the canonical /draft/{id} URL so refresh + share
      // both land on the same draft.
      const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
      window.history.replaceState({}, "", url);
      state.parsed = { ...parsed, draftID: draft.id };
    } else {
      draft = await createProjectDraft(parsed);
      state.drafts = [draft];
      const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
      window.history.replaceState({}, "", url);
      state.parsed = { ...parsed, draftID: draft.id };
    }
    const view = await fetchView(state.parsed.projectID!, state.parsed.submissionCode!, draft.id);
    state.view = view;
    paint();
  } catch (err) {
    console.error("submission-draft boot:", err);
    showError(isEN() ? "Failed to load draft." : "Entwurf konnte nicht geladen werden.");
  }
}

// ─────────────────────────────────────────────────────────────────────
// API
// ─────────────────────────────────────────────────────────────────────

async function fetchDrafts(p: ParsedPath): Promise<SubmissionDraftListResponse> {
  if (!p.projectID || !p.submissionCode) throw new Error("no project context");
  const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
  const resp = await fetch(url);
  if (!resp.ok) throw new Error(`drafts list ${resp.status}`);
  return resp.json();
}

async function createProjectDraft(p: ParsedPath): Promise<SubmissionDraftJSON> {
  if (!p.projectID || !p.submissionCode) throw new Error("no project context");
  const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
  const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" } });
  if (!resp.ok) throw new Error(`create draft ${resp.status}`);
  const view = (await resp.json()) as SubmissionDraftView;
  return view.draft;
}

async function fetchView(projectID: string, code: string, draftID: string): Promise<SubmissionDraftView> {
  const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/drafts/${draftID}`;
  const resp = await fetch(url);
  if (!resp.ok) throw new Error(`get draft ${resp.status}`);
  return resp.json();
}

async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
  const resp = await fetch(`/api/submission-drafts/${draftID}`);
  if (!resp.ok) throw new Error(`get draft ${resp.status}`);
  return resp.json();
}

async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string; filename_keyword?: string }): Promise<SubmissionDraftView> {
  const p = state.parsed;
  if (!p.draftID) throw new Error("no draft id");
  if (state.inFlight) {
    state.inFlight.abort();
    state.inFlight = null;
  }
  const ctl = new AbortController();
  state.inFlight = ctl;
  // The global PATCH endpoint accepts both project-scoped and
  // project-less drafts — route everything through it so attach (set
  // project_id) works from both URL shapes.
  const url = `/api/submission-drafts/${p.draftID}`;
  try {
    const resp = await fetch(url, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
      signal: ctl.signal,
    });
    if (!resp.ok) throw new Error(`patch draft ${resp.status}`);
    return resp.json();
  } finally {
    if (state.inFlight === ctl) state.inFlight = null;
  }
}

async function deleteDraft(): Promise<void> {
  const p = state.parsed;
  if (!p.draftID) return;
  const resp = await fetch(`/api/submission-drafts/${p.draftID}`, { method: "DELETE" });
  if (!resp.ok && resp.status !== 204) throw new Error(`delete draft ${resp.status}`);
}

// ─────────────────────────────────────────────────────────────────────
// Render
// ─────────────────────────────────────────────────────────────────────

function paint(): void {
  if (!state.view) return;
  hide("submission-draft-loading");
  hide("submission-draft-notfound");
  hide("submission-draft-error");
  show("submission-draft-body");

  paintHeader();
  paintBackLink();
  paintNoProjectBanner();
  paintSwitcher();
  paintNameRow();
  paintBasePicker();
  paintImportRow();
  paintPartyPicker();
  paintLanguageRow();
  paintLanguageFallback();
  paintKeywordRow();
  paintVariables();
  paintSectionList();
  paintPreview();
}

function paintHeader(): void {
  const view = state.view!;
  const title = document.getElementById("submission-draft-title");
  if (title) {
    const ruleName = view.rule?.name ?? view.draft.submission_code;
    title.textContent = ruleName;
  }
  const subtitle = document.getElementById("submission-draft-subtitle");
  if (subtitle) {
    const code = view.draft.submission_code;
    const source = view.rule?.legal_source_pretty ?? view.rule?.legal_source ?? "";
    const parts: string[] = [code];
    if (source) parts.push(source);
    subtitle.textContent = parts.join(" · ");
  }
}

function paintBackLink(): void {
  const back = document.getElementById("submission-draft-back-link") as HTMLAnchorElement | null;
  if (!back || !state.view) return;
  if (state.view.draft.project_id) {
    back.href = `/projects/${state.view.draft.project_id}/submissions`;
    back.textContent = isEN() ? "← Back to project" : "← Zurück zum Projekt";
  } else {
    back.href = "/submissions";
    back.textContent = isEN() ? "← Back to drafts" : "← Zurück zur Übersicht";
  }
}

// paintNoProjectBanner adds (or removes) the "Kein Projekt zugeordnet"
// banner above the editor body. The banner offers a "Projekt zuweisen"
// button that opens an inline project picker — same modal pattern the
// /submissions/new page uses. Removed once the draft has a project_id.
function paintNoProjectBanner(): void {
  const body = document.getElementById("submission-draft-body");
  if (!body || !state.view) return;
  let banner = document.getElementById("submission-draft-noproject-banner");

  if (state.view.draft.project_id) {
    if (banner) banner.remove();
    return;
  }

  const msg = isEN()
    ? "No project assigned — all variables are filled manually."
    : "Kein Projekt zugeordnet — alle Variablen werden manuell befüllt.";
  const cta = isEN() ? "Assign project…" : "Projekt zuweisen…";
  const html = `<p class="submission-draft-noproject-banner-msg">${escapeHtml(msg)}</p>
                <button type="button" id="submission-draft-noproject-assign"
                        class="btn-secondary btn-small">${escapeHtml(cta)}</button>`;

  if (banner) {
    banner.innerHTML = html;
  } else {
    banner = document.createElement("aside");
    banner.id = "submission-draft-noproject-banner";
    banner.className = "submission-draft-noproject-banner";
    banner.innerHTML = html;
    // Insert before the header.
    const header = body.querySelector(".submission-draft-header");
    if (header && header.parentElement) {
      header.parentElement.insertBefore(banner, header);
    } else {
      body.prepend(banner);
    }
  }

  const btn = document.getElementById("submission-draft-noproject-assign") as HTMLButtonElement | null;
  if (btn) btn.onclick = () => openProjectAssignPicker();
}

function paintSwitcher(): void {
  const sel = document.getElementById("submission-draft-pick") as HTMLSelectElement | null;
  if (!sel || !state.view) return;
  sel.innerHTML = state.drafts
    .map((d) => `<option value="${escapeHtml(d.id)}"${d.id === state.view!.draft.id ? " selected" : ""}>${escapeHtml(d.name)}</option>`)
    .join("");
  sel.onchange = () => {
    const id = sel.value;
    if (!id || !state.view) return;
    if (id === state.view.draft.id) return;
    const p = state.parsed;
    const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${id}`;
    window.location.href = url;
  };
}

function paintNameRow(): void {
  const input = document.getElementById("submission-draft-name") as HTMLInputElement | null;
  const del = document.getElementById("submission-draft-delete-btn") as HTMLButtonElement | null;
  if (input && state.view) {
    input.value = state.view.draft.name;
    input.onchange = () => {
      const newName = input.value.trim();
      if (!newName || newName === state.view!.draft.name) return;
      void renameDraft(newName);
    };
  }
  if (del) del.onclick = () => onDelete();

  const newBtn = document.getElementById("submission-draft-new-btn") as HTMLButtonElement | null;
  if (newBtn) newBtn.onclick = () => onCreateNew();

  const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
  if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
}

// t-paliad-277 — "Aus Projekt importieren" + last-imported-at stamp.
// Hidden when the draft has no project (no project state to import).
function paintImportRow(): void {
  const row = document.getElementById("submission-draft-import-row");
  const btn = document.getElementById("submission-draft-import-btn") as HTMLButtonElement | null;
  const stamp = document.getElementById("submission-draft-import-stamp");
  if (!row || !btn || !stamp || !state.view) return;

  if (!state.view.draft.project_id) {
    row.style.display = "none";
    return;
  }
  row.style.display = "";

  const last = state.view.draft.last_imported_at;
  if (last) {
    stamp.textContent = (isEN() ? "Last imported: " : "Zuletzt importiert: ") + formatStamp(last);
  } else {
    stamp.textContent = isEN() ? "Never imported" : "Noch nicht importiert";
  }
  btn.onclick = () => { void onImportFromProject(btn); };
}

// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
// Party affordance per side. Lists every party on the draft's project
// (view.available_parties), grouped by role, with one checkbox per
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
// an "+ Partei hinzufügen" button that opens an inline panel with two
// modes: manual entry (creates a fresh paliad.parties row) or DB
// picker (searches every visible project, clones the row into THIS
// project on selection). Empty selection still falls back to the
// legacy "include every party" default.
function paintPartyPicker(): void {
  const block = document.getElementById("submission-draft-parties");
  const list = document.getElementById("submission-draft-parties-list");
  if (!block || !list || !state.view) return;

  // t-paliad-287 — picker is now shown even on empty-roster projects so
  // the lawyer can use Add Party to populate. Still hidden when there
  // is no project attached (no row to attach a party to).
  if (!state.view.draft.project_id) {
    block.style.display = "none";
    list.innerHTML = "";
    return;
  }
  block.style.display = "";

  const parties = state.view.available_parties ?? [];
  const selected = new Set(state.view.draft.selected_parties ?? []);
  // Empty selection is the implicit "all" default — pre-check every
  // party so the lawyer can see what's currently being mentioned and
  // then deselect what they want to drop. This matches the issue's
  // "default = all parties on the project, lawyer can deselect" line.
  const effective = selected.size === 0
    ? new Set(parties.map((p) => p.id))
    : selected;

  const grouped = groupPartiesByRole(parties);
  let html = "";
  for (const group of grouped) {
    html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
    html += `<legend>${escapeHtml(group.label)}</legend>`;
    if (group.parties.length === 0) {
      html += `<p class="submission-draft-parties-empty">${escapeHtml(
        isEN() ? "No parties yet." : "Noch keine Parteien.",
      )}</p>`;
    }
    for (const p of group.parties) {
      const checked = effective.has(p.id) ? " checked" : "";
      const chip = p.role
        ? `<span class="submission-draft-party-chip">${escapeHtml(p.role)}</span>`
        : "";
      const rep = p.representative
        ? `<span class="submission-draft-party-rep">${escapeHtml(
            (isEN() ? "Repr.: " : "Vertr.: ") + p.representative,
          )}</span>`
        : "";
      html += `<label class="submission-draft-party-row">`;
      html += `<input type="checkbox" class="submission-draft-party-check"`;
      html += ` data-party-id="${escapeHtml(p.id)}"${checked} />`;
      html += `<span class="submission-draft-party-name">${escapeHtml(p.name)}</span>`;
      html += chip;
      html += rep;
      html += `</label>`;
    }
    html += renderAddPartyControls(group.bucket);
    html += `</fieldset>`;
  }
  list.innerHTML = html;

  list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
    inp.addEventListener("change", () => onPartySelectionChange());
  });
  wireAddPartyControls(list);
}

// renderAddPartyControls emits the per-side "+ Add party" button and
// (when expanded) the inline panel offering manual entry OR DB search.
// Sticky panel state lives in state.addPartyOpen so a repaint after
// search-fetch / autosave / language-switch doesn't snap the panel
// shut mid-edit.
function renderAddPartyControls(side: PartySide): string {
  const open = state.addPartyOpen === side;
  const mode = state.addPartyMode;
  const sideLabel = sideLabelFor(side);
  const btnLabel = isEN()
    ? `+ Add party (${sideLabel})`
    : `+ Partei hinzufügen (${sideLabel})`;

  let html = `<div class="submission-draft-addparty">`;
  html += `<button type="button" class="btn-small btn-secondary submission-draft-addparty-toggle"`;
  html += ` data-side="${side}" aria-expanded="${open ? "true" : "false"}">`;
  html += escapeHtml(btnLabel);
  html += `</button>`;

  if (!open) {
    html += `</div>`;
    return html;
  }

  // Tabs — manual / search.
  html += `<div class="submission-draft-addparty-panel">`;
  html += `<div class="submission-draft-addparty-tabs" role="tablist">`;
  html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
  if (mode === "manual") html += ` submission-draft-addparty-tab--active`;
  html += `" data-tab="manual" data-side="${side}" aria-selected="${mode === "manual"}">`;
  html += escapeHtml(isEN() ? "Manual entry" : "Manuell");
  html += `</button>`;
  html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
  if (mode === "search") html += ` submission-draft-addparty-tab--active`;
  html += `" data-tab="search" data-side="${side}" aria-selected="${mode === "search"}">`;
  html += escapeHtml(isEN() ? "From DB" : "Aus DB übernehmen");
  html += `</button>`;
  html += `</div>`;

  if (mode === "manual") {
    html += renderAddPartyManualForm(side);
  } else {
    html += renderAddPartySearchPanel(side);
  }

  html += `</div></div>`;
  return html;
}

function renderAddPartyManualForm(side: PartySide): string {
  const defaultRole = defaultRoleFor(side);
  const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : "";
  let html = `<form class="submission-draft-addparty-form${busyCls}" data-side="${side}" data-mode="manual">`;
  html += `<label class="submission-draft-addparty-field">`;
  html += `<span>${escapeHtml(isEN() ? "Name" : "Name")}</span>`;
  html += `<input type="text" name="name" required class="entity-form-input"`;
  html += ` placeholder="${escapeHtml(isEN() ? "Acme Inc." : "z. B. Acme GmbH")}" />`;
  html += `</label>`;
  html += `<label class="submission-draft-addparty-field">`;
  html += `<span>${escapeHtml(isEN() ? "Role" : "Rolle")}</span>`;
  html += `<input type="text" name="role" class="entity-form-input"`;
  html += ` value="${escapeHtml(defaultRole)}"`;
  html += ` placeholder="${escapeHtml(isEN() ? "claimant / defendant / intervenor / …" : "Klägerin / Beklagte / Streithelferin / …")}" />`;
  html += `</label>`;
  html += `<label class="submission-draft-addparty-field">`;
  html += `<span>${escapeHtml(isEN() ? "Representative (optional)" : "Vertreter:in (optional)")}</span>`;
  html += `<input type="text" name="representative" class="entity-form-input"`;
  html += ` placeholder="${escapeHtml(isEN() ? "Dr. Müller, …" : "RA Dr. Müller, …")}" />`;
  html += `</label>`;
  html += `<div class="submission-draft-addparty-actions">`;
  html += `<button type="submit" class="btn-small btn-primary"${state.addPartyBusy ? " disabled" : ""}>`;
  html += escapeHtml(isEN() ? "Add party" : "Hinzufügen");
  html += `</button>`;
  html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
  html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
  html += `</button>`;
  html += `</div>`;
  html += `</form>`;
  return html;
}

function renderAddPartySearchPanel(side: PartySide): string {
  let html = `<div class="submission-draft-addparty-search" data-side="${side}" data-mode="search">`;
  html += `<input type="search" class="entity-form-input submission-draft-addparty-search-input"`;
  html += ` data-side="${side}"`;
  html += ` placeholder="${escapeHtml(
    isEN()
      ? "Search across projects (name or representative)…"
      : "In allen Projekten suchen (Name oder Vertreter)…",
  )}" />`;
  html += renderPartySearchResultsList();
  html += `<p class="submission-draft-addparty-search-hint">${escapeHtml(
    isEN()
      ? "Picking a row clones it as a fresh party on this project — no typing."
      : "Auswählen kopiert die Partei in dieses Projekt — kein erneutes Tippen.",
  )}</p>`;
  html += `<div class="submission-draft-addparty-actions">`;
  html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
  html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
  html += `</button>`;
  html += `</div>`;
  html += `</div>`;
  return html;
}

function wireAddPartyControls(root: HTMLElement): void {
  root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-toggle").forEach((btn) => {
    btn.addEventListener("click", () => {
      const side = (btn.dataset.side as PartySide) ?? "other";
      if (state.addPartyOpen === side) {
        // Toggle off.
        state.addPartyOpen = null;
        state.addPartySearchHits = [];
      } else {
        state.addPartyOpen = side;
        state.addPartyMode = "manual";
        state.addPartySearchHits = [];
      }
      paintPartyPicker();
    });
  });
  root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-tab").forEach((btn) => {
    btn.addEventListener("click", () => {
      const tab = btn.dataset.tab;
      if (tab !== "manual" && tab !== "search") return;
      state.addPartyMode = tab;
      if (tab === "manual") state.addPartySearchHits = [];
      paintPartyPicker();
      if (tab === "search") {
        // Pre-load most-recent matches with empty query so the lawyer
        // sees options without typing first.
        void runPartySearch("");
      }
    });
  });
  root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-cancel").forEach((btn) => {
    btn.addEventListener("click", () => {
      state.addPartyOpen = null;
      state.addPartySearchHits = [];
      paintPartyPicker();
    });
  });
  root.querySelectorAll<HTMLFormElement>(".submission-draft-addparty-form").forEach((form) => {
    form.addEventListener("submit", (ev) => {
      ev.preventDefault();
      const side = (form.dataset.side as PartySide) ?? "other";
      const data = new FormData(form);
      const name = String(data.get("name") ?? "").trim();
      if (!name) return;
      const role = String(data.get("role") ?? "").trim();
      const representative = String(data.get("representative") ?? "").trim();
      void onAddPartyManualSubmit(side, { name, role, representative });
    });
  });
  root.querySelectorAll<HTMLInputElement>(".submission-draft-addparty-search-input").forEach((inp) => {
    let timer: number | null = null;
    inp.addEventListener("input", () => {
      if (timer !== null) window.clearTimeout(timer);
      timer = window.setTimeout(() => {
        void runPartySearch(inp.value.trim());
      }, 200);
    });
    // Pre-load on first render of the search tab.
    if (state.addPartyMode === "search" && state.addPartySearchHits.length === 0) {
      void runPartySearch("");
    }
  });
  root.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
    li.addEventListener("click", () => {
      const hitID = li.dataset.hitId;
      if (!hitID) return;
      const hit = state.addPartySearchHits.find((h) => h.id === hitID);
      if (!hit) return;
      const side = state.addPartyOpen ?? "other";
      void onAddPartySearchPick(side, hit);
    });
  });
}

function sideLabelFor(side: PartySide): string {
  if (side === "claimant") return isEN() ? "Claimant side" : "Klägerseite";
  if (side === "defendant") return isEN() ? "Defendant side" : "Beklagtenseite";
  return isEN() ? "Other parties" : "Weitere Parteien";
}

function defaultRoleFor(side: PartySide): string {
  if (side === "claimant") return isEN() ? "claimant" : "Klägerin";
  if (side === "defendant") return isEN() ? "defendant" : "Beklagte";
  return "";
}

interface PartyRoleGroup {
  bucket: "claimant" | "defendant" | "other";
  label: string;
  parties: AvailablePartyJSON[];
}

function groupPartiesByRole(parties: AvailablePartyJSON[]): PartyRoleGroup[] {
  const claimants: AvailablePartyJSON[] = [];
  const defendants: AvailablePartyJSON[] = [];
  const others: AvailablePartyJSON[] = [];
  for (const p of parties) {
    const role = (p.role ?? "").trim().toLowerCase();
    if (role === "claimant" || role === "kläger" || role === "klaeger"
        || role === "klägerin" || role === "klaegerin") {
      claimants.push(p);
    } else if (role === "defendant" || role === "beklagter" || role === "beklagte") {
      defendants.push(p);
    } else {
      others.push(p);
    }
  }
  return [
    {
      bucket: "claimant",
      label: isEN() ? "Claimants" : "Klägerinnen",
      parties: claimants,
    },
    {
      bucket: "defendant",
      label: isEN() ? "Defendants" : "Beklagte",
      parties: defendants,
    },
    {
      bucket: "other",
      label: isEN() ? "Other parties" : "Weitere Parteien",
      parties: others,
    },
  ];
}

function formatStamp(iso: string): string {
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return iso;
  return d.toLocaleString(isEN() ? "en-GB" : "de-DE");
}

// paintLanguageRow syncs the DE/EN radio with the loaded draft's
// language. Switching the radio fires onLanguageChange which PATCHes
// the draft and lets the server return the freshly-resolved bag +
// preview HTML (so the lawyer sees the EN form names appear without a
// manual reload). t-paliad-276.
function paintLanguageRow(): void {
  if (!state.view) return;
  const lang = (state.view.draft.language || "de").toLowerCase();
  const de = document.getElementById("submission-draft-language-de") as HTMLInputElement | null;
  const en = document.getElementById("submission-draft-language-en") as HTMLInputElement | null;
  if (de) {
    de.checked = lang === "de";
    de.onchange = () => { void onLanguageChange("de"); };
  }
  if (en) {
    en.checked = lang === "en";
    en.onchange = () => { void onLanguageChange("en"); };
  }
}

// paintLanguageFallback shows / hides the "no language-matched
// template" notice. The server sets language_fallback=true when the
// resolved template tier doesn't match the draft's language
// (e.g. EN draft → DE per-code template, or no skeleton EN sibling).
function paintLanguageFallback(): void {
  const el = document.getElementById("submission-draft-language-fallback");
  if (!el) return;
  const fallback = !!state.view?.language_fallback;
  el.style.display = fallback ? "" : "none";
}

// autoKeyword returns the lang-aware rule name that leads the exported
// filename when the user sets no override — shown as the keyword input's
// placeholder so the lawyer sees the default without it being forced.
// t-paliad-354.
function autoKeyword(): string {
  const view = state.view;
  if (!view?.rule) return "";
  const en = (view.draft.language || view.lang || "de").toLowerCase() === "en";
  const name = en && view.rule.name_en ? view.rule.name_en : view.rule.name;
  return (name || "").trim();
}

// paintKeywordRow syncs the "Stichwort (Dateiname)" input with the
// draft's stored override (composer_meta.filename_keyword) and shows the
// auto-derived rule name as the placeholder. Editing PATCHes the draft on
// blur (change), persisting under composer_meta.filename_keyword.
// t-paliad-354.
function paintKeywordRow(): void {
  const input = document.getElementById("submission-draft-keyword") as HTMLInputElement | null;
  if (!input || !state.view) return;
  const stored = state.view.draft.composer_meta?.["filename_keyword"];
  input.value = typeof stored === "string" ? stored : "";
  const auto = autoKeyword();
  if (auto) input.placeholder = auto;
  input.onchange = () => { void onKeywordChange(input.value.trim()); };
}

async function onKeywordChange(keyword: string): Promise<void> {
  if (!state.view) return;
  const stored = state.view.draft.composer_meta?.["filename_keyword"];
  const current = typeof stored === "string" ? stored.trim() : "";
  if (keyword === current) return;
  setSaveStatus(isEN() ? "Saving…" : "Speichert…");
  try {
    const view = await patchDraft({ filename_keyword: keyword });
    state.view = view;
    paintKeywordRow();
    setSaveStatus(isEN() ? "Saved" : "Gespeichert");
  } catch (err) {
    if ((err as Error).name === "AbortError") return;
    console.error("submission-draft keyword save:", err);
    setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
    // Revert to the persisted value so the field doesn't lie.
    paintKeywordRow();
  }
}

async function onLanguageChange(lang: "de" | "en"): Promise<void> {
  if (!state.view) return;
  if ((state.view.draft.language || "de").toLowerCase() === lang) return;
  setSaveStatus(isEN() ? "Saving…" : "Speichert…");
  try {
    const view = await patchDraft({ language: lang });
    state.view = view;
    // Repaint everything that depends on language: the DE/EN form
    // values in the resolved bag, the localized rule name in the
    // header, and the fallback notice.
    paintHeader();
    paintLanguageRow();
    paintLanguageFallback();
    paintVariables();
    paintPreview();
    setSaveStatus(isEN() ? "Saved" : "Gespeichert");
  } catch (err) {
    if ((err as Error).name === "AbortError") return;
    console.error("submission-draft language switch:", err);
    setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
    // Revert the radio to the persisted value so the UI doesn't lie
    // about which language is active.
    paintLanguageRow();
  }
}


function paintVariables(): void {
  const host = document.getElementById("submission-draft-variables");
  if (!host || !state.view) return;
  const overrides = state.view.draft.variables ?? {};
  const resolved = state.view.resolved_bag ?? {};
  const merged = state.view.merged_bag ?? {};

  let html = "";
  for (const group of VARIABLE_GROUPS) {
    const groupLabel = isEN() ? group.label.en : group.label.de;
    // Re-use the user's prior toggle state across paintVariables calls
    // (autosave / language switch trigger a repaint). Default sticky
    // state lives in state.collapsedGroups; on first render the
    // collapsedByDefault flag seeds it.
    if (!Object.prototype.hasOwnProperty.call(state.collapsedGroups, group.id)) {
      state.collapsedGroups[group.id] = !!(group.collapsible && group.collapsedByDefault);
    }
    const collapsed = !!state.collapsedGroups[group.id];
    const collapsibleCls = group.collapsible ? " submission-draft-var-group--collapsible" : "";
    const collapsedCls = collapsed ? " submission-draft-var-group--collapsed" : "";
    html += `<section class="submission-draft-var-group${collapsibleCls}${collapsedCls}" data-group="${group.id}">`;
    if (group.collapsible) {
      html += `<button type="button" class="submission-draft-var-group-toggle"`;
      html += ` data-toggle-group="${escapeHtml(group.id)}" aria-expanded="${collapsed ? "false" : "true"}">`;
      html += `<span class="submission-draft-var-group-caret" aria-hidden="true">▸</span>`;
      html += `<span class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</span>`;
      html += `</button>`;
    } else {
      html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
    }
    html += `<div class="submission-draft-var-group-body">`;
    for (const key of group.keys) {
      const label = labelFor(key);
      const override = overrides[key];
      const resolvedVal = resolved[key] ?? "";
      const mergedVal = merged[key] ?? "";
      const overridden = Object.prototype.hasOwnProperty.call(overrides, key);
      html += `<label class="submission-draft-var-row" data-key="${escapeHtml(key)}">`;
      html += `<span class="submission-draft-var-label">${escapeHtml(label)}</span>`;
      html += `<input type="text" class="submission-draft-var-input entity-form-input"`;
      html += ` data-var="${escapeHtml(key)}"`;
      html += ` value="${escapeHtml(overridden ? override : resolvedVal)}"`;
      html += ` data-resolved="${escapeHtml(resolvedVal)}" />`;
      const hintParts: string[] = [];
      hintParts.push(`<code>{{${escapeHtml(key)}}}</code>`);
      if (overridden) {
        if (override === "") {
          hintParts.push(`<span class="submission-draft-var-marker">${escapeHtml(isEN() ? "→ [NO VALUE: " + key + "]" : "→ [KEIN WERT: " + key + "]")}</span>`);
        } else if (override !== resolvedVal) {
          const original = resolvedVal === "" ? (isEN() ? "(empty)" : "(leer)") : resolvedVal;
          hintParts.push(`<span class="submission-draft-var-was">${escapeHtml((isEN() ? "Project: " : "Projekt: ") + original)}</span>`);
        }
      }
      html += `<span class="submission-draft-var-hint">${hintParts.join(" · ")}</span>`;
      if (overridden && override !== resolvedVal) {
        html += `<button type="button" class="submission-draft-var-reset btn-small btn-link" data-reset-key="${escapeHtml(key)}">${escapeHtml(isEN() ? "Reset" : "Zurücksetzen")}</button>`;
      }
      html += `</label>`;
      // Visual hint: marker text appears in preview when override is "".
      void mergedVal;
    }
    html += `</div>`;
    html += `</section>`;
  }
  host.innerHTML = html;

  host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-group-toggle").forEach((btn) => {
    btn.addEventListener("click", () => {
      const id = btn.dataset.toggleGroup;
      if (!id) return;
      state.collapsedGroups[id] = !state.collapsedGroups[id];
      paintVariables();
    });
  });
  host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
    inp.addEventListener("input", () => onVarChange(inp));
    // t-paliad-274 (B) — focus into a sidebar field highlights every
    // matching .draft-var span in the preview (sticky while focused,
    // clears on blur). Survives autosave repaints because paintVariables
    // is called by flushAutosave and we re-bind every render.
    inp.addEventListener("focusin", () => onVarFocusEnter(inp.dataset.var ?? ""));
    inp.addEventListener("focusout", () => onVarFocusLeave(inp.dataset.var ?? ""));
  });
  host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
    btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
  });
  // After repaint, re-apply the active highlight if a field is still
  // focused (paintVariables runs after autosave; the same input regains
  // focus via restoreVarFocus and would otherwise emit focusin too
  // late for our handler — re-apply explicitly).
  const active = document.activeElement;
  if (isVarField(active)) {
    const key = active.dataset.var;
    if (key) applyPreviewActiveHighlight(key);
  }
}

function paintPreview(): void {
  const host = document.getElementById("submission-draft-preview");
  if (!host || !state.view) return;
  host.innerHTML = state.view.preview_html ?? "";
  wireDraftVars(host);
  // t-paliad-274 (B) — preview HTML was just blown away by innerHTML,
  // so any prior --active classes are gone. Re-apply for whichever
  // sidebar field is currently focused (typing in a field triggers an
  // autosave round-trip that ends in paintPreview, and the user should
  // see the highlight stay put across that cycle).
  const active = document.activeElement;
  if (isVarField(active)) {
    const key = active.dataset.var;
    if (key) applyPreviewActiveHighlight(key);
  }
}

// ─────────────────────────────────────────────────────────────────────
// t-paliad-313 Composer Slice A — base picker + section list
// ─────────────────────────────────────────────────────────────────────

async function loadBases(): Promise<void> {
  const res = await fetch("/api/submission-bases", { credentials: "include" });
  if (!res.ok) {
    throw new Error("base list HTTP " + res.status);
  }
  const body = await res.json() as { bases?: SubmissionBaseRow[] };
  state.bases = body.bases ?? [];
  state.basesLoaded = true;
  // If the view has already painted, re-paint the picker so it
  // hydrates as soon as the catalog lands. paint() is idempotent.
  if (state.view) paintBasePicker();
}

// loadTemplates fetches the firm-shared uploaded-template catalog
// (t-paliad-349 slice 7). Failure leaves the list empty — the picker
// simply offers no uploaded templates, the editor stays usable.
async function loadTemplates(): Promise<void> {
  const res = await fetch("/api/templates", { credentials: "include" });
  if (!res.ok) {
    throw new Error("template list HTTP " + res.status);
  }
  const body = await res.json() as { templates?: PickerTemplate[] };
  state.templates = (body.templates ?? []).filter(t => !!t.version_id);
  state.templatesLoaded = true;
  if (state.view) paintBasePicker();
}

function paintBasePicker(): void {
  const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
  const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
  if (!row || !sel || !state.view) return;

  // Hide the picker only when BOTH catalogs are loaded-but-empty. As long
  // as bases OR uploaded templates exist, the picker is useful. A failed
  // fetch leaves the respective list empty; the editor stays usable.
  const hasBases = state.basesLoaded && state.bases.length > 0;
  const hasTemplates = state.templatesLoaded && state.templates.length > 0;
  if (!hasBases && !hasTemplates) {
    row.style.display = "none";
    return;
  }
  row.style.display = "";

  // Rebuild the <option> list each paint so language toggles + catalog
  // updates flow through.
  sel.innerHTML = "";
  const currentBaseID = state.view.draft.base_id ?? "";
  const currentTplVersion = state.view.draft.template_version_id ?? "";

  // "Keine Vorlagenbasis" only listed when the draft is currently in
  // that state (no base, no template). Avoids tempting the lawyer to
  // clear after they've already picked one.
  if (!currentBaseID && !currentTplVersion) {
    const opt = document.createElement("option");
    opt.value = "";
    opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
    sel.appendChild(opt);
  }
  for (const b of state.bases) {
    const opt = document.createElement("option");
    opt.value = b.id;
    opt.textContent = isEN() ? b.label_en : b.label_de;
    if (b.id === currentBaseID) opt.selected = true;
    sel.appendChild(opt);
  }
  // t-paliad-349 slice 7 — uploaded templates as a separate optgroup.
  // The value is "tpl:<version_id>" so onBaseChange can route it to the
  // template_version_id PATCH instead of base_id.
  if (hasTemplates) {
    const group = document.createElement("optgroup");
    group.label = isEN() ? "Uploaded templates" : "Hochgeladene Vorlagen";
    for (const tmpl of state.templates) {
      const opt = document.createElement("option");
      opt.value = "tpl:" + tmpl.version_id;
      opt.textContent = isEN() ? tmpl.name_en : tmpl.name_de;
      if (tmpl.version_id === currentTplVersion) opt.selected = true;
      group.appendChild(opt);
    }
    sel.appendChild(group);
  }

  // Wire change handler once per paint. Removing then re-adding
  // keeps the binding consistent across repaints (e.g. after
  // language toggle re-renders the labels).
  sel.onchange = () => { onBaseChange(sel.value); };
}

async function onBaseChange(newValue: string): Promise<void> {
  if (!state.view) return;
  // The picker mixes legacy bases (plain uuid) and uploaded templates
  // ("tpl:<version_id>"). Route to the matching field and clear the other
  // so the two render paths stay mutually exclusive. Empty = clear both.
  let payload: Record<string, unknown>;
  if (newValue.startsWith("tpl:")) {
    payload = { template_version_id: newValue.slice(4), base_id: null };
  } else {
    payload = { base_id: newValue === "" ? null : newValue, template_version_id: null };
  }
  try {
    const res = await fetch(
      `/api/submission-drafts/${state.view.draft.id}`,
      {
        method: "PATCH",
        credentials: "include",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      },
    );
    if (!res.ok) {
      console.warn("base swap PATCH failed", res.status);
      return;
    }
    const view = await res.json() as SubmissionDraftView;
    state.view = view;
    paint();
  } catch (err) {
    console.warn("base swap PATCH error", err);
  }
}

// sectionAutosaveTimers — one debounce timer per section id so two
// sections autosaving simultaneously don't trample each other. Reset
// on each keystroke; 500ms after the last keystroke the patch fires.
const sectionAutosaveTimers: Record<string, number> = {};
const SECTION_AUTOSAVE_MS = 500;

function paintSectionList(): void {
  const wrap = document.getElementById("submission-draft-sections-wrap");
  const list = document.getElementById("submission-draft-sections-list") as HTMLOListElement | null;
  if (!wrap || !list || !state.view) return;

  const sections = state.view.sections ?? [];
  if (sections.length === 0) {
    wrap.style.display = "none";
    return;
  }
  wrap.style.display = "";

  // Don't blow away the editor if a section is currently focused —
  // would steal cursor + selection mid-type. The patch round-trip
  // returns the updated row, but paintSectionList only re-renders
  // when the focused section isn't being edited (or the new render
  // is being driven by something other than the active editor itself).
  const activeID = activeSectionEditorID();

  list.innerHTML = "";
  const lang = state.view.draft.language || state.view.lang || "de";
  for (const sec of sections) {
    list.appendChild(renderSectionRow(sec, lang, activeID === sec.id));
  }

  // t-paliad-318 Slice F — "+ Abschnitt hinzufügen" trailing
  // affordance + "Reihenfolge speichern" affordance (only visible
  // after a manual reorder; surfaced by paintReorderControls when
  // pendingReorder is set).
  let trailer = document.getElementById("submission-draft-sections-trailer");
  if (!trailer) {
    trailer = document.createElement("div");
    trailer.id = "submission-draft-sections-trailer";
    trailer.className = "submission-draft-sections-trailer";
    wrap.appendChild(trailer);
  }
  trailer.innerHTML = "";

  const addBtn = document.createElement("button");
  addBtn.type = "button";
  addBtn.className = "btn-small btn-secondary";
  addBtn.textContent = isEN() ? "+ Add section" : "+ Abschnitt hinzufügen";
  addBtn.addEventListener("click", () => openAddSectionForm(trailer!));
  trailer.appendChild(addBtn);
}

function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
  const li = document.createElement("li");
  li.className = "submission-draft-section";
  li.dataset.sectionId = sec.id;
  if (!sec.included) li.classList.add("submission-draft-section--excluded");

  // t-paliad-318 Slice F — drag-and-drop reorder. Native HTML5 DnD,
  // no external library. The drag handle is the only draggable
  // affordance so clicks inside the editor area don't accidentally
  // trigger a drag.
  li.draggable = false; // overridden via the handle below
  li.addEventListener("dragover", (ev) => onSectionDragOver(ev, li));
  li.addEventListener("drop", (ev) => onSectionDrop(ev, li));
  li.addEventListener("dragleave", () => li.classList.remove("submission-draft-section--drop-target"));

  const head = document.createElement("header");
  head.className = "submission-draft-section-head";

  // Drag handle — making just this element draggable scoped the
  // gesture so contentEditable selections still work.
  const handle = document.createElement("span");
  handle.className = "submission-draft-section-handle";
  handle.draggable = true;
  handle.title = isEN() ? "Drag to reorder" : "Zum Sortieren ziehen";
  handle.textContent = "⋮⋮";
  handle.addEventListener("dragstart", (ev) => onSectionDragStart(ev, sec.id));
  handle.addEventListener("dragend", () => onSectionDragEnd(li));
  head.appendChild(handle);

  const title = document.createElement("h3");
  title.className = "submission-draft-section-title";
  title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key;
  head.appendChild(title);

  const kind = document.createElement("span");
  kind.className = "submission-draft-section-kind";
  kind.textContent = sec.kind;
  head.appendChild(kind);

  if (!sec.included) {
    const muted = document.createElement("span");
    muted.className = "submission-draft-section-excluded-badge";
    muted.textContent = isEN() ? "excluded" : "ausgeblendet";
    head.appendChild(muted);
  }

  // Per-section "Aufnehmen" / "Ausblenden" toggle in the head — flips
  // `included` via PATCH and re-paints.
  const toggle = document.createElement("button");
  toggle.type = "button";
  toggle.className = "btn-small btn-secondary submission-draft-section-toggle";
  toggle.textContent = sec.included
    ? (isEN() ? "Hide" : "Ausblenden")
    : (isEN() ? "Include" : "Aufnehmen");
  toggle.addEventListener("click", () => onSectionToggleIncluded(sec));
  head.appendChild(toggle);

  // t-paliad-318 Slice F — per-section delete. Removes the row.
  // Confirmation guard prevents accidental loss of typed prose.
  const del = document.createElement("button");
  del.type = "button";
  del.className = "btn-small btn-link-danger submission-draft-section-delete";
  del.textContent = isEN() ? "Delete" : "Entfernen";
  del.title = isEN() ? "Remove this section from the draft" : "Abschnitt aus dem Entwurf entfernen";
  del.addEventListener("click", () => onSectionDelete(sec));
  head.appendChild(del);

  li.appendChild(head);

  // Toolbar — Slice D rich-prose affordances: B/I + H1/H2/H3 +
  // bullet/numbered list + blockquote + hyperlink. Plus the Slice C
  // building-block button. execCommand drives bold/italic/headings/
  // lists/blockquote; hyperlink uses createLink with a prompt.
  const toolbar = document.createElement("div");
  toolbar.className = "submission-draft-section-toolbar";
  toolbar.appendChild(makeToolbarButton("B", isEN() ? "Bold" : "Fett", "bold"));
  toolbar.appendChild(makeToolbarButton("I", isEN() ? "Italic" : "Kursiv", "italic"));
  toolbar.appendChild(makeHeadingButton("H1", isEN() ? "Heading 1" : "Überschrift 1", 1));
  toolbar.appendChild(makeHeadingButton("H2", isEN() ? "Heading 2" : "Überschrift 2", 2));
  toolbar.appendChild(makeHeadingButton("H3", isEN() ? "Heading 3" : "Überschrift 3", 3));
  toolbar.appendChild(makeToolbarButton("•", isEN() ? "Bullet list" : "Aufzählung", "insertUnorderedList"));
  toolbar.appendChild(makeToolbarButton("1.", isEN() ? "Numbered list" : "Nummerierte Liste", "insertOrderedList"));
  toolbar.appendChild(makeToolbarButton("”", isEN() ? "Blockquote" : "Zitat", "formatBlock", "blockquote"));
  toolbar.appendChild(makeLinkButton());
  // t-paliad-315 Slice C — building-block insert button. Opens a
  // picker modal filtered to this section's section_key. Paste is
  // plain-text per Q2 (no lineage stamped).
  const bbBtn = document.createElement("button");
  bbBtn.type = "button";
  bbBtn.className = "btn-small btn-secondary submission-draft-section-bb-btn";
  bbBtn.textContent = isEN() ? "+ Block" : "+ Baustein";
  bbBtn.title = isEN() ? "Insert a saved building block" : "Baustein einfügen";
  bbBtn.addEventListener("click", () => openBlockPicker(sec));
  toolbar.appendChild(bbBtn);
  li.appendChild(toolbar);

  const md = (lang === "en" ? sec.content_md_en : sec.content_md_de) || "";
  const editor = document.createElement("div");
  editor.className = "submission-draft-section-editor";
  editor.contentEditable = "true";
  editor.spellcheck = true;
  editor.dataset.sectionId = sec.id;
  editor.dataset.lang = lang;
  editor.dataset.placeholder = isEN()
    ? "Write section content…"
    : "Abschnittstext eingeben…";
  // Paint the Markdown as plain text on first render — the editor's
  // source of truth is Markdown, the DOM is the view. Lawyer types,
  // we serialise back to MD on autosave.
  editor.textContent = md;

  editor.addEventListener("input", () => onSectionInput(editor));
  editor.addEventListener("focus", () => {
    li.classList.add("submission-draft-section--editing");
  });
  editor.addEventListener("blur", () => {
    li.classList.remove("submission-draft-section--editing");
    // Force-flush any pending autosave so we don't leave unsynced
    // edits hanging when the lawyer tabs out.
    flushSectionAutosave(sec.id);
  });

  li.appendChild(editor);

  if (isActive) {
    // The repaint happened while this section was focused — restore
    // focus to it. Cursor placement at the end is a fair default
    // (typing mid-content during a repaint is rare; the autosave path
    // typically doesn't repaint at all).
    queueMicrotask(() => {
      const fresh = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sec.id)}"]`) as HTMLDivElement | null;
      if (fresh) {
        fresh.focus();
        placeCaretAtEnd(fresh);
      }
    });
  }

  return li;
}

function makeToolbarButton(label: string, title: string, format: string, value?: string): HTMLButtonElement {
  const btn = document.createElement("button");
  btn.type = "button";
  btn.className = "submission-draft-section-toolbar-btn";
  btn.textContent = label;
  btn.title = title;
  // Mousedown rather than click so the editor doesn't lose focus
  // mid-command — execCommand requires the editor to be the active
  // selection target.
  btn.addEventListener("mousedown", (ev) => {
    ev.preventDefault();
    document.execCommand(format, false, value);
    // Trigger the input handler so autosave fires.
    const editor = document.activeElement as HTMLElement | null;
    if (editor && editor.classList.contains("submission-draft-section-editor")) {
      onSectionInput(editor as HTMLDivElement);
    }
  });
  return btn;
}

// makeHeadingButton emits an `<h1|h2|h3>` wrapping for the active
// block via execCommand("formatBlock", "h1") etc. Toggling the same
// heading back to a paragraph is handled by clicking the same button
// again (the browser's execCommand semantics).
function makeHeadingButton(label: string, title: string, level: 1 | 2 | 3): HTMLButtonElement {
  const btn = document.createElement("button");
  btn.type = "button";
  btn.className = "submission-draft-section-toolbar-btn";
  btn.textContent = label;
  btn.title = title;
  btn.addEventListener("mousedown", (ev) => {
    ev.preventDefault();
    document.execCommand("formatBlock", false, "h" + level);
    const editor = document.activeElement as HTMLElement | null;
    if (editor && editor.classList.contains("submission-draft-section-editor")) {
      onSectionInput(editor as HTMLDivElement);
    }
  });
  return btn;
}

// makeLinkButton prompts for a URL and wraps the current selection
// (or inserts a label-as-URL if nothing selected). The browser's
// createLink built-in wires the <a href="…"> tag into the DOM;
// domToMarkdown reads it back as `[label](url)`.
function makeLinkButton(): HTMLButtonElement {
  const btn = document.createElement("button");
  btn.type = "button";
  btn.className = "submission-draft-section-toolbar-btn";
  btn.textContent = "🔗";
  btn.title = isEN() ? "Insert link" : "Link einfügen";
  btn.addEventListener("mousedown", (ev) => {
    ev.preventDefault();
    const url = prompt(isEN() ? "URL:" : "URL:");
    if (!url) return;
    document.execCommand("createLink", false, url);
    const editor = document.activeElement as HTMLElement | null;
    if (editor && editor.classList.contains("submission-draft-section-editor")) {
      onSectionInput(editor as HTMLDivElement);
    }
  });
  return btn;
}

function activeSectionEditorID(): string | null {
  const active = document.activeElement as HTMLElement | null;
  if (!active || !active.classList.contains("submission-draft-section-editor")) return null;
  return active.dataset.sectionId ?? null;
}

function placeCaretAtEnd(el: HTMLElement): void {
  const range = document.createRange();
  range.selectNodeContents(el);
  range.collapse(false);
  const sel = window.getSelection();
  if (!sel) return;
  sel.removeAllRanges();
  sel.addRange(range);
}

function onSectionInput(editor: HTMLDivElement): void {
  const id = editor.dataset.sectionId;
  if (!id) return;
  if (sectionAutosaveTimers[id]) clearTimeout(sectionAutosaveTimers[id]);
  sectionAutosaveTimers[id] = window.setTimeout(() => {
    sectionAutosaveTimers[id] = 0;
    flushSectionAutosave(id);
  }, SECTION_AUTOSAVE_MS);
}

function flushSectionAutosave(sectionID: string): void {
  if (sectionAutosaveTimers[sectionID]) {
    clearTimeout(sectionAutosaveTimers[sectionID]);
    sectionAutosaveTimers[sectionID] = 0;
  }
  const editor = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sectionID)}"]`) as HTMLDivElement | null;
  if (!editor || !state.view) return;
  const lang = editor.dataset.lang || state.view.draft.language || "de";
  const md = domToMarkdown(editor);
  void patchSection(sectionID, lang === "en" ? { content_md_en: md } : { content_md_de: md });
}

// domToMarkdown serialises a contentEditable's DOM tree back to
// Markdown. Walks the tree: <b>/<strong> emit `**…**`, <i>/<em> emit
// `*…*`, <br> emits a newline, block-level elements emit a blank line
// between siblings. Slice B handles only B/I + paragraphs/line breaks
// — Slice D's rich toolbar extends this to headings + lists + quote.
function domToMarkdown(root: HTMLElement): string {
  return serializeNode(root).trim();
}

function serializeNode(node: Node): string {
  if (node.nodeType === Node.TEXT_NODE) {
    return node.textContent ?? "";
  }
  if (node.nodeType !== Node.ELEMENT_NODE) return "";
  const el = node as HTMLElement;
  const tag = el.tagName.toLowerCase();

  // Lists: handle the wrapper before recursing into items so we can
  // emit the right per-item Markdown prefix.
  if (tag === "ul" || tag === "ol") {
    const items: string[] = [];
    let counter = 1;
    for (const child of Array.from(el.childNodes)) {
      if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === "li") {
        const liInner = serializeNode(child).replace(/\n+$/g, "");
        const prefix = tag === "ol" ? `${counter}. ` : "- ";
        items.push(prefix + liInner);
        counter++;
      }
    }
    return items.join("\n") + "\n\n";
  }

  let inner = "";
  for (const child of Array.from(el.childNodes)) {
    inner += serializeNode(child);
  }

  switch (tag) {
    case "b":
    case "strong":
      return inner ? `**${inner}**` : "";
    case "i":
    case "em":
      return inner ? `*${inner}*` : "";
    case "br":
      return "\n";
    case "div":
    case "p":
      // execCommand and contentEditable insert <div> on Enter in some
      // browsers, <p> in others. Both are paragraph boundaries.
      return inner + "\n\n";
    case "h1":
      return "# " + inner.replace(/\n+$/g, "") + "\n\n";
    case "h2":
      return "## " + inner.replace(/\n+$/g, "") + "\n\n";
    case "h3":
      return "### " + inner.replace(/\n+$/g, "") + "\n\n";
    case "blockquote":
      // Each line inside the blockquote gets its own "> " prefix per
      // Markdown convention.
      return inner.split("\n").map(line => line === "" ? "" : "> " + line).join("\n").replace(/\n+$/g, "") + "\n\n";
    case "li":
      // <li> rendered standalone (no <ul>/<ol> ancestor) — emit
      // bullet by default. The ul/ol branch above handles the
      // ordered/unordered choice when present.
      return "- " + inner.replace(/\n+$/g, "") + "\n";
    case "a": {
      const href = el.getAttribute("href") ?? "";
      if (!href || !inner) return inner;
      return `[${inner}](${href})`;
    }
    default:
      return inner;
  }
}

async function onSectionToggleIncluded(sec: SubmissionSectionJSON): Promise<void> {
  await patchSection(sec.id, { included: !sec.included });
}

// ─────────────────────────────────────────────────────────────────────
// t-paliad-318 Slice F — reorder / delete / add
// ─────────────────────────────────────────────────────────────────────

let dragSourceID: string | null = null;

function onSectionDragStart(ev: DragEvent, sectionID: string): void {
  if (!ev.dataTransfer) return;
  dragSourceID = sectionID;
  ev.dataTransfer.effectAllowed = "move";
  ev.dataTransfer.setData("text/plain", sectionID);
  const parentLi = (ev.target as HTMLElement).closest("li");
  if (parentLi) parentLi.classList.add("submission-draft-section--dragging");
}

function onSectionDragOver(ev: DragEvent, li: HTMLLIElement): void {
  ev.preventDefault();
  if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
  if (dragSourceID && dragSourceID !== li.dataset.sectionId) {
    li.classList.add("submission-draft-section--drop-target");
  }
}

function onSectionDragEnd(li: HTMLLIElement): void {
  li.classList.remove("submission-draft-section--dragging");
  document.querySelectorAll(".submission-draft-section--drop-target").forEach((el) => {
    el.classList.remove("submission-draft-section--drop-target");
  });
  dragSourceID = null;
}

async function onSectionDrop(ev: DragEvent, targetLi: HTMLLIElement): Promise<void> {
  ev.preventDefault();
  targetLi.classList.remove("submission-draft-section--drop-target");
  const sourceID = dragSourceID;
  dragSourceID = null;
  document.querySelectorAll(".submission-draft-section--dragging").forEach((el) => {
    el.classList.remove("submission-draft-section--dragging");
  });
  if (!sourceID || !state.view?.sections) return;
  const targetID = targetLi.dataset.sectionId;
  if (!targetID || sourceID === targetID) return;

  const ids = state.view.sections.map(s => s.id);
  const fromIdx = ids.indexOf(sourceID);
  const toIdx = ids.indexOf(targetID);
  if (fromIdx < 0 || toIdx < 0) return;

  // Splice source out, insert at target position. "Drop on row X"
  // semantics: source lands JUST BEFORE the target row.
  ids.splice(fromIdx, 1);
  const insertAt = ids.indexOf(targetID);
  ids.splice(insertAt, 0, sourceID);

  await reorderSections(ids);
}

async function reorderSections(ids: string[]): Promise<void> {
  if (!state.view) return;
  const draftID = state.view.draft.id;
  try {
    const res = await fetch(
      `/api/submission-drafts/${draftID}/sections/reorder`,
      {
        method: "POST",
        credentials: "include",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ section_order: ids }),
      },
    );
    if (!res.ok) {
      console.warn("reorder failed", res.status);
      return;
    }
    const body = await res.json() as { sections?: SubmissionSectionJSON[] };
    if (state.view && body.sections) state.view.sections = body.sections;
    paintSectionList();
  } catch (err) {
    console.warn("reorder error", err);
  }
}

async function onSectionDelete(sec: SubmissionSectionJSON): Promise<void> {
  const label = isEN() ? sec.label_en : sec.label_de;
  const confirmMsg = isEN()
    ? `Delete section "${label}"? This cannot be undone.`
    : `Abschnitt "${label}" entfernen? Diese Aktion kann nicht rückgängig gemacht werden.`;
  if (!confirm(confirmMsg)) return;
  if (!state.view) return;
  try {
    const res = await fetch(
      `/api/submission-drafts/${state.view.draft.id}/sections/${sec.id}`,
      { method: "DELETE", credentials: "include" },
    );
    if (!res.ok && res.status !== 204) {
      console.warn("delete section failed", res.status);
      return;
    }
    if (state.view.sections) {
      state.view.sections = state.view.sections.filter(s => s.id !== sec.id);
    }
    paintSectionList();
  } catch (err) {
    console.warn("delete section error", err);
  }
}

function openAddSectionForm(host: HTMLElement): void {
  // If already open, close (toggle).
  const existing = host.querySelector(".submission-draft-add-section");
  if (existing) {
    existing.remove();
    return;
  }
  const form = document.createElement("form");
  form.className = "submission-draft-add-section";
  form.addEventListener("submit", (ev) => { ev.preventDefault(); void submitAddSection(form); });

  const fields = [
    { name: "section_key", label: isEN() ? "Slug" : "Slug", required: true, placeholder: "berufungsantraege" },
    { name: "label_de", label: "Label (DE)", required: true, placeholder: "Berufungsanträge" },
    { name: "label_en", label: "Label (EN)", required: true, placeholder: "Appeal requests" },
  ];
  for (const f of fields) {
    const row = document.createElement("label");
    row.className = "submission-draft-add-section-row";
    const lab = document.createElement("span");
    lab.textContent = f.label + (f.required ? " *" : "");
    row.appendChild(lab);
    const inp = document.createElement("input");
    inp.type = "text";
    inp.name = f.name;
    inp.className = "entity-form-input";
    inp.required = f.required;
    inp.placeholder = f.placeholder;
    row.appendChild(inp);
    form.appendChild(row);
  }

  const kindRow = document.createElement("label");
  kindRow.className = "submission-draft-add-section-row";
  const kindLab = document.createElement("span");
  kindLab.textContent = isEN() ? "Kind" : "Typ";
  kindRow.appendChild(kindLab);
  const kindSel = document.createElement("select");
  kindSel.name = "kind";
  kindSel.className = "entity-form-input";
  for (const opt of ["prose", "requests", "evidence"]) {
    const o = document.createElement("option");
    o.value = opt;
    o.textContent = opt;
    kindSel.appendChild(o);
  }
  kindRow.appendChild(kindSel);
  form.appendChild(kindRow);

  const actions = document.createElement("div");
  actions.className = "submission-draft-add-section-actions";
  const ok = document.createElement("button");
  ok.type = "submit";
  ok.className = "btn-small btn-primary btn-cta-lime";
  ok.textContent = isEN() ? "Add" : "Hinzufügen";
  actions.appendChild(ok);
  const cancel = document.createElement("button");
  cancel.type = "button";
  cancel.className = "btn-small btn-secondary";
  cancel.textContent = isEN() ? "Cancel" : "Abbrechen";
  cancel.addEventListener("click", () => form.remove());
  actions.appendChild(cancel);
  form.appendChild(actions);

  host.appendChild(form);
  setTimeout(() => (form.querySelector('input[name="section_key"]') as HTMLInputElement | null)?.focus(), 0);
}

async function submitAddSection(form: HTMLFormElement): Promise<void> {
  if (!state.view) return;
  const data = new FormData(form);
  const payload = {
    section_key: String(data.get("section_key") ?? "").trim(),
    kind: String(data.get("kind") ?? "prose"),
    label_de: String(data.get("label_de") ?? "").trim(),
    label_en: String(data.get("label_en") ?? "").trim(),
  };
  try {
    const res = await fetch(
      `/api/submission-drafts/${state.view.draft.id}/sections`,
      {
        method: "POST",
        credentials: "include",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      },
    );
    if (!res.ok) {
      const body = await res.json().catch(() => ({} as { error?: string }));
      alert(body.error ?? `HTTP ${res.status}`);
      return;
    }
    const created = await res.json() as SubmissionSectionJSON;
    if (state.view.sections) state.view.sections.push(created);
    form.remove();
    paintSectionList();
  } catch (err) {
    alert(String(err));
  }
}

// ─────────────────────────────────────────────────────────────────────
// t-paliad-315 Slice C — building-block picker modal
// ─────────────────────────────────────────────────────────────────────

interface BuildingBlockPickJSON {
  id: string;
  slug: string;
  section_key: string;
  proceeding_family?: string | null;
  title_de: string;
  title_en: string;
  description_de?: string | null;
  description_en?: string | null;
  content_md_de: string;
  content_md_en: string;
  visibility: string;
}

let blockPickerSearchTimer: number | null = null;

function openBlockPicker(sec: SubmissionSectionJSON): void {
  // Remove any prior picker.
  document.getElementById("submission-bb-picker")?.remove();

  const overlay = document.createElement("div");
  overlay.id = "submission-bb-picker";
  overlay.className = "submission-bb-picker-overlay";
  overlay.addEventListener("click", (ev) => {
    if (ev.target === overlay) overlay.remove();
  });

  const modal = document.createElement("div");
  modal.className = "submission-bb-picker";

  const head = document.createElement("header");
  head.className = "submission-bb-picker-head";
  const title = document.createElement("h2");
  title.textContent = isEN() ? "Insert building block" : "Baustein einfügen";
  head.appendChild(title);
  const close = document.createElement("button");
  close.type = "button";
  close.className = "btn-small btn-secondary";
  close.textContent = isEN() ? "Close" : "Schließen";
  close.addEventListener("click", () => overlay.remove());
  head.appendChild(close);
  modal.appendChild(head);

  const search = document.createElement("input");
  search.type = "search";
  search.placeholder = isEN() ? "Search blocks…" : "Bausteine suchen…";
  search.className = "entity-form-input submission-bb-picker-search";
  modal.appendChild(search);

  const sectionInfo = document.createElement("p");
  sectionInfo.className = "submission-bb-picker-sectioninfo";
  sectionInfo.textContent = (isEN() ? "Section: " : "Abschnitt: ") + sec.section_key;
  modal.appendChild(sectionInfo);

  const list = document.createElement("div");
  list.className = "submission-bb-picker-list";
  list.textContent = isEN() ? "Loading…" : "Lädt…";
  modal.appendChild(list);

  overlay.appendChild(modal);
  document.body.appendChild(overlay);

  const fetchBlocks = async (q: string) => {
    const params = new URLSearchParams();
    params.set("section_key", sec.section_key);
    if (q) params.set("q", q);
    try {
      const res = await fetch(`/api/submission-building-blocks?${params.toString()}`, { credentials: "include" });
      if (!res.ok) {
        list.textContent = `HTTP ${res.status}`;
        return;
      }
      const body = await res.json() as { blocks?: BuildingBlockPickJSON[] };
      paintPickerList(list, body.blocks ?? [], sec, overlay);
    } catch (err) {
      list.textContent = String(err);
    }
  };

  search.addEventListener("input", () => {
    if (blockPickerSearchTimer) clearTimeout(blockPickerSearchTimer);
    blockPickerSearchTimer = window.setTimeout(() => {
      void fetchBlocks(search.value.trim());
    }, 200);
  });

  void fetchBlocks("");
  setTimeout(() => search.focus(), 0);
}

function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec: SubmissionSectionJSON, overlay: HTMLElement): void {
  host.innerHTML = "";
  if (blocks.length === 0) {
    const empty = document.createElement("p");
    empty.className = "submission-bb-picker-empty";
    empty.textContent = isEN() ? "No blocks match." : "Keine passenden Bausteine.";
    host.appendChild(empty);
    return;
  }
  const lang = state.view?.draft.language || "de";
  for (const b of blocks) {
    const row = document.createElement("button");
    row.type = "button";
    row.className = "submission-bb-picker-row";
    const title = (lang === "en" ? b.title_en : b.title_de) || b.slug;
    const desc = (lang === "en" ? b.description_en : b.description_de) || "";
    const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
    row.innerHTML = `
      <div class="submission-bb-picker-row-head">
        <strong>${escapeHtml(title)}</strong>
        <span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHtml(b.visibility)}">${escapeHtml(b.visibility)}</span>
      </div>
      ${desc ? `<div class="submission-bb-picker-row-desc">${escapeHtml(desc)}</div>` : ""}
      <pre class="submission-bb-picker-row-preview">${escapeHtml(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
    row.addEventListener("click", () => {
      void insertBlockIntoSection(b.id, sec.id, overlay);
    });
    host.appendChild(row);
  }
}

async function insertBlockIntoSection(blockID: string, sectionID: string, overlay: HTMLElement): Promise<void> {
  try {
    const res = await fetch(
      `/api/submission-building-blocks/${blockID}/insert-into/${sectionID}`,
      { method: "POST", credentials: "include" },
    );
    if (!res.ok) {
      console.warn("insert-into PATCH failed", res.status);
      return;
    }
    const updated = await res.json() as SubmissionSectionJSON;
    if (state.view && state.view.sections) {
      const idx = state.view.sections.findIndex(s => s.id === sectionID);
      if (idx >= 0) state.view.sections[idx] = updated;
    }
    paintSectionList();
    overlay.remove();
  } catch (err) {
    console.warn("insert block error", err);
  }
}

async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
  try {
    const draftID = state.view?.draft.id;
    if (!draftID) return;
    const res = await fetch(
      `/api/submission-drafts/${draftID}/sections/${sectionID}`,
      {
        method: "PATCH",
        credentials: "include",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      },
    );
    if (!res.ok) {
      console.warn("section PATCH failed", res.status, sectionID);
      return;
    }
    const updated = await res.json() as SubmissionSectionJSON;
    // Splice the updated row into state.view.sections. Don't re-paint
    // unless we need to (avoid focus stealing during active typing).
    if (state.view && state.view.sections) {
      const idx = state.view.sections.findIndex(s => s.id === sectionID);
      if (idx >= 0) state.view.sections[idx] = updated;
    }
    // Only repaint when the change has visible UI knock-on (toggle,
    // label, order). content_md_* changes don't need a repaint —
    // the editor already shows the lawyer's keystrokes.
    if ("included" in payload || "label_de" in payload || "label_en" in payload || "order_index" in payload) {
      paintSectionList();
    }
  } catch (err) {
    console.warn("section PATCH error", err);
  }
}

// t-paliad-261 (B) — click a substituted variable in the preview to
// jump to the matching sidebar input. Re-wires on every paintPreview
// since the preview HTML is replaced wholesale. The server side wraps
// each substituted placeholder (resolved OR missing marker) in
// <span class="draft-var" data-var="<key>">…</span>; clicks here scroll
// the corresponding input into view, focus + select, and flash the row.
// If the key has no matching sidebar input (derived variables not
// exposed in VARIABLE_GROUPS), the click is a silent no-op — the span
// is still rendered so the user gets the visible hint that this is a
// resolved variable.
function wireDraftVars(previewHost: HTMLElement): void {
  previewHost.querySelectorAll<HTMLElement>(".draft-var").forEach((el) => {
    const key = el.dataset.var;
    if (!key) return;
    if (findVarInput(key)) {
      el.classList.add("draft-var--has-input");
      el.setAttribute("role", "button");
      el.setAttribute("tabindex", "0");
      el.setAttribute(
        "aria-label",
        (isEN() ? "Edit variable " : "Variable bearbeiten: ") + labelFor(key),
      );
    }
    el.addEventListener("click", (ev) => onDraftVarClick(key, ev));
    el.addEventListener("keydown", (ev) => {
      if (ev.key === "Enter" || ev.key === " ") {
        ev.preventDefault();
        onDraftVarClick(key, ev);
      }
    });
  });
}

function findVarInput(key: string): HTMLInputElement | null {
  const host = document.getElementById("submission-draft-variables");
  if (!host) return null;
  return host.querySelector<HTMLInputElement>(
    `.submission-draft-var-input[data-var="${cssEscape(key)}"]`,
  );
}

function onDraftVarClick(key: string, ev: Event): void {
  const input = findVarInput(key);
  if (!input) return;
  ev.preventDefault();
  ev.stopPropagation();
  // Smooth-scroll the input into view, then focus on the next tick so
  // the scroll animation has started and the focus call doesn't trigger
  // a second jarring jump.
  input.scrollIntoView({ behavior: "smooth", block: "center" });
  window.setTimeout(() => {
    input.focus();
    try {
      input.select();
    } catch {
      /* select() throws on number/email inputs; safe to ignore */
    }
  }, 50);
  flashVarRow(input);
}

// t-paliad-274 (B) — sidebar-field-focus → preview-occurrence highlight.
// Reverse direction of the click-to-jump from #92: when the user focuses
// any .submission-draft-var-input, every matching .draft-var span in the
// preview gets the --active modifier; on blur (or focus shift to a
// different field), the previous key's highlights clear and the new
// key's apply. Sticky-while-focused, not a one-shot flash — the lawyer
// can scan the preview for "where does this variable land in my prose?"
// while the field stays focused.
function onVarFocusEnter(key: string): void {
  if (!key) return;
  // Clear any leftover highlight before applying the new one — covers
  // the focus-shift-without-blur case (Tab between fields).
  clearPreviewActiveHighlight();
  applyPreviewActiveHighlight(key);
}

function onVarFocusLeave(_key: string): void {
  // We don't need the key here — if focus moves to a different sidebar
  // input, that input's focusin will re-call apply with the new key
  // (after our clearPreviewActiveHighlight). If focus leaves the sidebar
  // entirely, this clears.
  clearPreviewActiveHighlight();
}

function applyPreviewActiveHighlight(key: string): void {
  const host = document.getElementById("submission-draft-preview");
  if (!host) return;
  host.querySelectorAll<HTMLElement>(
    `.draft-var[data-var="${cssEscape(key)}"]`,
  ).forEach((el) => {
    el.classList.add("draft-var--active");
  });
}

function clearPreviewActiveHighlight(): void {
  const host = document.getElementById("submission-draft-preview");
  if (!host) return;
  host.querySelectorAll<HTMLElement>(".draft-var--active").forEach((el) => {
    el.classList.remove("draft-var--active");
  });
}

function flashVarRow(input: HTMLElement): void {
  const row = input.closest<HTMLElement>(".submission-draft-var-row");
  if (!row) return;
  row.classList.remove("submission-draft-var-row--flash");
  // Force reflow so removing+re-adding the class restarts the animation
  // even on rapid successive clicks.
  void row.offsetWidth;
  row.classList.add("submission-draft-var-row--flash");
  window.setTimeout(() => row.classList.remove("submission-draft-var-row--flash"), 1200);
}

// ─────────────────────────────────────────────────────────────────────
// Event handlers
// ─────────────────────────────────────────────────────────────────────

async function onPartySelectionChange(): Promise<void> {
  if (!state.view) return;
  const host = document.getElementById("submission-draft-parties-list");
  if (!host) return;
  const checks = host.querySelectorAll<HTMLInputElement>(".submission-draft-party-check");
  const selectedIDs: string[] = [];
  checks.forEach((c) => {
    if (c.checked && c.dataset.partyId) selectedIDs.push(c.dataset.partyId);
  });

  // If the lawyer has checked every party, persist that as an empty
  // array so the row matches the "implicit all" default semantics — a
  // future party added to the project will then be picked up
  // automatically rather than silently dropped from this submission.
  // If they've unchecked some, persist the actual subset.
  const available = state.view.available_parties ?? [];
  const allChecked = selectedIDs.length === available.length;
  const payload = allChecked ? [] : selectedIDs;

  setSaveStatus(isEN() ? "Saving…" : "Speichert…");
  try {
    const view = await patchDraft({ selected_parties: payload });
    state.view = view;
    paintImportRow();
    paintPartyPicker();
    paintVariables();
    paintPreview();
    setSaveStatus(isEN() ? "Saved" : "Gespeichert");
  } catch (err) {
    if ((err as Error).name === "AbortError") return;
    console.error("submission-draft party selection:", err);
    setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
  }
}

async function runPartySearch(query: string): Promise<void> {
  try {
    const params = new URLSearchParams();
    if (query) params.set("q", query);
    const resp = await fetch(`/api/parties/search?${params.toString()}`);
    if (!resp.ok) throw new Error(`search ${resp.status}`);
    const data = (await resp.json()) as { results: PartySearchHit[] };
    // Filter out parties already on THIS project — picking one of them
    // would be a no-op clone that doubles the row.
    const existingIDs = new Set(
      (state.view?.available_parties ?? []).map((p) => p.id),
    );
    state.addPartySearchHits = (data.results ?? []).filter((h) => !existingIDs.has(h.id));

    // Refresh ONLY the results <ul> in place — repainting the whole
    // picker would steal focus from the search input on every
    // keystroke. The input keeps its value/selection and the lawyer
    // can keep typing.
    const ul = document.querySelector<HTMLUListElement>(
      ".submission-draft-addparty-search-results",
    );
    if (ul) {
      ul.outerHTML = renderPartySearchResultsList();
      const fresh = document.querySelector<HTMLUListElement>(
        ".submission-draft-addparty-search-results",
      );
      if (fresh) {
        fresh.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
          li.addEventListener("click", () => {
            const hitID = li.dataset.hitId;
            if (!hitID) return;
            const hit = state.addPartySearchHits.find((h) => h.id === hitID);
            if (!hit) return;
            const side = state.addPartyOpen ?? "other";
            void onAddPartySearchPick(side, hit);
          });
        });
      }
    } else {
      // First load (panel just opened) — full picker paint to wire up
      // every control. Subsequent keystroke updates take the cheaper
      // path above.
      paintPartyPicker();
    }
  } catch (err) {
    console.error("submission-draft party-search:", err);
  }
}

function renderPartySearchResultsList(): string {
  let html = `<ul class="submission-draft-addparty-search-results">`;
  if (state.addPartySearchHits.length === 0) {
    html += `<li class="submission-draft-addparty-search-empty">${escapeHtml(
      isEN() ? "No matches." : "Keine Treffer.",
    )}</li>`;
  } else {
    for (const hit of state.addPartySearchHits) {
      const ref = hit.project_reference
        ? `<span class="submission-draft-addparty-search-projref">${escapeHtml(hit.project_reference)}</span>`
        : "";
      const role = hit.role
        ? `<span class="submission-draft-party-chip">${escapeHtml(hit.role)}</span>`
        : "";
      const rep = hit.representative
        ? `<span class="submission-draft-addparty-search-rep">${escapeHtml(
            (isEN() ? "Repr.: " : "Vertr.: ") + hit.representative,
          )}</span>`
        : "";
      html += `<li class="submission-draft-addparty-search-row" data-hit-id="${escapeHtml(hit.id)}">`;
      html += `<span class="submission-draft-addparty-search-name">${escapeHtml(hit.name)}</span>`;
      html += role;
      html += rep;
      html += `<span class="submission-draft-addparty-search-projwrap">`;
      html += escapeHtml(isEN() ? "Project: " : "Projekt: ");
      html += `<span class="submission-draft-addparty-search-proj">${escapeHtml(hit.project_title)}</span>`;
      html += ref;
      html += `</span>`;
      html += `</li>`;
    }
  }
  html += `</ul>`;
  return html;
}

async function onAddPartyManualSubmit(
  side: PartySide,
  payload: { name: string; role: string; representative: string },
): Promise<void> {
  if (!state.view) return;
  const projectID = state.view.draft.project_id;
  if (!projectID) return;
  // Disable the submit button in-place rather than repainting the form
  // mid-flight (a repaint would blow away the lawyer's typed values on
  // error and reset focus). The post-success/-error repaint runs once
  // the call settles.
  const submitBtn = document.querySelector<HTMLButtonElement>(
    `.submission-draft-addparty-form[data-side="${side}"] button[type="submit"]`,
  );
  if (submitBtn) submitBtn.disabled = true;
  state.addPartyBusy = true;
  try {
    const body: Record<string, unknown> = { name: payload.name };
    if (payload.role) body.role = payload.role;
    if (payload.representative) body.representative = payload.representative;
    const resp = await fetch(`/api/projects/${projectID}/parties`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if (!resp.ok) throw new Error(`create party ${resp.status}`);
    const created = (await resp.json()) as { id: string };
    await refreshDraftViewAndSelect(created.id);
    state.addPartyOpen = null;
    setSaveStatus(isEN() ? "Party added" : "Partei hinzugefügt");
    state.addPartyBusy = false;
    paintPartyPicker();
  } catch (err) {
    console.error("submission-draft add-party manual:", err);
    setSaveStatus(isEN() ? "Add party failed" : "Hinzufügen fehlgeschlagen", true);
    if (submitBtn) submitBtn.disabled = false;
    state.addPartyBusy = false;
  }
}

async function onAddPartySearchPick(side: PartySide, hit: PartySearchHit): Promise<void> {
  // DB picks clone the row into the current project — the simplest
  // semantics that survive paliad.parties' project_id-NOT-NULL schema.
  // The lawyer asked for "no manual re-typing"; this honours that
  // without bending the data model.
  await onAddPartyManualSubmit(side, {
    name: hit.name,
    role: hit.role ?? defaultRoleFor(side),
    representative: hit.representative ?? "",
  });
}

// refreshDraftViewAndSelect refetches the editor payload (so
// available_parties picks up the new row) and ensures the newly-added
// party is checked in selected_parties. If the lawyer was on the
// implicit-all default (empty selected_parties), the new party comes
// in pre-selected via the "empty=all" rule and no PATCH is needed.
async function refreshDraftViewAndSelect(newPartyID: string): Promise<void> {
  if (!state.view) return;
  const draftID = state.view.draft.id;
  const view = state.view.draft.project_id
    ? await fetchView(state.view.draft.project_id, state.view.draft.submission_code, draftID)
    : await fetchGlobalView(draftID);
  state.view = view;

  // If the previous draft had a non-empty selected_parties subset,
  // explicitly add the new party so it isn't silently dropped from the
  // submission. Empty selected_parties = "all" → no PATCH needed.
  const currentSel = state.view.draft.selected_parties ?? [];
  if (currentSel.length > 0 && !currentSel.includes(newPartyID)) {
    const next = [...currentSel, newPartyID];
    try {
      const patched = await patchDraft({ selected_parties: next });
      state.view = patched;
    } catch (err) {
      console.error("submission-draft select new party:", err);
    }
  }

  paintImportRow();
  paintPartyPicker();
  paintVariables();
  paintPreview();
}

async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
  if (!state.view) return;
  const draftID = state.view.draft.id;
  const originalLabel = btn.textContent ?? "";
  btn.disabled = true;
  btn.textContent = isEN() ? "Importing…" : "Importiert…";
  setSaveStatus(isEN() ? "Importing from project…" : "Importiere aus Projekt…");
  try {
    const resp = await fetch(`/api/submission-drafts/${draftID}/import-from-project`, {
      method: "POST",
    });
    if (!resp.ok) throw new Error(`import ${resp.status}`);
    const view = (await resp.json()) as SubmissionDraftView;
    state.view = view;
    paintImportRow();
    paintPartyPicker();
    paintVariables();
    paintPreview();
    setSaveStatus(isEN() ? "Imported" : "Importiert");
  } catch (err) {
    console.error("submission-draft import-from-project:", err);
    setSaveStatus(isEN() ? "Import failed" : "Import fehlgeschlagen", true);
  } finally {
    btn.disabled = false;
    btn.textContent = originalLabel;
  }
}

function onVarChange(input: HTMLInputElement): void {
  const key = input.dataset.var;
  if (!key || !state.view) return;
  // Stage the override on the draft view so paintPreview reflects.
  const overrides = { ...state.view.draft.variables };
  overrides[key] = input.value;
  state.view.draft.variables = overrides;
  state.pendingOverrides = overrides;
  setSaveStatus(isEN() ? "Saving…" : "Speichert…");
  if (state.saveTimer) window.clearTimeout(state.saveTimer);
  state.saveTimer = window.setTimeout(() => {
    void flushAutosave();
  }, 500);
}

function onVarReset(key: string): void {
  if (!state.view) return;
  const overrides = { ...state.view.draft.variables };
  delete overrides[key];
  state.view.draft.variables = overrides;
  state.pendingOverrides = overrides;
  setSaveStatus(isEN() ? "Saving…" : "Speichert…");
  if (state.saveTimer) window.clearTimeout(state.saveTimer);
  state.saveTimer = window.setTimeout(() => {
    void flushAutosave();
  }, 500);
}

async function flushAutosave(): Promise<void> {
  if (!state.pendingOverrides) return;
  const payload = { variables: state.pendingOverrides };
  state.pendingOverrides = null;
  // t-paliad-261 (A) — paintVariables() below replaces every input in
  // the sidebar via innerHTML, which blows away the active-element
  // reference. Capture the focused input's key + selection range before
  // the repaint and restore on the new element after, so the user can
  // keep typing without clicking back into the field.
  const focusSnap = captureVarFocus();
  try {
    const view = await patchDraft(payload);
    state.view = view;
    paintVariables();
    paintPreview();
    restoreVarFocus(focusSnap);
    setSaveStatus(isEN() ? "Saved" : "Gespeichert");
  } catch (err) {
    if ((err as Error).name === "AbortError") return;
    console.error("submission-draft autosave:", err);
    setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
  }
}

// captureVarFocus / restoreVarFocus — focus-preservation across the
// paintVariables() innerHTML-replace cycle (t-paliad-261 part A).
// Tracks selection start/end/direction so the cursor lands exactly
// where it was before the repaint, including any active selection
// range. Handles both <input> and <textarea> via the shared
// HTMLInputElement|HTMLTextAreaElement contract for selectionStart /
// selectionEnd / selectionDirection / setSelectionRange.

interface VarFocusSnapshot {
  key: string;
  start: number | null;
  end: number | null;
  dir: "forward" | "backward" | "none";
}

type SelectableEl = HTMLInputElement | HTMLTextAreaElement;

function isVarField(el: Element | null): el is SelectableEl {
  if (!el) return false;
  if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) {
    return false;
  }
  return el.classList.contains("submission-draft-var-input");
}

function captureVarFocus(): VarFocusSnapshot | null {
  const active = document.activeElement;
  if (!isVarField(active)) return null;
  const key = active.dataset.var;
  if (!key) return null;
  return {
    key,
    start: active.selectionStart,
    end: active.selectionEnd,
    dir: (active.selectionDirection as "forward" | "backward" | "none" | null) ?? "forward",
  };
}

function restoreVarFocus(snap: VarFocusSnapshot | null): void {
  if (!snap) return;
  const host = document.getElementById("submission-draft-variables");
  if (!host) return;
  const next = host.querySelector<SelectableEl>(
    `.submission-draft-var-input[data-var="${cssEscape(snap.key)}"]`,
  );
  if (!next) return;
  next.focus();
  if (snap.start !== null && snap.end !== null) {
    try {
      next.setSelectionRange(snap.start, snap.end, snap.dir);
    } catch {
      /* setSelectionRange throws on inputs whose type doesn't support
         selection ranges (number, email, etc.); safe to ignore — the
         focus() call above is enough for those. */
    }
  }
}

async function renameDraft(newName: string): Promise<void> {
  setSaveStatus(isEN() ? "Saving…" : "Speichert…");
  try {
    const view = await patchDraft({ name: newName });
    state.view = view;
    // Refresh the draft list cache.
    const idx = state.drafts.findIndex((d) => d.id === view.draft.id);
    if (idx >= 0) state.drafts[idx].name = view.draft.name;
    paintSwitcher();
    setSaveStatus(isEN() ? "Renamed" : "Umbenannt");
  } catch (err) {
    if ((err as Error).name === "AbortError") return;
    console.error("submission-draft rename:", err);
    const msg = (err as Error).message?.includes("409")
      ? (isEN() ? "Name already in use" : "Name bereits vergeben")
      : (isEN() ? "Rename failed" : "Umbenennen fehlgeschlagen");
    setSaveStatus(msg, true);
  }
}

async function onCreateNew(): Promise<void> {
  const p = state.parsed;
  // From a project-less draft, "Neuer Entwurf" can't auto-pick a
  // (project, code) cross-section — kick the user out to the global
  // picker instead.
  if (!p.projectID || !p.submissionCode) {
    window.location.href = "/submissions/new";
    return;
  }
  try {
    const fresh = await createProjectDraft(p);
    const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${fresh.id}`;
    window.location.href = url;
  } catch (err) {
    console.error("submission-draft new:", err);
    setSaveStatus(isEN() ? "Create failed" : "Anlegen fehlgeschlagen", true);
  }
}

async function onDelete(): Promise<void> {
  if (!state.view) return;
  const msg = isEN()
    ? `Delete draft "${state.view.draft.name}"? This cannot be undone.`
    : `Entwurf "${state.view.draft.name}" löschen? Das kann nicht rückgängig gemacht werden.`;
  if (!window.confirm(msg)) return;
  try {
    await deleteDraft();
    const p = state.parsed;
    const url = p.projectID && p.submissionCode
      ? `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft`
      : "/submissions";
    window.location.href = url;
  } catch (err) {
    console.error("submission-draft delete:", err);
    setSaveStatus(isEN() ? "Delete failed" : "Löschen fehlgeschlagen", true);
  }
}

async function onExport(btn: HTMLButtonElement): Promise<void> {
  if (!state.view) return;
  const p = state.parsed;
  if (!p.draftID) return;
  const originalLabel = btn.textContent ?? "";
  btn.disabled = true;
  btn.textContent = isEN() ? "Exporting…" : "Exportiert…";
  try {
    // Use the global export endpoint for both project-scoped and
    // project-less drafts; the handler routes audit + project_events
    // writes based on the draft row's project_id.
    const url = `/api/submission-drafts/${p.draftID}/export`;
    const resp = await fetch(url, { method: "POST" });
    if (!resp.ok) {
      let detail = "";
      try {
        const data = (await resp.json()) as { error?: string };
        detail = data.error ?? "";
      } catch { /* fallthrough */ }
      alert((isEN() ? "Export failed." : "Export fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""));
      return;
    }
    const blob = await resp.blob();
    const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "") ?? `${state.view.draft.submission_code}.docx`;
    triggerDownload(blob, filename);
  } finally {
    btn.disabled = false;
    btn.textContent = originalLabel;
  }
}

// ─────────────────────────────────────────────────────────────────────
// Project assign picker (project-less → project-scoped)
// ─────────────────────────────────────────────────────────────────────

interface PickerProjectRow {
  id: string;
  title: string;
  reference?: string | null;
}

let assignPickerProjects: PickerProjectRow[] = [];
let assignPickerLoaded = false;

function openProjectAssignPicker(): void {
  ensureAssignPickerDOM();
  const modal = document.getElementById("submission-draft-assign-modal");
  if (modal) modal.style.display = "";
  if (!assignPickerLoaded) {
    void loadAssignPickerProjects();
  } else {
    renderAssignPickerList();
  }
  const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
  if (searchInput) {
    searchInput.value = "";
    setTimeout(() => searchInput.focus(), 50);
  }
}

function closeProjectAssignPicker(): void {
  const modal = document.getElementById("submission-draft-assign-modal");
  if (modal) modal.style.display = "none";
}

function ensureAssignPickerDOM(): void {
  if (document.getElementById("submission-draft-assign-modal")) return;
  const titleTxt = isEN() ? "Assign project" : "Projekt zuweisen";
  const placeholder = isEN()
    ? "Search project (title or reference)…"
    : "Projekt suchen (Titel oder Aktenzeichen)…";
  const loadingTxt = isEN() ? "Loading projects…" : "Lädt Projekte…";
  const emptyTxt = isEN() ? "No visible projects." : "Keine sichtbaren Projekte.";

  const modal = document.createElement("div");
  modal.id = "submission-draft-assign-modal";
  modal.className = "modal-overlay";
  modal.setAttribute("role", "dialog");
  modal.setAttribute("aria-modal", "true");
  modal.style.display = "none";
  modal.innerHTML = `
    <div class="modal-card">
      <header class="modal-header">
        <h2>${escapeHtml(titleTxt)}</h2>
        <button type="button" id="submission-draft-assign-close" class="modal-close" aria-label="Close">×</button>
      </header>
      <div class="modal-body">
        <input type="search" id="submission-draft-assign-search" class="entity-form-input" placeholder="${escapeHtml(placeholder)}" />
        <ul id="submission-draft-assign-list" class="submissions-new-project-list"></ul>
        <p id="submission-draft-assign-loading" class="entity-events-empty" style="display:none">${escapeHtml(loadingTxt)}</p>
        <p id="submission-draft-assign-empty" class="entity-empty" style="display:none">${escapeHtml(emptyTxt)}</p>
      </div>
    </div>`;
  document.body.appendChild(modal);

  modal.addEventListener("click", (e) => {
    if (e.target === modal) closeProjectAssignPicker();
  });
  const closeBtn = document.getElementById("submission-draft-assign-close");
  if (closeBtn) closeBtn.addEventListener("click", () => closeProjectAssignPicker());
  const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
  if (searchInput) searchInput.addEventListener("input", () => renderAssignPickerList());
  document.addEventListener("keydown", (e) => {
    if (e.key === "Escape" && modal.style.display !== "none") closeProjectAssignPicker();
  });
}

async function loadAssignPickerProjects(): Promise<void> {
  const loadingEl = document.getElementById("submission-draft-assign-loading");
  if (loadingEl) loadingEl.style.display = "";
  try {
    const resp = await fetch("/api/projects?status=active");
    if (!resp.ok) throw new Error(`projects list ${resp.status}`);
    const rows = (await resp.json()) as PickerProjectRow[];
    assignPickerProjects = rows ?? [];
    assignPickerLoaded = true;
  } catch (err) {
    console.error("submission-draft assignPicker:", err);
    assignPickerProjects = [];
  } finally {
    if (loadingEl) loadingEl.style.display = "none";
  }
  renderAssignPickerList();
}

function renderAssignPickerList(): void {
  const list = document.getElementById("submission-draft-assign-list");
  const empty = document.getElementById("submission-draft-assign-empty");
  if (!list || !empty) return;

  const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
  const term = (searchInput?.value ?? "").trim().toLowerCase();

  const matches = assignPickerProjects.filter((p) => {
    if (term === "") return true;
    const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
    return hay.includes(term);
  }).slice(0, 50);

  if (matches.length === 0) {
    list.innerHTML = "";
    empty.style.display = "";
    return;
  }
  empty.style.display = "none";

  list.innerHTML = matches.map((p) => {
    const ref = p.reference ? `<span class="entity-ref">${escapeHtml(p.reference)}</span> ` : "";
    return `<li class="submissions-new-project-item" data-id="${escapeHtml(p.id)}">${ref}<span class="submissions-new-project-title">${escapeHtml(p.title)}</span></li>`;
  }).join("");

  list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
    li.addEventListener("click", () => {
      const pid = li.dataset.id;
      if (pid) void onAssignProject(pid);
    });
  });
}

async function onAssignProject(projectID: string): Promise<void> {
  closeProjectAssignPicker();
  setSaveStatus(isEN() ? "Assigning…" : "Wird zugewiesen…");
  try {
    const view = await patchDraft({ project_id: projectID });
    state.view = view;
    setSaveStatus(isEN() ? "Project assigned" : "Projekt zugewiesen");
    // Redirect to the project-scoped URL so the editor's URL matches the
    // attached project and the project-scoped draft list (sidebar
    // switcher) loads on refresh.
    const code = view.draft.submission_code;
    window.location.href = `/projects/${projectID}/submissions/${encodeURIComponent(code)}/draft/${view.draft.id}`;
  } catch (err) {
    console.error("submission-draft assign:", err);
    setSaveStatus(isEN() ? "Assign failed" : "Zuweisung fehlgeschlagen", true);
  }
}

// ─────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────

function show(id: string): void {
  const el = document.getElementById(id);
  if (el) el.style.display = "";
}

function hide(id: string): void {
  const el = document.getElementById(id);
  if (el) el.style.display = "none";
}

function showNotFound(): void {
  hide("submission-draft-loading");
  hide("submission-draft-body");
  show("submission-draft-notfound");
}

function showError(msg: string): void {
  hide("submission-draft-loading");
  hide("submission-draft-body");
  const el = document.getElementById("submission-draft-error");
  if (el) {
    el.textContent = msg;
    el.style.display = "";
  }
}

function setSaveStatus(msg: string, errorState: boolean = false): void {
  const el = document.getElementById("submission-draft-savestatus");
  if (!el) return;
  el.textContent = msg;
  el.classList.toggle("submission-draft-savestatus--error", errorState);
}

function parseFilename(header: string): string | null {
  const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
  return m ? m[1] : null;
}

function triggerDownload(blob: Blob, filename: string): void {
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  setTimeout(() => URL.revokeObjectURL(url), 0);
}

// Keep t() referenced so the bundler doesn't tree-shake it; future
// affordances will use the per-page i18n keys.
void t;

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", () => { void boot(); });
} else {
  void boot();
}
