import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
import { renderAgendaTimeline, type AgendaItem } from "./agenda-render";
import { openModal } from "./components/modal";
import {
  GRID_COLUMNS,
  MAX_ROW_SPAN,
  placeWidgets,
  clampW as gridClampW,
  clampH as gridClampH,
  type PlacedRect,
  type WidgetPlacementInput,
  type WidgetSizeBound,
} from "./dashboard-grid";

interface DashboardUser {
  id: string;
  email: string;
  display_name: string;
  office: string;
  role: string;
  global_role?: string;
}

interface DeadlineSummary {
  overdue: number;
  today: number;
  this_week: number;
  next_week: number;
  later: number;
}

interface MatterSummary {
  active: number;
  archived: number;
  total: number;
}

interface UpcomingDeadline {
  id: string;
  title: string;
  due_date: string;
  project_id: string;
  project_title: string;
  project_reference: string;
  urgency: "overdue" | "today" | "urgent" | "soon";
}

interface UpcomingAppointment {
  id: string;
  title: string;
  start_at: string;
  end_at: string | null;
  type: string | null;
  project_id: string | null;
  project_title: string | null;
  project_reference: string | null;
}

interface ActivityEntry {
  timestamp: string;
  actor_email: string | null;
  actor_name: string | null;
  project_id: string;
  project_title: string;
  project_reference: string;
  action: string | null;
  details: string;
  description: string | null;
  metadata: Record<string, unknown> | null;
}

interface DashboardData {
  user: DashboardUser | null;
  deadline_summary: DeadlineSummary;
  matter_summary: MatterSummary;
  upcoming_deadlines: UpcomingDeadline[];
  upcoming_appointments: UpcomingAppointment[];
  recent_activity: ActivityEntry[];
  inbox_summary?: InboxSummary;
  pinned_projects?: PinnedProjectRef[];
}

interface PinnedProjectRef {
  project_id: string;
  project_title: string;
  project_reference: string;
}

interface InboxEntry {
  id: string;
  entity_type: string;
  entity_title?: string | null;
  project_id: string;
  project_title: string;
  requested_at: string;
  requester_id: string;
  requester_name: string;
}

interface InboxSummary {
  pending_count: number;
  top: InboxEntry[];
}

// DashboardLayoutSpec mirrors the Go shape in
// internal/services/dashboard_layout_spec.go. The client treats the spec
// as advice: unknown widget keys are dropped silently (server is the
// source of truth for the catalog).
//
// Position fields (x/y/w/h) drive the 12-column grid. Missing values
// default to {x:0, y:auto, w:12, h:1} — pre-overhaul layouts therefore
// render as a single full-width stack until the user customises them.
interface WidgetSettingsValue {
  count?: number;
  horizon_days?: number;
  view?: string;
}
interface DashboardWidgetRef {
  key: string;
  visible: boolean;
  x?: number;
  y?: number;
  w?: number;
  h?: number;
  settings?: WidgetSettingsValue;
}
interface DashboardLayoutSpec {
  v: number;
  widgets: DashboardWidgetRef[];
}

// WidgetDef mirrors services.WidgetDef (Go side). Only the fields the
// client actually reads are typed here; the picker modal needs the
// titles + descriptions in both languages, and the settings schema so
// the gear popover can render the right knobs.
interface ViewOption {
  id: string;
  label_de: string;
  label_en: string;
}
interface WidgetSettingsSchema {
  count_options?: number[];
  horizon_options?: number[];
  count_allows_all?: boolean;
  count_max?: number;
  horizon_max?: number;
  views?: ViewOption[];
}
interface WidgetCatalogEntry {
  key: string;
  title_de: string;
  title_en: string;
  description_de: string;
  description_en: string;
  default_visible: boolean;
  default_count?: number;
  default_horizon_days?: number;
  default_view?: string;
  default_w?: number;
  default_h?: number;
  min_w?: number;
  max_w?: number;
  min_h?: number;
  max_h?: number;
  settings?: WidgetSettingsSchema | null;
}

// Grid constants — must match internal/services/dashboard_layout_spec.go.
// Re-exported from ./dashboard-grid so the placement math is shared with
// the unit tests; the names below keep the local imports tidy.
declare global {
  interface Window {
    __PALIAD_DASHBOARD__?: DashboardData | null;
    __PALIAD_DASHBOARD_LAYOUT__?: DashboardLayoutSpec | null;
    __PALIAD_DASHBOARD_CATALOG__?: WidgetCatalogEntry[] | null;
  }
}

let currentLayout: DashboardLayoutSpec | null = null;
let currentCatalog: WidgetCatalogEntry[] = [];
let editMode = false;

// Pending PUT debounce — 400ms per design §6.4. Every layout mutation
// (drag, ↑/↓, hide, add, settings change) calls scheduleSave(); only
// the last write inside a 400ms window goes to the wire. A successful
// PUT flashes the .dashboard-save-toast briefly; a failed PUT rolls
// the layout back to its pre-mutation snapshot and surfaces the
// "Speichern fehlgeschlagen" toast so the user can retry.
let saveTimer: number | null = null;
let saveSnapshot: DashboardLayoutSpec | null = null;
const SAVE_DEBOUNCE_MS = 400;

// settingsFor returns the (possibly-empty) settings blob for a given
// widget key in the active layout. Falls back to an empty object so
// renderers can read `.count ?? defaultN` without null checks.
function settingsFor(key: string): WidgetSettingsValue {
  if (!currentLayout) return {};
  for (const w of currentLayout.widgets) {
    if (w.key === key) return w.settings ?? {};
  }
  return {};
}

// viewFor resolves the active view id for a widget: explicit settings,
// then catalog default, then a single-renderer fallback (empty string).
function viewFor(key: string): string {
  const s = settingsFor(key);
  if (s.view) return s.view;
  const def = lookupCatalog(key);
  return def?.default_view ?? "";
}

const POLL_INTERVAL_MS = 60_000;
// 30-day look-ahead matches the agenda.tsx default chip and the server's
// default `to=today+30d` window — keeps the inline agenda visually
// consistent with /agenda when users follow the "full agenda" link.
const AGENDA_LOOKAHEAD_DAYS = 30;
const COLLAPSE_KEY_PREFIX = "paliad:dashboard:collapse:";
let data: DashboardData | null = null;
let agendaItems: AgendaItem[] | null = null;

async function loadDashboard(): Promise<void> {
  const unavailable = document.getElementById("dashboard-unavailable")!;
  try {
    const resp = await fetch("/api/dashboard");
    if (resp.status === 503) {
      unavailable.style.display = "block";
      return;
    }
    if (!resp.ok) {
      unavailable.style.display = "block";
      return;
    }
    data = await resp.json();
    render();
  } catch {
    unavailable.style.display = "block";
  }
}

function render(): void {
  if (!data) return;
  renderGreeting(data.user);
  renderSummary(data.deadline_summary);
  renderMatters(data.matter_summary);
  renderDeadlines(data.upcoming_deadlines);
  renderAppointments(data.upcoming_appointments);
  renderAgenda();
  renderActivity(data.recent_activity);
  renderInbox(data.inbox_summary ?? { pending_count: 0, top: [] });
  renderPinnedProjects(data.pinned_projects ?? []);
  // quick-actions is pure markup — nothing to render dynamically; the
  // anchor URLs are baked into dashboard.tsx and only need i18n which
  // applyTranslations already handles.
  toggleOnboardingHint(data.user);
  syncPromoteButtonVisibility(data.user);
  // Apply the saved layout AFTER renderers so the per-widget settings
  // applied above (count truncation, horizon filtering) are stable
  // before we toggle visibility + reorder. Failing to find the layout
  // is non-fatal — the factory default markup order takes over.
  applyLayout();
}

function renderGreeting(user: DashboardUser | null): void {
  const nameEl = document.getElementById("dashboard-greeting-name")!;
  const chip = document.getElementById("dashboard-office-chip")!;
  const dateEl = document.getElementById("dashboard-date")!;

  if (user) {
    nameEl.textContent = user.display_name ? `, ${user.display_name}` : "";
    const officeLabel = tDyn(`office.${user.office}`) || user.office;
    chip.textContent = officeLabel;
    chip.className = `dashboard-office-chip office-chip office-${user.office}`;
    chip.style.display = "inline-block";
  } else {
    nameEl.textContent = "";
    chip.style.display = "none";
  }

  const now = new Date();
  dateEl.textContent = now.toLocaleDateString(
    getLang() === "de" ? "de-DE" : "en-GB",
    { weekday: "long", year: "numeric", month: "long", day: "numeric" },
  );
}

function renderSummary(s: DeadlineSummary): void {
  setCount("dashboard-count-overdue", s.overdue);
  setCount("dashboard-count-today", s.today);
  setCount("dashboard-count-this-week", s.this_week);
  setCount("dashboard-count-next-week", s.next_week);
  setCount("dashboard-count-later", s.later);

  // Überfällig is an emergency category — hide the card entirely on a clean
  // slate (the .dashboard-summary-grid uses auto-fit so the row re-flows to
  // 4 cards) and trip the alarm styling when there's anything overdue. See
  // t-paliad-105 / t-paliad-106 / t-paliad-110.
  const overdueCard = document.getElementById("dashboard-card-overdue")!;
  overdueCard.classList.toggle("dashboard-card-overdue-hidden", s.overdue === 0);
  overdueCard.classList.toggle("dashboard-card-alarm", s.overdue > 0);
}

function renderMatters(s: MatterSummary): void {
  setCount("dashboard-matter-active", s.active);
  setCount("dashboard-matter-archived", s.archived);
  setCount("dashboard-matter-total", s.total);
}

function renderDeadlines(items: UpcomingDeadline[]): void {
  const list = document.getElementById("dashboard-deadlines-list")!;
  const empty = document.getElementById("dashboard-deadlines-empty")!;
  const cal = document.getElementById("dashboard-deadlines-calendar");

  // Per-widget settings: truncate by count + filter by horizon. Backend
  // returns 40 rows / 60d; the widget settings narrow it. Defaults match
  // the catalog (10 rows, 30 days).
  const s = settingsFor("upcoming-deadlines");
  items = filterByHorizonDays(items, s.horizon_days ?? 30, (d) => d.due_date);
  items = items.slice(0, s.count ?? 10);

  const view = viewFor("upcoming-deadlines");
  if (view === "calendar" && cal) {
    list.innerHTML = "";
    list.style.display = "none";
    if (!items.length) {
      cal.style.display = "none";
      empty.style.display = "block";
      return;
    }
    empty.style.display = "none";
    cal.style.display = "";
    cal.innerHTML = renderMiniCalendar(items.map((d) => ({
      date: d.due_date,
      label: d.title,
      href: `/projects/${d.project_id}/deadlines`,
      urgency: d.urgency,
    })));
    return;
  }
  if (cal) cal.style.display = "none";

  if (!items.length) {
    list.innerHTML = "";
    list.style.display = "none";
    empty.style.display = "block";
    return;
  }
  list.style.display = "";
  empty.style.display = "none";
  list.innerHTML = items.map((d) => {
    const urgencyClass = `dashboard-urgency-${d.urgency}`;
    const urgencyLabel = tDyn(`dashboard.urgency.${d.urgency}`);
    return `<li class="dashboard-list-item">
      <a href="/projects/${esc(d.project_id)}/deadlines" class="dashboard-list-link">
        <div class="dashboard-list-main">
          <span class="dashboard-list-title">${esc(d.title)}</span>
          <span class="dashboard-list-ref" title="${escAttr(`${d.project_reference} · ${d.project_title}`)}">${esc(d.project_reference)} &middot; ${esc(d.project_title)}</span>
        </div>
        <div class="dashboard-list-meta">
          <span class="dashboard-urgency-badge ${urgencyClass}" title="${escAttr(urgencyLabel)}">${esc(formatRelative(d.due_date))}</span>
        </div>
      </a>
    </li>`;
  }).join("");
}

function renderAppointments(items: UpcomingAppointment[]): void {
  const list = document.getElementById("dashboard-appointments-list")!;
  const empty = document.getElementById("dashboard-appointments-empty")!;
  const cal = document.getElementById("dashboard-appointments-calendar");

  const s = settingsFor("upcoming-appointments");
  items = filterByHorizonDays(items, s.horizon_days ?? 30, (a) => a.start_at);
  items = items.slice(0, s.count ?? 10);

  const view = viewFor("upcoming-appointments");
  if (view === "calendar" && cal) {
    list.innerHTML = "";
    list.style.display = "none";
    if (!items.length) {
      cal.style.display = "none";
      empty.style.display = "block";
      return;
    }
    empty.style.display = "none";
    cal.style.display = "";
    cal.innerHTML = renderMiniCalendar(items.map((a) => ({
      // start_at is RFC 3339 — take the date prefix for grouping.
      date: a.start_at.slice(0, 10),
      label: a.title,
      href: a.project_id ? `/projects/${a.project_id}/appointments` : "#",
      urgency: undefined,
    })));
    return;
  }
  if (cal) cal.style.display = "none";

  if (!items.length) {
    list.innerHTML = "";
    list.style.display = "none";
    empty.style.display = "block";
    return;
  }
  list.style.display = "";
  empty.style.display = "none";
  list.innerHTML = items.map((a) => {
    const dot = a.type
      ? `<span class="dashboard-termin-dot dashboard-termin-${esc(a.type)}" aria-hidden="true"></span>`
      : `<span class="dashboard-termin-dot" aria-hidden="true"></span>`;
    const href = a.project_id ? `/projects/${esc(a.project_id)}/appointments` : "#";
    const tag = a.project_id ? "a" : "div";
    const projectLine = a.project_reference && a.project_title
      ? `<span class="dashboard-list-ref" title="${escAttr(`${a.project_reference} · ${a.project_title}`)}">${esc(a.project_reference)} &middot; ${esc(a.project_title)}</span>`
      : "";
    return `<li class="dashboard-list-item">
      <${tag} href="${href}" class="dashboard-list-link">
        <div class="dashboard-list-main">
          <span class="dashboard-list-title">${dot}${esc(a.title)}</span>
          ${projectLine}
        </div>
        <div class="dashboard-list-meta">
          <span class="dashboard-appt-time">${esc(formatDateTime(a.start_at))}</span>
        </div>
      </${tag}>
    </li>`;
  }).join("");
}

function renderActivity(items: ActivityEntry[]): void {
  const list = document.getElementById("dashboard-activity-list")!;
  const empty = document.getElementById("dashboard-activity-empty")!;

  const s = settingsFor("recent-activity");
  items = items.slice(0, s.count ?? 10);

  // View toggle: "compact" collapses each event to a single line; the
  // default "full" view keeps the two-line breakdown with actor + detail.
  const compact = viewFor("recent-activity") === "compact";
  list.classList.toggle("dashboard-activity-list--compact", compact);

  if (!items.length) {
    list.innerHTML = "";
    list.style.display = "none";
    empty.style.display = "block";
    return;
  }
  list.style.display = "";
  empty.style.display = "none";
  list.innerHTML = items.map((e) => {
    const actor = e.actor_name || e.actor_email || t("dashboard.activity.system");
    const shortKey = e.action ? `dashboard.action.short.${e.action}` : "";
    const translated = shortKey ? tDyn(shortKey) : "";
    const hasI18n = translated !== "" && translated !== shortKey;
    const shortAction = hasI18n
      ? translated
      : (e.action || t("dashboard.activity.event"));
    // Localize the muted detail line so it speaks DE in DE and EN in EN —
    // historical rows carry English nouns inside DE narrative ("Deadline „ok"
    // geändert", "Note zu deadline hinzugefügt"); translateEvent parses both
    // legacy and new (value-only) shapes.
    const stored = e.description ?? (hasI18n ? "" : e.details);
    const { description: detail } = translateEvent(e.action, "", stored);
    // For checklist_* events with a known instance_id, deep-link the project
    // ref straight to the instance — saves a click vs. landing on the project.
    // Falls back to /projects/{id} for any other event or when the instance
    // ID is missing (older rows pre-metadata, or checklist_deleted).
    const ref = activityHref(e);
    return `<li class="dashboard-activity-item">
      <span class="dashboard-activity-time">${esc(formatDateTime(e.timestamp))}</span>
      <div class="dashboard-activity-body">
        <p class="dashboard-activity-summary"><strong>${esc(actor)}</strong> ${esc(shortAction)}</p>
        <p class="dashboard-activity-detail">
          <a href="${escAttr(ref)}" class="dashboard-activity-project">${esc(e.project_reference)}</a>${detail ? ` <span>${esc(detail)}</span>` : ""}
        </p>
      </div>
    </li>`;
  }).join("");
  // Row-level click handler: clicking anywhere on the row navigates to the
  // same target as the inner .dashboard-activity-project link. Inner <a>/
  // <button> clicks bubble through unchanged (Cmd-click → new tab still
  // works) and text remains selectable — same pattern as .entity-table rows
  // (t-098/099) and the project Verlauf cards (t-paliad-103).
  list.querySelectorAll<HTMLLIElement>(".dashboard-activity-item").forEach((row) => {
    const link = row.querySelector<HTMLAnchorElement>(".dashboard-activity-project");
    if (!link) return;
    row.addEventListener("click", (e) => {
      const target = e.target as HTMLElement;
      if (target.closest("a") || target.closest("button")) return;
      window.location.href = link.href;
    });
  });
}

// Resolve an activity row to the most-specific deep-link target. Mirrors the
// rules in projects-detail.ts:eventDetailHref so the activity feed and the
// project Verlauf agree on where each event family points. Falls back to the
// owning project page when no metadata is wired (older rows or _deleted/
// deadlines_imported events). Wired families: checklist_*, deadline_*,
// appointment_*, note_created — see t-paliad-097/102.
function activityHref(e: ActivityEntry): string {
  const action = e.action ?? "";
  const meta = (e.metadata ?? null) as Record<string, unknown> | null;
  if (meta) {
    if (action.startsWith("checklist_") && action !== "checklist_deleted") {
      const id = meta["checklist_instance_id"];
      if (typeof id === "string" && id) return `/checklists/instances/${id}`;
    }
    if (
      action.startsWith("deadline_") &&
      action !== "deadline_deleted" &&
      action !== "deadlines_imported"
    ) {
      const id = meta["deadline_id"];
      if (typeof id === "string" && id) return `/deadlines/${id}`;
    }
    if (action.startsWith("appointment_") && action !== "appointment_deleted") {
      const id = meta["appointment_id"];
      if (typeof id === "string" && id) return `/appointments/${id}`;
    }
    if (action === "note_created") {
      const apptID = meta["appointment_id"];
      if (typeof apptID === "string" && apptID) return `/appointments/${apptID}`;
      const deadlineID = meta["deadline_id"];
      if (typeof deadlineID === "string" && deadlineID) return `/deadlines/${deadlineID}`;
    }
  }
  return `/projects/${e.project_id}`;
}

// Render the inline Agenda section. Items are fetched once on mount via
// loadAgenda(); subsequent re-renders (lang change, dashboard poll) reuse
// the cached array. The dashboard inline agenda is read-only — no chip
// filters, default 30-day window — see CollapsibleSection in
// dashboard.tsx for the surrounding shell.
function renderAgenda(): void {
  const timeline = document.getElementById("dashboard-agenda-timeline");
  const list = document.getElementById("dashboard-agenda-list");
  const empty = document.getElementById("dashboard-agenda-empty");
  if (!timeline || !list || !empty) return;
  const view = viewFor("inline-agenda");
  const useList = view === "list";
  if (agendaItems === null) {
    timeline.innerHTML = "";
    timeline.style.display = "none";
    list.innerHTML = "";
    list.style.display = "none";
    empty.style.display = "none";
    return;
  }
  if (!agendaItems.length) {
    timeline.innerHTML = "";
    timeline.style.display = "none";
    list.innerHTML = "";
    list.style.display = "none";
    empty.style.display = "block";
    return;
  }
  empty.style.display = "none";
  if (useList) {
    timeline.style.display = "none";
    list.style.display = "";
    list.innerHTML = agendaItems.map((it: AgendaItem) => {
      const href = it.type === "deadline"
        ? `/deadlines/${esc(it.id)}`
        : `/appointments/${esc(it.id)}`;
      const when = it.type === "deadline"
        ? formatRelative(it.due_date || it.date.slice(0, 10))
        : formatDateTime(it.date);
      return `<li class="dashboard-list-item">
        <a href="${escAttr(href)}" class="dashboard-list-link">
          <div class="dashboard-list-main">
            <span class="dashboard-list-title">${esc(it.title)}</span>
            ${it.project_reference ? `<span class="dashboard-list-ref">${esc(it.project_reference)}</span>` : ""}
          </div>
          <div class="dashboard-list-meta">
            <span class="dashboard-appt-time">${esc(when)}</span>
          </div>
        </a>
      </li>`;
    }).join("");
  } else {
    list.style.display = "none";
    timeline.style.display = "";
    timeline.innerHTML = renderAgendaTimeline(agendaItems);
  }
}

// Mini-calendar — a 1-month grid of YYYY-MM-DD cells with item dots.
// Designed for the upcoming-deadlines / upcoming-appointments calendar
// view: shows the current month plus rolls into the next when the
// horizon stretches past month-end. Compact (no week-of-year, no time
// of day) — for full calendar UX users still click "Vollständige
// Agenda öffnen".
interface CalendarItem {
  date: string;             // YYYY-MM-DD
  label: string;
  href: string;
  urgency?: "overdue" | "today" | "urgent" | "soon";
}

function renderMiniCalendar(items: CalendarItem[]): string {
  if (!items.length) return "";
  const today = startOfToday();
  // Group by date; sort so dots within a day order by appearance.
  const byDate = new Map<string, CalendarItem[]>();
  for (const it of items) {
    if (!it.date) continue;
    (byDate.get(it.date) ?? byDate.set(it.date, []).get(it.date)!).push(it);
  }
  // Range = today's month start to last item's month end (cap +2 months).
  const startMonth = new Date(today.getFullYear(), today.getMonth(), 1);
  let lastDate = today;
  for (const it of items) {
    const d = parseDateOnly(it.date);
    if (d && d.getTime() > lastDate.getTime()) lastDate = d;
  }
  const maxMonth = new Date(today.getFullYear(), today.getMonth() + 2, 1);
  if (lastDate.getTime() > maxMonth.getTime()) lastDate = maxMonth;
  const months: { year: number; month: number }[] = [];
  for (
    let m = new Date(startMonth);
    m.getTime() <= new Date(lastDate.getFullYear(), lastDate.getMonth(), 1).getTime();
    m = new Date(m.getFullYear(), m.getMonth() + 1, 1)
  ) {
    months.push({ year: m.getFullYear(), month: m.getMonth() });
  }
  const dayHeaderKeys = ["mo", "di", "mi", "do", "fr", "sa", "so"];
  const dayHeaders = dayHeaderKeys.map((k) => t(`agenda.day.${k}`) || k);
  return months.map((m) => renderMonthGrid(m.year, m.month, byDate, today, dayHeaders)).join("");
}

function renderMonthGrid(
  year: number,
  month: number,
  byDate: Map<string, CalendarItem[]>,
  today: Date,
  dayHeaders: string[],
): string {
  const first = new Date(year, month, 1);
  // ISO week starts Mon (1) — convert JS Sun=0 / Mon=1 to Mon=0.
  const offset = (first.getDay() + 6) % 7;
  const daysInMonth = new Date(year, month + 1, 0).getDate();
  const cells: string[] = [];
  for (let i = 0; i < offset; i++) cells.push(`<div class="dashboard-cal-cell dashboard-cal-cell--blank"></div>`);
  for (let d = 1; d <= daysInMonth; d++) {
    const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
    const dayItems = byDate.get(dateStr) ?? [];
    const isToday = year === today.getFullYear() && month === today.getMonth() && d === today.getDate();
    const cls = ["dashboard-cal-cell"];
    if (isToday) cls.push("dashboard-cal-cell--today");
    if (dayItems.length) cls.push("dashboard-cal-cell--has-items");
    const dots = dayItems.slice(0, 3).map((it) => {
      const urgency = it.urgency ? `dashboard-cal-dot--${it.urgency}` : "";
      return `<a class="dashboard-cal-dot ${urgency}" href="${escAttr(it.href)}" title="${escAttr(it.label)}"></a>`;
    }).join("");
    const overflow = dayItems.length > 3 ? `<span class="dashboard-cal-more">+${dayItems.length - 3}</span>` : "";
    cells.push(`<div class="${cls.join(" ")}">
      <span class="dashboard-cal-num">${d}</span>
      <span class="dashboard-cal-dots">${dots}${overflow}</span>
    </div>`);
  }
  const monthLabel = first.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", { year: "numeric", month: "long" });
  return `<div class="dashboard-cal-month">
    <h4 class="dashboard-cal-title">${esc(monthLabel)}</h4>
    <div class="dashboard-cal-grid">
      ${dayHeaders.map((d) => `<div class="dashboard-cal-day">${esc(d)}</div>`).join("")}
      ${cells.join("")}
    </div>
  </div>`;
}

function parseDateOnly(s: string): Date | null {
  if (!s) return null;
  const d = new Date(s.length === 10 ? s + "T00:00:00" : s);
  if (isNaN(d.getTime())) return null;
  d.setHours(0, 0, 0, 0);
  return d;
}

async function loadAgenda(): Promise<void> {
  const s = settingsFor("inline-agenda");
  const horizon = s.horizon_days ?? AGENDA_LOOKAHEAD_DAYS;
  const from = toAgendaDate(startOfToday());
  const to = toAgendaDate(addDays(startOfToday(), horizon - 1));
  try {
    const resp = await fetch(`/api/agenda?from=${from}&to=${to}&types=deadlines,appointments`);
    if (!resp.ok) {
      // Fail silently — the rest of the dashboard still loads. The
      // inline agenda is best-effort: a 503 (DB-less knowledge-platform
      // deploy) or 401 (session timed out, should be caught by the
      // page-level redirect) just leaves the section empty.
      agendaItems = [];
      renderAgenda();
      return;
    }
    agendaItems = (await resp.json()) as AgendaItem[];
    renderAgenda();
  } catch {
    agendaItems = [];
    renderAgenda();
  }
}

function startOfToday(): Date {
  const d = new Date();
  d.setHours(0, 0, 0, 0);
  return d;
}

function addDays(d: Date, days: number): Date {
  const r = new Date(d);
  r.setDate(r.getDate() + days);
  return r;
}

function toAgendaDate(d: Date): string {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

// Wire collapsible-section toggles. Each .dashboard-section carries a
// data-collapse-key and the SSR markup renders aria-expanded="true" so
// unstyled fallback shows everything; here we restore persisted state and
// attach click handlers. Persistence is per-section via localStorage —
// keys live under paliad:dashboard:collapse:<section> per the brief.
function initCollapsibleSections(): void {
  const sections = document.querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]");
  sections.forEach((section) => {
    const key = section.dataset.collapseKey || "";
    if (!key) return;
    const stored = localStorage.getItem(COLLAPSE_KEY_PREFIX + key);
    const collapsed = stored === "true";
    applyCollapseState(section, collapsed);

    const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
    if (!toggle) return;
    toggle.addEventListener("click", () => {
      const nowExpanded = section.getAttribute("aria-expanded") === "true";
      const nextCollapsed = nowExpanded; // expanded → collapsing
      applyCollapseState(section, nextCollapsed);
      try {
        localStorage.setItem(COLLAPSE_KEY_PREFIX + key, String(nextCollapsed));
      } catch {
        // localStorage may be full or disabled (Safari private mode);
        // collapse still works for the current page life. Silent.
      }
    });
  });
  // Re-localise the toggle aria-labels on language switch so screen
  // readers always read the current language. The visible heading text
  // is handled by the i18n applyTranslations pass already.
  syncCollapseAriaLabels();
}

function applyCollapseState(section: HTMLElement, collapsed: boolean): void {
  section.setAttribute("aria-expanded", String(!collapsed));
  const toggle = section.querySelector<HTMLButtonElement>(".dashboard-section-toggle");
  if (toggle) {
    toggle.setAttribute("aria-expanded", String(!collapsed));
    toggle.setAttribute(
      "aria-label",
      collapsed ? t("dashboard.section.expand") : t("dashboard.section.collapse"),
    );
  }
}

function syncCollapseAriaLabels(): void {
  document
    .querySelectorAll<HTMLElement>(".dashboard-section[data-collapse-key]")
    .forEach((section) => {
      const collapsed = section.getAttribute("aria-expanded") !== "true";
      applyCollapseState(section, collapsed);
    });
}

function renderInbox(s: InboxSummary): void {
  const summary = document.getElementById("dashboard-inbox-summary");
  const list = document.getElementById("dashboard-inbox-list");
  const empty = document.getElementById("dashboard-inbox-empty");
  if (!summary || !list || !empty) return;

  const settings = settingsFor("inbox-approvals");
  const cap = settings.count ?? 3;
  const top = s.top.slice(0, cap);

  if (s.pending_count === 0) {
    summary.style.display = "none";
    list.innerHTML = "";
    list.style.display = "none";
    empty.style.display = "block";
    return;
  }
  empty.style.display = "none";
  summary.style.display = "block";
  summary.textContent = getLang() === "de"
    ? `${s.pending_count} offene Freigaben warten auf dich.`
    : `${s.pending_count} open approvals are waiting for you.`;
  list.style.display = "";
  list.innerHTML = top.map((e) => {
    const entityLabel = e.entity_type === "deadline"
      ? tDyn("dashboard.inbox.entity.deadline")
      : (e.entity_type === "appointment"
        ? tDyn("dashboard.inbox.entity.appointment")
        : e.entity_type);
    const title = e.entity_title || entityLabel;
    return `<li class="dashboard-list-item">
      <a href="/inbox" class="dashboard-list-link">
        <div class="dashboard-list-main">
          <span class="dashboard-list-title">${esc(title)}</span>
          <span class="dashboard-list-ref" title="${escAttr(`${e.project_title} · ${e.requester_name}`)}">${esc(e.project_title)} &middot; ${esc(e.requester_name)}</span>
        </div>
        <div class="dashboard-list-meta">
          <span class="dashboard-appt-time">${esc(formatDateTime(e.requested_at))}</span>
        </div>
      </a>
    </li>`;
  }).join("");
}

// --- Edit mode (t-paliad-219 Slice B) -------------------------------------
//
// The Anpassen toggle in the dashboard header flips `editMode` and the
// `body.dashboard-editing` class. When on, every visible widget grows
// a chrome strip with drag handle / ↑ / ↓ / hide / gear; an edit
// footer below the activity widget surfaces the picker + reset. CSS
// hides the chrome when off so unstyled fallback (no JS) is fine.
//
// Chrome is rendered dynamically into a `.dashboard-widget__chrome`
// element appended to each [data-widget-key] element. Toggling edit
// mode rebinds handlers each time — the chrome is destroyed on exit
// so view mode has zero edit-related DOM cost.
//
// Persistence is autosave-on-every-change with a 400ms debounce. See
// scheduleSave() / flushSave().

function lookupCatalog(key: string): WidgetCatalogEntry | undefined {
  for (const def of currentCatalog) if (def.key === key) return def;
  return undefined;
}

function ensureLayout(): DashboardLayoutSpec {
  if (currentLayout) return currentLayout;
  // Fallback: derive a layout from the catalog so edit-mode mutations
  // have a coherent draft even when the server didn't inline one. The
  // next PUT seeds the user's row server-side.
  currentLayout = {
    v: 1,
    widgets: currentCatalog
      .filter((d) => d.default_visible)
      .map((d) => ({ key: d.key, visible: true, settings: defaultSettings(d) })),
  };
  return currentLayout;
}

function defaultSettings(def: WidgetCatalogEntry): { count?: number; horizon_days?: number } | undefined {
  const out: { count?: number; horizon_days?: number } = {};
  if (typeof def.default_count === "number") out.count = def.default_count;
  if (typeof def.default_horizon_days === "number") out.horizon_days = def.default_horizon_days;
  return Object.keys(out).length ? out : undefined;
}

function snapshotLayout(spec: DashboardLayoutSpec): DashboardLayoutSpec {
  return JSON.parse(JSON.stringify(spec));
}

function widgetLabel(key: string): string {
  const def = lookupCatalog(key);
  if (!def) return key;
  return getLang() === "de" ? def.title_de : def.title_en;
}

function widgetDescription(key: string): string {
  const def = lookupCatalog(key);
  if (!def) return "";
  return getLang() === "de" ? def.description_de : def.description_en;
}

function initEditToggle(): void {
  const btn = document.getElementById("dashboard-edit-toggle") as HTMLButtonElement | null;
  if (!btn) return;
  btn.addEventListener("click", () => setEditMode(!editMode));
  syncEditToggleLabel();
}

function setEditMode(on: boolean): void {
  editMode = on;
  document.body.classList.toggle("dashboard-editing", on);
  const btn = document.getElementById("dashboard-edit-toggle") as HTMLButtonElement | null;
  if (btn) btn.setAttribute("aria-pressed", String(on));
  syncEditToggleLabel();
  rebuildEditChrome();
  // Close any open gear popover when leaving edit mode.
  if (!on) closeGearPopover();
}

function syncEditToggleLabel(): void {
  const btn = document.getElementById("dashboard-edit-toggle") as HTMLButtonElement | null;
  if (!btn) return;
  btn.textContent = editMode ? t("dashboard.edit.exit") : t("dashboard.edit.toggle");
}

// rebuildEditChrome removes any existing chrome and, when editMode is
// on, paints fresh chrome onto every [data-widget-key] element + binds
// the handlers. The chrome is dynamic so view-mode DOM stays lean.
function rebuildEditChrome(): void {
  document.querySelectorAll<HTMLElement>(".dashboard-widget__chrome").forEach((el) => el.remove());
  document.querySelectorAll<HTMLElement>(".dashboard-widget__resize").forEach((el) => el.remove());
  document.querySelectorAll<HTMLElement>("[data-widget-key]").forEach((el) => {
    el.removeAttribute("draggable");
    el.classList.remove("dashboard-widget");
    el.classList.remove("dashboard-widget--hidden");
    el.classList.remove("dashboard-widget--dragover");
    el.classList.remove("dashboard-widget--resizing");
  });

  if (!editMode) return;
  const layout = ensureLayout();

  document.querySelectorAll<HTMLElement>("[data-widget-key]").forEach((el) => {
    const key = el.dataset.widgetKey || "";
    if (!key) return;
    const entry = layout.widgets.find((w) => w.key === key);
    const visible = entry ? entry.visible : true;
    el.classList.add("dashboard-widget");
    if (!visible) {
      el.classList.add("dashboard-widget--hidden");
      // Hidden widgets are display:none in view mode (applyLayout);
      // in edit mode we want them visible-but-dimmed so the user can
      // un-hide them inline.
      el.style.display = "";
    }
    const def = lookupCatalog(key);
    const chrome = buildChrome(key, visible, def);
    el.insertBefore(chrome, el.firstChild);
    el.setAttribute("draggable", "true");
    bindDnDHandlers(el);
    if (visible) {
      const handle = buildResizeHandle(key, def);
      el.appendChild(handle);
    }
  });
}

function buildChrome(key: string, visible: boolean, def: WidgetCatalogEntry | undefined): HTMLElement {
  const wrap = document.createElement("div");
  wrap.className = "dashboard-widget__chrome";

  const handle = document.createElement("span");
  handle.className = "dashboard-widget__handle";
  handle.setAttribute("aria-hidden", "true");
  handle.title = t("dashboard.edit.drag");
  handle.textContent = "⠇⠇"; // ⠇⠇ a tighter drag-glyph than ⋮⋮

  const label = document.createElement("span");
  label.className = "dashboard-widget__label";
  label.textContent = widgetLabel(key);

  const actions = document.createElement("span");
  actions.className = "dashboard-widget__actions";

  const upBtn = chromeButton("↑", t("dashboard.edit.move_up") + ": " + widgetLabel(key), () => moveWidget(key, -1));
  const downBtn = chromeButton("↓", t("dashboard.edit.move_down") + ": " + widgetLabel(key), () => moveWidget(key, +1));
  const hideBtn = chromeButton(visible ? "×" : "+", t(visible ? "dashboard.edit.hide" : "dashboard.picker.status.hidden") + ": " + widgetLabel(key), () => toggleHidden(key));
  hideBtn.classList.add(visible ? "dashboard-widget__hide" : "dashboard-widget__show");

  actions.append(upBtn, downBtn, hideBtn);

  // Show the gear when ANY knob exists: count, horizon, view, or
  // size/position (every widget has a size, so this is effectively
  // every widget in edit mode).
  const hasSchemaKnobs = !!(def?.settings && (
    def.settings.count_options?.length ||
    def.settings.horizon_options?.length ||
    def.settings.views?.length ||
    def.settings.count_max ||
    def.settings.horizon_max
  ));
  if (def && (hasSchemaKnobs || true)) {
    const gearBtn = chromeButton("⚙", t("dashboard.edit.settings") + ": " + widgetLabel(key), (ev) => openGearPopover(key, ev.currentTarget as HTMLElement));
    gearBtn.classList.add("dashboard-widget__gear");
    actions.appendChild(gearBtn);
  }

  wrap.append(handle, label, actions);
  return wrap;
}

function chromeButton(glyph: string, label: string, onClick: (ev: MouseEvent) => void): HTMLButtonElement {
  const b = document.createElement("button");
  b.type = "button";
  b.className = "dashboard-widget__btn";
  b.setAttribute("aria-label", label);
  b.title = label;
  b.textContent = glyph;
  b.addEventListener("click", (ev) => {
    ev.preventDefault();
    ev.stopPropagation();
    onClick(ev);
  });
  return b;
}

// --- Layout mutations ---------------------------------------------------

function moveWidget(key: string, delta: -1 | 1): void {
  const layout = ensureLayout();
  const idx = layout.widgets.findIndex((w) => w.key === key);
  if (idx < 0) return;
  const target = idx + delta;
  if (target < 0 || target >= layout.widgets.length) return;
  const tmp = layout.widgets[idx];
  layout.widgets[idx] = layout.widgets[target];
  layout.widgets[target] = tmp;
  afterLayoutMutation();
}

function toggleHidden(key: string): void {
  const layout = ensureLayout();
  const entry = layout.widgets.find((w) => w.key === key);
  if (entry) {
    entry.visible = !entry.visible;
  } else {
    // Widget not yet in the layout — append visible (re-add path).
    const def = lookupCatalog(key);
    if (!def) return;
    layout.widgets.push({ key, visible: true, settings: defaultSettings(def) });
  }
  afterLayoutMutation();
}

// reorderViaDnd handles a drop of srcKey onto destKey. Strategy:
// swap the (x, y) coordinates of the two widgets so the dropped widget
// takes destKey's slot and destKey moves to where src came from. This
// keeps widget sizes intact (no implicit resize on drag) and works
// across rows because positions are explicit grid coords, not list
// indices. Falls back to a simple array-order swap when either widget
// lacks resolved coords (defensive — placements should always exist).
function reorderViaDnd(srcKey: string, destKey: string): void {
  if (srcKey === destKey) return;
  const layout = ensureLayout();
  const src = layout.widgets.find((w) => w.key === srcKey);
  const dest = layout.widgets.find((w) => w.key === destKey);
  if (!src || !dest) return;

  const placements = computePlacements(layout.widgets);
  const sp = placements.get(srcKey);
  const dp = placements.get(destKey);
  if (sp && dp) {
    src.x = dp.x;
    src.y = dp.y;
    dest.x = sp.x;
    dest.y = sp.y;
    // Sort widgets by (y, x) so the persisted array order tracks the
    // visual order — keeps the picker + auto-flow fallback in sync.
    layout.widgets.sort(compareByPlacement);
  } else {
    // Defensive fallback: array-order swap.
    const srcI = layout.widgets.indexOf(src);
    const destI = layout.widgets.indexOf(dest);
    layout.widgets[srcI] = dest;
    layout.widgets[destI] = src;
  }
  afterLayoutMutation();
}

function compareByPlacement(a: DashboardWidgetRef, b: DashboardWidgetRef): number {
  const ay = a.y ?? 0, by = b.y ?? 0;
  if (ay !== by) return ay - by;
  return (a.x ?? 0) - (b.x ?? 0);
}

// resizeWidget commits a new (w, h) to a widget. Sizes are clamped
// against the catalog's min/max so we never persist an out-of-schema
// size — the validator would reject it on PUT and the user would lose
// the gesture.
function resizeWidget(key: string, newW: number, newH: number): void {
  const layout = ensureLayout();
  const entry = layout.widgets.find((w) => w.key === key);
  if (!entry) return;
  const def = lookupCatalog(key);
  entry.w = clampW(newW, def);
  entry.h = clampH(newH, def);
  // Re-pack: if the resize pushes x+w past the right edge of the grid,
  // shove the widget left so the layout stays inside [0, GRID_COLUMNS).
  if (typeof entry.x === "number" && entry.x + entry.w > GRID_COLUMNS) {
    entry.x = Math.max(0, GRID_COLUMNS - entry.w);
  }
  afterLayoutMutation();
}

// moveWidgetToPosition explicitly sets a widget's (x, y). Used by the
// gear popover's position spinners.
function moveWidgetToPosition(key: string, x: number, y: number): void {
  const layout = ensureLayout();
  const entry = layout.widgets.find((w) => w.key === key);
  if (!entry) return;
  const def = lookupCatalog(key);
  const w = clampW(entry.w ?? def?.default_w ?? GRID_COLUMNS, def);
  entry.x = Math.max(0, Math.min(GRID_COLUMNS - w, Math.round(x)));
  entry.y = Math.max(0, Math.round(y));
  layout.widgets.sort(compareByPlacement);
  afterLayoutMutation();
}

function updateWidgetSettings(key: string, patch: { count?: number; horizon_days?: number }): void {
  const layout = ensureLayout();
  const entry = layout.widgets.find((w) => w.key === key);
  if (!entry) return;
  entry.settings = { ...(entry.settings ?? {}), ...patch };
  afterLayoutMutation();
}

// afterLayoutMutation is the single funnel post-mutation:
//   1. materialise positions so the spec is self-describing (no mixed
//      explicit / auto-flow that produces unpredictable renders),
//   2. re-render the widget stack so per-widget settings + ordering
//      take effect immediately (count truncation, horizon filtering),
//   3. rebuild edit chrome so the up/down/hide affordances reflect
//      the new state,
//   4. schedule a debounced PUT.
function afterLayoutMutation(): void {
  materializePositions();
  if (data) render();
  if (editMode) rebuildEditChrome();
  scheduleSave();
}

// materializePositions snapshots computed placements onto the layout
// spec so every visible widget has explicit x/y/w/h on the next render.
// Without this, partial position assignments (e.g. a drag set positions
// on src + dest while other widgets still rely on auto-flow) lead to
// layouts that depend on iteration order — fragile and surprising.
// After materialisation the spec round-trips through the wire without
// losing layout information.
function materializePositions(): void {
  const layout = currentLayout;
  if (!layout) return;
  const placements = computePlacements(layout.widgets);
  for (const w of layout.widgets) {
    const p = placements.get(w.key);
    if (!p) continue;
    w.x = p.x;
    w.y = p.y;
    w.w = p.w;
    w.h = p.h;
  }
  layout.widgets.sort(compareByPlacement);
}

// --- Autosave + reset ---------------------------------------------------

function scheduleSave(): void {
  if (!currentLayout) return;
  // Take a snapshot on the first dirty edit in a window — rollback
  // target for failed saves.
  if (saveSnapshot === null) saveSnapshot = snapshotLayout(currentLayout);
  if (saveTimer !== null) window.clearTimeout(saveTimer);
  saveTimer = window.setTimeout(() => {
    saveTimer = null;
    void flushSave();
  }, SAVE_DEBOUNCE_MS);
}

async function flushSave(): Promise<void> {
  if (!currentLayout) return;
  const payload = snapshotLayout(currentLayout);
  const rollback = saveSnapshot;
  saveSnapshot = null;
  try {
    const resp = await fetch("/api/me/dashboard-layout", {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    if (!resp.ok) throw new Error(`PUT /api/me/dashboard-layout: ${resp.status}`);
    const fresh = (await resp.json()) as DashboardLayoutSpec;
    currentLayout = fresh;
    if (data) render();
    if (editMode) rebuildEditChrome();
    flashToast(t("dashboard.edit.saved"), "ok");
  } catch (_e) {
    if (rollback) currentLayout = rollback;
    if (data) render();
    if (editMode) rebuildEditChrome();
    flashToast(t("dashboard.edit.save_failed"), "err");
  }
}

function flashToast(message: string, kind: "ok" | "err"): void {
  const el = document.getElementById("dashboard-save-toast");
  if (!el) return;
  el.textContent = message;
  el.classList.remove("dashboard-save-toast--ok", "dashboard-save-toast--err", "dashboard-save-toast--show");
  el.classList.add(kind === "ok" ? "dashboard-save-toast--ok" : "dashboard-save-toast--err");
  // Force reflow before adding --show so the CSS transition replays
  // when toasts fire back-to-back.
  void el.offsetWidth;
  el.classList.add("dashboard-save-toast--show");
  window.setTimeout(() => el.classList.remove("dashboard-save-toast--show"), 1500);
}

function initEditFooter(): void {
  const add = document.getElementById("dashboard-edit-add") as HTMLButtonElement | null;
  const reset = document.getElementById("dashboard-edit-reset") as HTMLButtonElement | null;
  const promote = document.getElementById("dashboard-edit-promote") as HTMLButtonElement | null;
  add?.addEventListener("click", openPickerModal);
  reset?.addEventListener("click", async () => {
    if (!window.confirm(t("dashboard.edit.reset_confirm"))) return;
    try {
      const resp = await fetch("/api/me/dashboard-layout/reset", { method: "POST" });
      if (!resp.ok) throw new Error(`POST reset: ${resp.status}`);
      currentLayout = (await resp.json()) as DashboardLayoutSpec;
      if (data) render();
      if (editMode) rebuildEditChrome();
      flashToast(t("dashboard.edit.saved"), "ok");
    } catch (_e) {
      flashToast(t("dashboard.edit.save_failed"), "err");
    }
  });
  // Promote: admin-only convenience. POSTs to /api/me/dashboard-layout/
  // promote which reads the admin's own current layout and stashes it
  // into paliad.firm_dashboard_default. Subsequent user reseeds + resets
  // land on this layout. Server enforces admin gate; the button hides
  // for non-admins via syncPromoteButtonVisibility.
  promote?.addEventListener("click", async () => {
    if (!window.confirm(t("dashboard.edit.promote_confirm"))) return;
    try {
      const resp = await fetch("/api/me/dashboard-layout/promote", { method: "POST" });
      if (!resp.ok) throw new Error(`POST promote: ${resp.status}`);
      flashToast(t("dashboard.edit.promoted"), "ok");
    } catch (_e) {
      flashToast(t("dashboard.edit.save_failed"), "err");
    }
  });
}

// --- Picker modal -------------------------------------------------------

function openPickerModal(): void {
  const layout = ensureLayout();
  const body = document.createElement("div");
  body.className = "widget-picker";

  const list = document.createElement("ul");
  list.className = "widget-picker__list";
  body.appendChild(list);
  renderPickerList(list, layout);

  void openModal<void>({
    title: t("dashboard.picker.title"),
    body,
    primary: { label: t("dashboard.picker.close"), handler: (close) => close() },
    secondary: null,
    size: "md",
  });
}

function renderPickerList(list: HTMLUListElement, layout: DashboardLayoutSpec): void {
  list.innerHTML = "";
  if (!currentCatalog.length) {
    const empty = document.createElement("li");
    empty.className = "widget-picker__empty";
    empty.textContent = t("dashboard.picker.empty");
    list.appendChild(empty);
    return;
  }
  for (const def of currentCatalog) {
    const layoutEntry = layout.widgets.find((w) => w.key === def.key);
    const status: "active" | "hidden" | "absent" = !layoutEntry
      ? "absent"
      : layoutEntry.visible
        ? "active"
        : "hidden";
    const item = document.createElement("li");
    item.className = `widget-picker__item widget-picker__item--${status}`;

    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "widget-picker__btn";
    btn.disabled = status === "active";
    btn.addEventListener("click", () => {
      handlePickerAdd(def.key);
      renderPickerList(list, ensureLayout());
    });

    const title = document.createElement("span");
    title.className = "widget-picker__title";
    title.textContent = widgetLabel(def.key);

    const desc = document.createElement("span");
    desc.className = "widget-picker__desc";
    desc.textContent = widgetDescription(def.key);

    const pill = document.createElement("span");
    pill.className = `widget-picker__pill widget-picker__pill--${status}`;
    pill.textContent = t(
      status === "active"
        ? "dashboard.picker.status.active"
        : status === "hidden"
          ? "dashboard.picker.status.hidden"
          : "dashboard.picker.status.absent",
    );

    btn.append(title, desc, pill);
    item.appendChild(btn);
    list.appendChild(item);
  }
}

function handlePickerAdd(key: string): void {
  const layout = ensureLayout();
  const entry = layout.widgets.find((w) => w.key === key);
  if (!entry) {
    const def = lookupCatalog(key);
    if (!def) return;
    layout.widgets.push({ key, visible: true, settings: defaultSettings(def) });
  } else if (!entry.visible) {
    entry.visible = true;
  } else {
    return; // already active, no-op
  }
  afterLayoutMutation();
}

// --- Gear popover -------------------------------------------------------

let openGearAnchor: HTMLElement | null = null;
let openGearPopoverEl: HTMLElement | null = null;
let gearOutsideHandler: ((ev: MouseEvent) => void) | null = null;
let gearKeyHandler: ((ev: KeyboardEvent) => void) | null = null;

function openGearPopover(key: string, anchor: HTMLElement): void {
  // Toggle off if the same gear is clicked again.
  if (openGearAnchor === anchor) { closeGearPopover(); return; }
  closeGearPopover();
  const def = lookupCatalog(key);
  if (!def) return;
  const layout = ensureLayout();
  const entry = layout.widgets.find((w) => w.key === key);
  const settings = entry?.settings ?? {};
  const placement = computePlacements(layout.widgets).get(key);

  const pop = document.createElement("div");
  pop.className = "dashboard-widget__gear-popover";
  pop.setAttribute("role", "dialog");
  pop.setAttribute("aria-label", t("dashboard.edit.settings"));

  if (def.settings?.views?.length) {
    pop.appendChild(buildViewRow(key, def.settings.views, viewFor(key)));
  }
  if (def.settings?.count_options?.length || def.settings?.count_max) {
    pop.appendChild(buildCountRow(key, def, settings));
  }
  if (def.settings?.horizon_options?.length || def.settings?.horizon_max) {
    pop.appendChild(buildHorizonRow(key, def, settings));
  }
  // Position + size — always available now that every widget lives in
  // the grid. The X spinner is clamped to [0, 12-w]; W/H to the
  // widget's MinW/MaxW/MinH/MaxH.
  if (placement) {
    pop.appendChild(buildSizeRow(key, def, placement));
    pop.appendChild(buildPositionRow(key, def, placement));
  }

  // Position relative to the anchor button. Page coordinates so we
  // don't have to chase a scrolling parent. The widget element is the
  // positioning context.
  const widget = anchor.closest<HTMLElement>("[data-widget-key]") || document.body;
  widget.style.position = widget.style.position || "relative";
  widget.appendChild(pop);

  const rect = anchor.getBoundingClientRect();
  const widgetRect = widget.getBoundingClientRect();
  pop.style.position = "absolute";
  pop.style.top = `${rect.bottom - widgetRect.top + 6}px`;
  pop.style.right = `${widgetRect.right - rect.right}px`;
  pop.style.zIndex = "20";

  openGearAnchor = anchor;
  openGearPopoverEl = pop;

  gearOutsideHandler = (ev: MouseEvent) => {
    const target = ev.target as Node;
    if (pop.contains(target) || anchor.contains(target)) return;
    closeGearPopover();
  };
  gearKeyHandler = (ev: KeyboardEvent) => { if (ev.key === "Escape") closeGearPopover(); };
  // Defer attach so this click doesn't immediately close us.
  window.setTimeout(() => {
    document.addEventListener("mousedown", gearOutsideHandler!);
    document.addEventListener("keydown", gearKeyHandler!);
  }, 0);
}

function closeGearPopover(): void {
  if (openGearPopoverEl) openGearPopoverEl.remove();
  if (gearOutsideHandler) document.removeEventListener("mousedown", gearOutsideHandler);
  if (gearKeyHandler) document.removeEventListener("keydown", gearKeyHandler);
  openGearAnchor = null;
  openGearPopoverEl = null;
  gearOutsideHandler = null;
  gearKeyHandler = null;
}

function buildSettingRow(
  label: string,
  options: number[],
  allowsAll: boolean | undefined,
  current: number | undefined,
  onChange: (value: number) => void,
  formatLabel: (n: number) => string,
): HTMLElement {
  const row = document.createElement("label");
  row.className = "dashboard-widget__gear-row";

  const span = document.createElement("span");
  span.className = "dashboard-widget__gear-label";
  span.textContent = label;
  row.appendChild(span);

  const sel = document.createElement("select");
  sel.className = "dashboard-widget__gear-select";
  for (const opt of options) {
    const o = document.createElement("option");
    o.value = String(opt);
    o.textContent = formatLabel(opt);
    if (current === opt) o.selected = true;
    sel.appendChild(o);
  }
  if (allowsAll) {
    const o = document.createElement("option");
    o.value = "-1";
    o.textContent = t("dashboard.picker.status.active"); // best effort; "Alle" not in v1 keys
    if (current === -1) o.selected = true;
    sel.appendChild(o);
  }
  sel.addEventListener("change", () => onChange(parseInt(sel.value, 10)));
  row.appendChild(sel);
  return row;
}

// buildCountRow combines a preset dropdown (count_options) and an
// optional free-form numeric input (count_max). Either knob commits via
// updateWidgetSettings({count}); the schema validator accepts both.
function buildCountRow(key: string, def: WidgetCatalogEntry, settings: WidgetSettingsValue): HTMLElement {
  const row = document.createElement("div");
  row.className = "dashboard-widget__gear-row dashboard-widget__gear-row--combo";

  const span = document.createElement("span");
  span.className = "dashboard-widget__gear-label";
  span.textContent = t("dashboard.edit.setting.count");
  row.appendChild(span);

  const controls = document.createElement("span");
  controls.className = "dashboard-widget__gear-controls";

  if (def.settings?.count_options?.length) {
    const sel = document.createElement("select");
    sel.className = "dashboard-widget__gear-select";
    for (const opt of def.settings.count_options) {
      const o = document.createElement("option");
      o.value = String(opt);
      o.textContent = String(opt);
      if (settings.count === opt) o.selected = true;
      sel.appendChild(o);
    }
    if (def.settings.count_allows_all) {
      const o = document.createElement("option");
      o.value = "-1";
      o.textContent = t("dashboard.picker.status.active");
      if (settings.count === -1) o.selected = true;
      sel.appendChild(o);
    }
    sel.addEventListener("change", () => updateWidgetSettings(key, { count: parseInt(sel.value, 10) }));
    controls.appendChild(sel);
  }
  if (def.settings?.count_max) {
    const input = buildNumberInput(
      typeof settings.count === "number" && settings.count > 0 ? settings.count : (def.default_count ?? 1),
      1, def.settings.count_max,
      (v) => updateWidgetSettings(key, { count: v }),
    );
    input.title = t("dashboard.edit.setting.count.custom").replace("{n}", String(def.settings.count_max));
    controls.appendChild(input);
  }
  row.appendChild(controls);
  return row;
}

function buildHorizonRow(key: string, def: WidgetCatalogEntry, settings: WidgetSettingsValue): HTMLElement {
  const row = document.createElement("div");
  row.className = "dashboard-widget__gear-row dashboard-widget__gear-row--combo";

  const span = document.createElement("span");
  span.className = "dashboard-widget__gear-label";
  span.textContent = t("dashboard.edit.setting.horizon");
  row.appendChild(span);

  const controls = document.createElement("span");
  controls.className = "dashboard-widget__gear-controls";

  if (def.settings?.horizon_options?.length) {
    const sel = document.createElement("select");
    sel.className = "dashboard-widget__gear-select";
    for (const opt of def.settings.horizon_options) {
      const o = document.createElement("option");
      o.value = String(opt);
      o.textContent = t("dashboard.edit.setting.horizon.days").replace("{n}", String(opt));
      if (settings.horizon_days === opt) o.selected = true;
      sel.appendChild(o);
    }
    sel.addEventListener("change", () => updateWidgetSettings(key, { horizon_days: parseInt(sel.value, 10) }));
    controls.appendChild(sel);
  }
  if (def.settings?.horizon_max) {
    const input = buildNumberInput(
      typeof settings.horizon_days === "number" && settings.horizon_days > 0
        ? settings.horizon_days
        : (def.default_horizon_days ?? 1),
      1, def.settings.horizon_max,
      (v) => updateWidgetSettings(key, { horizon_days: v }),
    );
    input.title = t("dashboard.edit.setting.horizon.custom").replace("{n}", String(def.settings.horizon_max));
    controls.appendChild(input);
  }
  row.appendChild(controls);
  return row;
}

// buildViewRow renders the per-widget view picker as a segmented control
// (one button per view option). Cheaper to scan than a dropdown when
// most widgets have 2-3 views.
function buildViewRow(key: string, views: ViewOption[], current: string): HTMLElement {
  const row = document.createElement("div");
  row.className = "dashboard-widget__gear-row";
  const span = document.createElement("span");
  span.className = "dashboard-widget__gear-label";
  span.textContent = t("dashboard.edit.setting.view");
  row.appendChild(span);

  const group = document.createElement("span");
  group.className = "dashboard-widget__view-group";
  for (const v of views) {
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "dashboard-widget__view-btn";
    btn.dataset.viewId = v.id;
    btn.textContent = getLang() === "de" ? v.label_de : v.label_en;
    if (v.id === current) btn.classList.add("dashboard-widget__view-btn--active");
    btn.addEventListener("click", (ev) => {
      ev.preventDefault();
      ev.stopPropagation();
      updateWidgetSettings(key, { view: v.id });
    });
    group.appendChild(btn);
  }
  row.appendChild(group);
  return row;
}

function buildSizeRow(key: string, def: WidgetCatalogEntry, placement: PlacedRect): HTMLElement {
  const row = document.createElement("div");
  row.className = "dashboard-widget__gear-row dashboard-widget__gear-row--combo";
  const span = document.createElement("span");
  span.className = "dashboard-widget__gear-label";
  span.textContent = t("dashboard.edit.setting.size");
  row.appendChild(span);

  const controls = document.createElement("span");
  controls.className = "dashboard-widget__gear-controls";

  const minW = def.min_w ?? 1;
  const maxW = def.max_w ?? GRID_COLUMNS;
  const minH = def.min_h ?? 1;
  const maxH = def.max_h ?? MAX_ROW_SPAN;

  const wInput = buildNumberInput(placement.w, minW, maxW, (v) => {
    const layout = ensureLayout();
    const entry = layout.widgets.find((w) => w.key === key);
    if (!entry) return;
    resizeWidget(key, v, entry.h ?? placement.h);
  });
  wInput.title = "W";
  const wLabel = document.createElement("span");
  wLabel.className = "dashboard-widget__gear-mini";
  wLabel.textContent = "W";
  controls.appendChild(wLabel);
  controls.appendChild(wInput);

  const hInput = buildNumberInput(placement.h, minH, maxH, (v) => {
    const layout = ensureLayout();
    const entry = layout.widgets.find((w) => w.key === key);
    if (!entry) return;
    resizeWidget(key, entry.w ?? placement.w, v);
  });
  hInput.title = "H";
  const hLabel = document.createElement("span");
  hLabel.className = "dashboard-widget__gear-mini";
  hLabel.textContent = "H";
  controls.appendChild(hLabel);
  controls.appendChild(hInput);

  row.appendChild(controls);
  return row;
}

function buildPositionRow(key: string, def: WidgetCatalogEntry, placement: PlacedRect): HTMLElement {
  const row = document.createElement("div");
  row.className = "dashboard-widget__gear-row dashboard-widget__gear-row--combo";
  const span = document.createElement("span");
  span.className = "dashboard-widget__gear-label";
  span.textContent = t("dashboard.edit.setting.position");
  row.appendChild(span);

  const controls = document.createElement("span");
  controls.className = "dashboard-widget__gear-controls";

  const xInput = buildNumberInput(placement.x, 0, GRID_COLUMNS - 1, (v) => {
    moveWidgetToPosition(key, v, placement.y);
  });
  xInput.title = "X";
  const xLabel = document.createElement("span");
  xLabel.className = "dashboard-widget__gear-mini";
  xLabel.textContent = "X";
  controls.appendChild(xLabel);
  controls.appendChild(xInput);

  const yInput = buildNumberInput(placement.y, 0, 99, (v) => {
    moveWidgetToPosition(key, placement.x, v);
  });
  yInput.title = "Y";
  const yLabel = document.createElement("span");
  yLabel.className = "dashboard-widget__gear-mini";
  yLabel.textContent = "Y";
  controls.appendChild(yLabel);
  controls.appendChild(yInput);

  // Reference def to silence the unused-param lint — kept in the
  // signature so future schemas can clamp position via def.
  void def;

  row.appendChild(controls);
  return row;
}

function buildNumberInput(
  current: number,
  min: number,
  max: number,
  onCommit: (v: number) => void,
): HTMLInputElement {
  const input = document.createElement("input");
  input.type = "number";
  input.className = "dashboard-widget__gear-number";
  input.min = String(min);
  input.max = String(max);
  input.step = "1";
  input.value = String(current);
  // Commit on blur or Enter — avoid firing on every digit.
  const commit = () => {
    let v = parseInt(input.value, 10);
    if (!Number.isFinite(v)) v = current;
    v = Math.max(min, Math.min(max, v));
    input.value = String(v);
    if (v !== current) onCommit(v);
  };
  input.addEventListener("blur", commit);
  input.addEventListener("keydown", (ev) => {
    if (ev.key === "Enter") {
      ev.preventDefault();
      commit();
      input.blur();
    }
  });
  return input;
}

// --- Resize handles -----------------------------------------------------
//
// Each visible widget gets a bottom-right resize affordance in edit mode.
// Pointerdown captures the pointer; pointermove computes the new W/H by
// converting the pointer delta into grid cells (cellW = grid_track_width
// + gap, cellH = rowH + gap, both read live from the grid container) and
// snapping; pointerup commits the size via resizeWidget. We update the
// live element's inline grid-column/row on every move so the user sees
// the resize in real time without a full applyLayout pass.

function buildResizeHandle(key: string, def: WidgetCatalogEntry | undefined): HTMLElement {
  const h = document.createElement("span");
  h.className = "dashboard-widget__resize";
  h.setAttribute("aria-hidden", "true");
  h.title = t("dashboard.edit.resize");
  // Without draggable=false, pointer-down on the handle inside a
  // draggable=true widget would still trigger the parent's HTML5
  // dragstart on the first mousemove. Belt + braces: also bail out of
  // any in-flight drag on pointerdown.
  h.setAttribute("draggable", "false");
  h.addEventListener("pointerdown", (ev) => startResize(ev, key, def));
  h.addEventListener("dragstart", (ev) => ev.preventDefault());
  return h;
}

interface ResizeState {
  key: string;
  el: HTMLElement;
  grid: HTMLElement;
  def: WidgetCatalogEntry | undefined;
  origW: number;
  origH: number;
  startX: number;
  startY: number;
  cellW: number;
  cellH: number;
  pointerId: number;
}

let activeResize: ResizeState | null = null;

function startResize(ev: PointerEvent, key: string, def: WidgetCatalogEntry | undefined): void {
  if (!editMode) return;
  ev.preventDefault();
  ev.stopPropagation();
  const el = (ev.currentTarget as HTMLElement).closest<HTMLElement>("[data-widget-key]");
  const grid = document.getElementById("dashboard-grid");
  if (!el || !grid) return;
  const layout = ensureLayout();
  const entry = layout.widgets.find((w) => w.key === key);
  if (!entry) return;

  const placement = computePlacements(layout.widgets).get(key);
  if (!placement) return;

  // Cell width = (grid width - 11 gaps) / 12. We read the live grid
  // size so a window-resize between renders doesn't desync the snap.
  const gridStyle = getComputedStyle(grid);
  const gapPx = parseFloat(gridStyle.columnGap || gridStyle.gap || "0") || 0;
  const rowGapPx = parseFloat(gridStyle.rowGap || gridStyle.gap || "0") || 0;
  const gridWidth = grid.clientWidth;
  const cellW = (gridWidth - (GRID_COLUMNS - 1) * gapPx) / GRID_COLUMNS + gapPx;
  // Row size is harder — auto-rows can vary. Approximate using the
  // widget's current height divided by its current H span.
  const rect = el.getBoundingClientRect();
  const cellH = (rect.height + rowGapPx) / placement.h;

  activeResize = {
    key, el, grid, def,
    origW: placement.w,
    origH: placement.h,
    startX: ev.clientX,
    startY: ev.clientY,
    cellW: Math.max(1, cellW),
    cellH: Math.max(1, cellH),
    pointerId: ev.pointerId,
  };
  el.classList.add("dashboard-widget--resizing");
  // Disable native DnD on the widget for the duration of the resize so
  // pointermove doesn't start a parallel HTML5 drag gesture.
  el.removeAttribute("draggable");
  (ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
  window.addEventListener("pointermove", onResizeMove);
  window.addEventListener("pointerup", onResizeEnd);
  window.addEventListener("pointercancel", onResizeEnd);
}

function onResizeMove(ev: PointerEvent): void {
  if (!activeResize) return;
  const dx = ev.clientX - activeResize.startX;
  const dy = ev.clientY - activeResize.startY;
  const dW = Math.round(dx / activeResize.cellW);
  const dH = Math.round(dy / activeResize.cellH);
  const newW = clampW(activeResize.origW + dW, activeResize.def);
  const newH = clampH(activeResize.origH + dH, activeResize.def);
  // Live preview — update the inline style only; don't mutate the
  // layout spec until pointerup. Reads current grid-column to keep
  // the X-start anchored.
  const layout = ensureLayout();
  const entry = layout.widgets.find((w) => w.key === activeResize!.key);
  if (!entry) return;
  const placement = computePlacements(layout.widgets).get(activeResize.key);
  if (!placement) return;
  // Clamp the live X so the preview doesn't spill past the right edge.
  const previewX = Math.min(placement.x, GRID_COLUMNS - newW);
  activeResize.el.style.gridColumn = `${previewX + 1} / span ${newW}`;
  activeResize.el.style.gridRow = `${placement.y + 1} / span ${newH}`;
}

function onResizeEnd(ev: PointerEvent): void {
  if (!activeResize) return;
  const dx = ev.clientX - activeResize.startX;
  const dy = ev.clientY - activeResize.startY;
  const dW = Math.round(dx / activeResize.cellW);
  const dH = Math.round(dy / activeResize.cellH);
  const newW = clampW(activeResize.origW + dW, activeResize.def);
  const newH = clampH(activeResize.origH + dH, activeResize.def);
  const state = activeResize;
  activeResize = null;
  state.el.classList.remove("dashboard-widget--resizing");
  // Restore native DnD on the widget — only when still in edit mode.
  if (editMode) state.el.setAttribute("draggable", "true");
  window.removeEventListener("pointermove", onResizeMove);
  window.removeEventListener("pointerup", onResizeEnd);
  window.removeEventListener("pointercancel", onResizeEnd);
  if (newW === state.origW && newH === state.origH) {
    // No effective change — re-run applyLayout to snap any preview
    // styles back to the canonical positions.
    applyLayout();
    return;
  }
  resizeWidget(state.key, newW, newH);
}

// --- Drag and drop ------------------------------------------------------

let dragSourceKey: string | null = null;

function bindDnDHandlers(el: HTMLElement): void {
  el.addEventListener("dragstart", (ev) => {
    if (!editMode) return;
    dragSourceKey = el.dataset.widgetKey || null;
    if (ev.dataTransfer && dragSourceKey) {
      ev.dataTransfer.setData("text/plain", dragSourceKey);
      ev.dataTransfer.effectAllowed = "move";
    }
    el.classList.add("dashboard-widget--dragging");
  });
  el.addEventListener("dragover", (ev) => {
    if (!editMode || !dragSourceKey) return;
    ev.preventDefault();
    if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
    el.classList.add("dashboard-widget--dragover");
  });
  el.addEventListener("dragleave", () => {
    el.classList.remove("dashboard-widget--dragover");
  });
  el.addEventListener("drop", (ev) => {
    if (!editMode || !dragSourceKey) return;
    ev.preventDefault();
    const destKey = el.dataset.widgetKey || "";
    el.classList.remove("dashboard-widget--dragover");
    if (destKey && dragSourceKey !== destKey) reorderViaDnd(dragSourceKey, destKey);
    dragSourceKey = null;
  });
  el.addEventListener("dragend", () => {
    el.classList.remove("dashboard-widget--dragging");
    document.querySelectorAll<HTMLElement>(".dashboard-widget--dragover").forEach((d) => d.classList.remove("dashboard-widget--dragover"));
    dragSourceKey = null;
  });
}

// renderPinnedProjects (Slice C). Backend ships every visible pin (cap
// 20, pinned_at DESC); the widget's count setting trims further.
function renderPinnedProjects(items: PinnedProjectRef[]): void {
  const list = document.getElementById("dashboard-pinned-list");
  const empty = document.getElementById("dashboard-pinned-empty");
  if (!list || !empty) return;

  const s = settingsFor("pinned-projects");
  const cap = typeof s.count === "number" && s.count > 0 ? s.count : 10;
  items = items.slice(0, cap);

  if (!items.length) {
    list.innerHTML = "";
    list.style.display = "none";
    empty.style.display = "block";
    return;
  }
  empty.style.display = "none";
  list.style.display = "";
  list.innerHTML = items.map((p) => {
    return `<li class="dashboard-list-item">
      <a href="/projects/${esc(p.project_id)}" class="dashboard-list-link">
        <div class="dashboard-list-main">
          <span class="dashboard-list-title">${esc(p.project_title)}</span>
          ${p.project_reference ? `<span class="dashboard-list-ref">${esc(p.project_reference)}</span>` : ""}
        </div>
        <div class="dashboard-list-meta" aria-hidden="true">★</div>
      </a>
    </li>`;
  }).join("");
}

// syncPromoteButtonVisibility shows the "Set as firm default" button
// only to global_admins. The button is rendered in dashboard.tsx with
// style="display:none" so non-admins never see it even if the user
// payload is delayed; once we know the role we either reveal or leave
// it hidden.
function syncPromoteButtonVisibility(user: DashboardUser | null): void {
  const btn = document.getElementById("dashboard-edit-promote") as HTMLButtonElement | null;
  if (!btn) return;
  btn.style.display = user && user.global_role === "global_admin" ? "" : "none";
}

// applyLayout walks the saved DashboardLayoutSpec and (a) hides
// invisible widgets, (b) places visible ones in the 12-column
// .dashboard-grid via grid-column / grid-row inline styles.
//
// Pre-overhaul this function moved widgets between two different
// parents (.container and .dashboard-columns) via appendChild, which
// shoved every widget to the END of its parent — past the edit-footer
// and save-toast — making cross-row drags appear to silently no-op.
// The new contract: every widget is a direct child of .dashboard-grid
// (single source of layout truth) and applyLayout only writes inline
// grid coords, never moves DOM nodes. That kills bug class (a) entirely.
//
// Widgets without explicit positions get an auto-flow placement based
// on their default size — this keeps pre-overhaul layouts (positions
// not yet on the wire) rendering as a sensible stack until the user
// customises them.
function applyLayout(): void {
  if (!currentLayout || !Array.isArray(currentLayout.widgets)) return;

  const allWidgets = Array.from(
    document.querySelectorAll<HTMLElement>("[data-widget-key]"),
  );
  if (!allWidgets.length) return;
  const byKey = new Map<string, HTMLElement>();
  allWidgets.forEach((el) => {
    const k = el.dataset.widgetKey;
    if (k) byKey.set(k, el);
  });

  // Compute effective placements. In edit mode we also include hidden
  // widgets so they render at their stored (or default) dimensions
  // dimmed-but-visible — without this they'd inherit no inline grid-
  // column and CSS Grid would auto-flow them as 1×1 slivers, producing
  // the "super slim greyed-out column" symptom (m/paliad#73). In view
  // mode hidden widgets are display:none and reserve no cells.
  const placements = computePlacements(currentLayout.widgets, {
    includeHidden: editMode,
  });

  for (const w of currentLayout.widgets) {
    const el = byKey.get(w.key);
    if (!el) continue;
    const placement = placements.get(w.key);
    if (editMode) {
      el.style.display = "";
    } else {
      el.style.display = w.visible ? "" : "none";
    }
    if (placement) {
      el.style.gridColumn = `${placement.x + 1} / span ${placement.w}`;
      el.style.gridRow = `${placement.y + 1} / span ${placement.h}`;
    } else {
      el.style.gridColumn = "";
      el.style.gridRow = "";
    }
  }
}

// computePlacements is the local adapter — it walks the layout's widgets,
// resolves each widget's catalog bound, and hands the spec to the pure
// placeWidgets() in ./dashboard-grid. The pure placer carries the no-
// overlap invariant: if two widgets request colliding cells (drag-drop
// swap with mismatched widths, resize-grow into a sibling, etc.) the
// later one is shifted down to the next free row. See m/paliad#70.
//
// includeHidden=true is used by applyLayout in edit mode to also place
// hidden widgets after the visible pass — so the hidden tray renders
// at proper size below the active layout. Default (false) matches the
// persistence + render paths where hidden widgets carry no placement.
function computePlacements(
  widgets: DashboardWidgetRef[],
  options: { includeHidden?: boolean } = {},
): Map<string, PlacedRect> {
  const inputs: WidgetPlacementInput[] = widgets.map((w) => ({
    key: w.key,
    visible: w.visible,
    x: w.x,
    y: w.y,
    w: w.w,
    h: w.h,
    bound: toBound(lookupCatalog(w.key)),
  }));
  return placeWidgets(inputs, options);
}

function clampW(w: number, def: WidgetCatalogEntry | undefined): number {
  return gridClampW(w, toBound(def));
}

function clampH(h: number, def: WidgetCatalogEntry | undefined): number {
  return gridClampH(h, toBound(def));
}

function toBound(def: WidgetCatalogEntry | undefined): WidgetSizeBound | undefined {
  if (!def) return undefined;
  return {
    default_w: def.default_w,
    default_h: def.default_h,
    min_w: def.min_w,
    max_w: def.max_w,
    min_h: def.min_h,
    max_h: def.max_h,
  };
}

// filterByHorizonDays drops items whose key date is more than `days`
// days from today. Items without a parseable date stay in (we don't
// want to silently hide rows on bad data). today is inclusive.
function filterByHorizonDays<T>(items: T[], days: number, key: (t: T) => string): T[] {
  if (!Number.isFinite(days) || days <= 0) return items;
  const cutoff = new Date();
  cutoff.setHours(0, 0, 0, 0);
  cutoff.setDate(cutoff.getDate() + days);
  return items.filter((t) => {
    const raw = key(t);
    if (!raw) return true;
    // due_date is "YYYY-MM-DD"; start_at is RFC 3339. Both parseable
    // by Date.
    const d = new Date(raw.length === 10 ? raw + "T00:00:00" : raw);
    if (isNaN(d.getTime())) return true;
    return d.getTime() <= cutoff.getTime();
  });
}

function toggleOnboardingHint(user: DashboardUser | null): void {
  // Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
  // already redirects users without a paliad.users row to /onboarding before
  // the dashboard HTML is served. If the gate ever misses (e.g. DB lookup
  // errored and we fell through), push the user to /onboarding here so they
  // don't get stuck on a blank dashboard.
  if (!user) {
    window.location.href = "/onboarding";
    return;
  }
  const onboarding = document.getElementById("dashboard-onboarding")!;
  onboarding.style.display = "none";
}

function setCount(id: string, n: number): void {
  const el = document.getElementById(id);
  if (el) el.textContent = String(n);
}

function formatRelative(isoDate: string): string {
  const due = new Date(isoDate + "T00:00:00");
  if (isNaN(due.getTime())) return isoDate;
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const diffDays = Math.round((due.getTime() - today.getTime()) / 86400000);
  const lang = getLang();
  if (diffDays < 0) {
    const n = Math.abs(diffDays);
    return lang === "de"
      ? (n === 1 ? "vor 1 Tag" : `vor ${n} Tagen`)
      : (n === 1 ? "1 day ago" : `${n} days ago`);
  }
  if (diffDays === 0) return t("dashboard.when.today");
  if (diffDays === 1) return t("dashboard.when.tomorrow");
  return lang === "de" ? `in ${diffDays} Tagen` : `in ${diffDays} days`;
}

function formatDateTime(iso: string): string {
  const d = new Date(iso);
  if (isNaN(d.getTime())) return iso;
  const locale = getLang() === "de" ? "de-DE" : "en-GB";
  return d.toLocaleString(locale, {
    day: "2-digit",
    month: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
  });
}

function esc(s: string): string {
  const d = document.createElement("div");
  d.textContent = s ?? "";
  return d.innerHTML;
}

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

function schedulePolling(): void {
  // Refresh the payload every minute so open dashboards stay current when
  // teammates create Akten/Fristen. Uses the JSON endpoint — no page reload.
  // The inline agenda is refreshed on the same cadence to stay in sync
  // with the deadlines/appointments rails above it.
  window.setInterval(() => {
    void loadDashboard();
    void loadAgenda();
  }, POLL_INTERVAL_MS);
}

document.addEventListener("DOMContentLoaded", () => {
  initI18n();
  initSidebar();
  initCollapsibleSections();
  onLangChange(() => {
    render();
    syncCollapseAriaLabels();
    syncEditToggleLabel();
    if (editMode) rebuildEditChrome();
  });

  // Configurable layout (t-paliad-219). The Go shell handler splices
  // the user's saved layout into __PALIAD_DASHBOARD_LAYOUT__. If it's
  // missing (knowledge-platform-only deploy, hydration failure), the
  // dashboard renders the factory order baked into dashboard.tsx; the
  // client also kicks off a best-effort fetch so a slow-hydrating user
  // still gets their saved layout on the next render pass.
  const layoutInline = window.__PALIAD_DASHBOARD_LAYOUT__;
  if (layoutInline) {
    currentLayout = layoutInline;
  } else if (layoutInline === undefined) {
    void fetch("/api/me/dashboard-layout").then(async (r) => {
      if (!r.ok) return;
      currentLayout = (await r.json()) as DashboardLayoutSpec;
      if (data) render();
    }).catch(() => { /* silent — factory order is the fallback */ });
  }

  // Widget catalog — server-inlined by dashboard_shell.go so the
  // picker / gear popover can render without a /api/dashboard-widget-
  // catalog round-trip. Fall back to a fetch on hydration miss.
  const catalogInline = window.__PALIAD_DASHBOARD_CATALOG__;
  if (catalogInline && Array.isArray(catalogInline)) {
    currentCatalog = catalogInline;
  } else if (catalogInline === undefined) {
    void fetch("/api/dashboard-widget-catalog").then(async (r) => {
      if (!r.ok) return;
      currentCatalog = (await r.json()) as WidgetCatalogEntry[];
    }).catch(() => { /* silent — picker shows empty state */ });
  }

  // Edit-mode wiring (t-paliad-219 Slice B). Toggle starts off; chrome
  // is built lazily on first activation so view-mode pays nothing.
  initEditToggle();
  initEditFooter();

  // Inline agenda fetch is independent of the main dashboard payload.
  // Kicked off in parallel so the agenda section paints as soon as the
  // /api/agenda response lands instead of waiting on the dashboard
  // payload poll.
  void loadAgenda();

  const inlined = window.__PALIAD_DASHBOARD__;
  if (inlined !== undefined) {
    // Server-side hydration path: the handler spliced the payload directly
    // into the HTML. Render synchronously, then start polling.
    if (inlined === null) {
      document.getElementById("dashboard-unavailable")!.style.display = "block";
    } else {
      data = inlined;
      render();
    }
    schedulePolling();
    return;
  }
  // Fallback for dev or when the handler couldn't splice (no DB, etc.).
  void loadDashboard().then(schedulePolling);
});
