// FilterBar — the universal filter + view-mode primitive
// (t-paliad-163). One client component every list-shaped paliad surface
// mounts.
//
// Lifecycle:
//   1. Caller hands in baseFilter + baseRender + axes + onResult.
//   2. We parse URL params (within urlNamespace) and localStorage prefs,
//      overlay them on the base spec to compute the effective spec.
//   3. We render the toolbar (one chip cluster / popover / select per
//      axis, plus trailing actions).
//   4. We POST /api/views/{slug}/run with the effective spec as override
//      and hand the result + effective spec to onResult. The surface's
//      shape host renders.
//   5. Every axis interaction patches BarState, re-encodes the URL,
//      re-runs the spec.
//
// The bar is a closed loop — surfaces don't see FilterSpec/RenderSpec
// directly, just BarState diffs and the final ViewRunResult. That keeps
// the substrate's validation invariants in one place (the bar).

import { onLangChange, t } from "../i18n";
import type { FilterSpec, RenderSpec, ViewRunResult } from "../views/types";
import {
  parseBar,
  encodeBar,
} from "./url-codec";
import { renderAxis, type AxisCtx, type RenderAxisOpts } from "./axes";
import { openSaveModal } from "./save-modal";
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";

export type { MountOpts, BarHandle, AxisKey } from "./types";

const PREFS_PREFIX = "paliad.bar.";

interface PrefsBlob {
  shape?: string;
  density?: string;
  sort?: string;
}

export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
  if (!!opts.customRunner === !!opts.systemViewSlug) {
    throw new Error(
      "mountFilterBar: exactly one of customRunner or systemViewSlug must be provided",
    );
  }
  let state: BarState = {};
  const ns = opts.urlNamespace;

  // Hydrate state: URL > localStorage prefs > base.
  const urlParams = new URLSearchParams(window.location.search);
  state = parseBar(urlParams, ns);
  hydratePrefs(state, opts.surfaceKey);

  // Toolbar shell.
  const toolbar = document.createElement("div");
  toolbar.className = "filter-bar";
  host.appendChild(toolbar);

  // Trailing actions: Save as view + Reset (when not suppressed).
  const showSave = opts.showSaveAsView !== false;

  // Run + render orchestration.
  let runVersion = 0;
  let lastEffective: EffectiveSpec | null = null;

  const runAndRender = async () => {
    const effective = computeEffective(opts.baseFilter, opts.baseRender, state);
    lastEffective = effective;
    const myVersion = ++runVersion;
    try {
      let result: ViewRunResult;
      if (opts.customRunner) {
        // Hand the runner a frozen snapshot of the bar state so it can
        // read axes the EffectiveSpec doesn't round-trip (SmartTimeline
        // timeline_status / timeline_track on the Verlauf surface).
        result = await opts.customRunner(effective, Object.freeze({ ...state }));
      } else {
        const slug = opts.systemViewSlug as string; // ctor guard guarantees this
        const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, {
          method: "POST",
          credentials: "include",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ filter: effective.filter }),
        });
        if (myVersion !== runVersion) return; // a newer click superseded us
        if (!r.ok) {
          opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
          return;
        }
        result = (await r.json()) as ViewRunResult;
      }
      if (myVersion !== runVersion) return;
      opts.onResult(result, effective);
    } catch (_e) {
      if (myVersion !== runVersion) return;
      opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
    }
  };

  // Axis context — all axis renderers patch state through here.
  const ctx: AxisCtx = {
    get<K extends keyof BarState>(key: K) { return state[key]; },
    patch(delta) {
      state = { ...state, ...delta };
      // Coerce empties so URL stays clean.
      for (const k of Object.keys(delta) as (keyof BarState)[]) {
        const v = state[k];
        if (Array.isArray(v) && v.length === 0) delete state[k];
        if (v === undefined || v === null || v === false) delete state[k];
      }
      // personal_only false should also be deleted (handled above as
      // falsy, but explicit for clarity).
      if (state.personal_only === false) delete state.personal_only;
      syncURL();
      syncPrefs();
      renderToolbar();
      void runAndRender();
    },
  };

  const axisRenderOpts: RenderAxisOpts = {
    timePresets: opts.timePresets,
  };

  // First paint.
  const renderToolbar = () => {
    toolbar.innerHTML = "";
    for (const axis of opts.axes) {
      const el = renderAxis(axis as AxisKey, ctx, axisRenderOpts);
      if (el) toolbar.appendChild(el);
    }
    if (showSave) {
      const trailing = document.createElement("div");
      trailing.className = "filter-bar-trailing";

      const resetBtn = document.createElement("button");
      resetBtn.type = "button";
      resetBtn.className = "btn-secondary btn-small filter-bar-reset";
      resetBtn.textContent = t("views.bar.action.reset");
      resetBtn.disabled = !isDirty(state);
      resetBtn.addEventListener("click", () => handle.reset());
      trailing.appendChild(resetBtn);

      const saveBtn = document.createElement("button");
      saveBtn.type = "button";
      saveBtn.className = "btn-primary btn-small filter-bar-save";
      saveBtn.textContent = t("views.bar.action.save_as_view");
      saveBtn.addEventListener("click", async () => {
        if (!lastEffective) return;
        const result = await openSaveModal(lastEffective.filter, lastEffective.render);
        if (result) {
          window.location.href = `/views/${encodeURIComponent(result.view.slug)}`;
        }
      });
      trailing.appendChild(saveBtn);

      toolbar.appendChild(trailing);
    }
  };

  const syncURL = () => {
    const params = new URLSearchParams(window.location.search);
    encodeBar(state, params, ns);
    const qs = params.toString();
    const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
    history.replaceState(null, "", url);
  };

  const syncPrefs = () => {
    const blob: PrefsBlob = {};
    if (state.shape) blob.shape = state.shape;
    if (state.density) blob.density = state.density;
    if (state.sort) blob.sort = state.sort;
    try {
      if (Object.keys(blob).length === 0) {
        localStorage.removeItem(PREFS_PREFIX + opts.surfaceKey);
      } else {
        localStorage.setItem(PREFS_PREFIX + opts.surfaceKey, JSON.stringify(blob));
      }
    } catch { /* private mode / quota — ignore */ }
  };

  // Re-render labels on language change without losing state. The
  // existing onLangChange API is register-only (no off-handler). We
  // gate via a `destroyed` flag so a torn-down bar's callback no-ops.
  let destroyed = false;
  onLangChange(() => {
    if (destroyed) return;
    renderToolbar();
  });

  const handle: BarHandle = {
    reset() {
      state = {};
      syncURL();
      syncPrefs();
      renderToolbar();
      void runAndRender();
    },
    async refresh() {
      await runAndRender();
    },
    getEffective() {
      if (lastEffective) return lastEffective;
      return computeEffective(opts.baseFilter, opts.baseRender, state);
    },
    getState() {
      // Hand back a frozen snapshot so callers can't smuggle mutations
      // back into the bar's owned state — the bar is the single writer.
      return Object.freeze({ ...state });
    },
    destroy() {
      destroyed = true;
      toolbar.remove();
    },
  };

  renderToolbar();
  void runAndRender();
  return handle;
}

// hydratePrefs reads the saved `paliad.bar.<surfaceKey>` blob and fills
// in render axes the URL didn't already pin. URL wins over prefs.
function hydratePrefs(state: BarState, surfaceKey: string): void {
  let blob: PrefsBlob;
  try {
    const raw = localStorage.getItem(PREFS_PREFIX + surfaceKey);
    if (!raw) return;
    blob = JSON.parse(raw) as PrefsBlob;
  } catch { return; }
  if (!state.shape && (blob.shape === "list" || blob.shape === "cards" || blob.shape === "calendar")) {
    state.shape = blob.shape;
  }
  if (!state.density && (blob.density === "comfortable" || blob.density === "compact")) {
    state.density = blob.density;
  }
  if (!state.sort && (blob.sort === "date_asc" || blob.sort === "date_desc")) {
    state.sort = blob.sort;
  }
}

// computeEffective overlays the BarState onto the base FilterSpec +
// RenderSpec to produce the spec that gets POSTed to the substrate.
//
// Server-side validator (FilterSpec.Validate) is the final gate; we
// produce shapes the validator will accept, but defer to it for the
// hard rejection case (e.g. PersonalOnly + ScopeExplicit).
export function computeEffective(
  base: FilterSpec,
  baseRender: RenderSpec,
  state: BarState,
): EffectiveSpec {
  // Deep-clone to avoid mutating the caller's base. JSON round-trip is
  // fine here — every field on FilterSpec is a primitive / array /
  // object literal (no class instances, no Date, no functions).
  const filter = JSON.parse(JSON.stringify(base)) as FilterSpec;
  const render = JSON.parse(JSON.stringify(baseRender)) as RenderSpec;

  if (state.time) {
    filter.time = {
      ...filter.time,
      horizon: state.time.horizon,
      from: state.time.horizon === "custom" ? state.time.from : undefined,
      to:   state.time.horizon === "custom" ? state.time.to   : undefined,
    };
  }

  if (state.project) {
    if (state.project.mode === "personal") {
      filter.scope = {
        ...filter.scope,
        personal_only: true,
        // When personal_only takes over, leave projects on the base
        // mode (typically all_visible). Validator rejects ScopeExplicit
        // + personal_only so we don't overwrite the mode here.
      };
    } else if (state.project.id) {
      filter.scope = {
        ...filter.scope,
        projects: { mode: "explicit", ids: [state.project.id] },
      };
    }
  }
  if (state.personal_only) {
    filter.scope = { ...filter.scope, personal_only: true };
  }

  // Per-source predicates. Build the predicates map idempotently;
  // never inject a predicate for a source the spec doesn't list.
  const sources = new Set(filter.sources);
  filter.predicates = filter.predicates ?? {};

  if (sources.has("deadline") && (state.deadline_status || state.deadline_event_type)) {
    const cur = filter.predicates.deadline ?? {};
    const next = { ...cur };
    if (state.deadline_status) next.status = state.deadline_status;
    if (state.deadline_event_type) {
      next.event_types = state.deadline_event_type.ids;
      next.include_untyped = state.deadline_event_type.include_untyped;
    }
    filter.predicates.deadline = next;
  }
  if (sources.has("appointment") && state.appointment_type) {
    const cur = filter.predicates.appointment ?? {};
    filter.predicates.appointment = { ...cur, appointment_types: state.appointment_type };
  }
  if (sources.has("approval_request") && (state.approval_viewer_role || state.approval_status || state.approval_entity_type)) {
    const cur = filter.predicates.approval_request ?? {};
    const next = { ...cur };
    if (state.approval_viewer_role) next.viewer_role = state.approval_viewer_role;
    if (state.approval_status) next.status = state.approval_status;
    if (state.approval_entity_type) next.entity_types = state.approval_entity_type;
    filter.predicates.approval_request = next;
  }
  if (sources.has("project_event") && state.project_event_kind) {
    const cur = filter.predicates.project_event ?? {};
    filter.predicates.project_event = { ...cur, event_types: state.project_event_kind };
  }

  // Render overlays.
  if (state.shape) render.shape = state.shape;
  if (state.sort) {
    if (render.shape === "list" || (state.shape === "list" && !render.list)) {
      render.list = { ...(render.list ?? {}), sort: state.sort };
    }
    if (render.shape === "cards" || state.shape === "cards") {
      render.cards = { ...(render.cards ?? {}), sort: state.sort };
    }
  }
  if (state.density && (render.shape === "list" || state.shape === "list")) {
    render.list = { ...(render.list ?? {}), density: state.density };
  }

  // Inbox overlays (t-paliad-249).
  //
  // unread_only is a top-level FilterSpec field; the server resolves
  // the actual cursor at run-time. Default-on for the inbox surface is
  // baked into the base spec — but we ALSO need to write `true` here
  // when the user explicitly picks the chip so the server doesn't
  // confuse "user wants unread" with "user wants no filter".
  if (state.unread_only !== undefined) {
    filter.unread_only = state.unread_only;
  }

  // inbox_focus is a coarse axis that overlays Sources + a few
  // per-source predicates. Translate here so the server sees a clean
  // spec; the validator + RunSpec don't need to know about the chip.
  if (state.inbox_focus && state.inbox_focus !== "alles") {
    applyInboxFocusOverlay(filter, state.inbox_focus);
  }

  return { filter, render };
}

// applyInboxFocusOverlay narrows the spec to the chip's intent.
// Mutates `filter` in place. Called only when state.inbox_focus is
// set to a non-default value.
//
// Contract:
//   - "genehmigungen"  → drop project_event from sources entirely.
//   - "plus_termine"   → keep both sources; narrow project_event to
//                        appointment_* kinds; narrow approval_request
//                        entity_types to ["appointment"].
//   - "plus_fristen"   → keep both sources; narrow project_event to
//                        deadline_* kinds; narrow approval_request
//                        entity_types to ["deadline"].
function applyInboxFocusOverlay(filter: FilterSpec, focus: Exclude<NonNullable<BarState["inbox_focus"]>, "alles">): void {
  filter.predicates = filter.predicates ?? {};
  if (focus === "genehmigungen") {
    filter.sources = filter.sources.filter((s) => s !== "project_event");
    delete filter.predicates.project_event;
    return;
  }
  const kindPrefix = focus === "plus_fristen" ? "deadline_" : "appointment_";
  const entity = focus === "plus_fristen" ? "deadline" : "appointment";

  if (filter.sources.includes("project_event")) {
    const baseKinds = filter.predicates.project_event?.event_types ?? [];
    const narrowed = baseKinds.filter((k) => k.startsWith(kindPrefix));
    filter.predicates.project_event = {
      ...(filter.predicates.project_event ?? {}),
      event_types: narrowed,
    };
  }
  if (filter.sources.includes("approval_request")) {
    filter.predicates.approval_request = {
      ...(filter.predicates.approval_request ?? {}),
      entity_types: [entity],
    };
  }
}

// isDirty — used to enable the Reset button only when there's something
// to reset to.
function isDirty(state: BarState): boolean {
  for (const k of Object.keys(state) as (keyof BarState)[]) {
    const v = state[k];
    if (v === undefined || v === null || v === false) continue;
    if (Array.isArray(v) && v.length === 0) continue;
    return true;
  }
  return false;
}
