/*
 * compare.sr.ht frontend bundle.
 *
 * Consumes the SSR DOM contract:
 *   <script id="compare-data" type="application/json">{...}</script>
 *   <div id="tree-root">   (file tree sidebar)
 *   <div id="diff-root">   (per-file diff sections)
 *   <button data-diff-layout="split">  / <button data-diff-layout="stacked">
 *   <button data-diff-wrap>  (long-line wrapping toggle)
 *
 * Rendering uses @pierre/diffs (FileDiff vanilla class) for each file and
 * @pierre/trees (FileTree vanilla class) for the sidebar. Both render on the
 * main thread (no worker pool passed) with the pure-JS Shiki engine, so the
 * bundle is fully self-contained (no wasm / no runtime asset fetches).
 */

import { FileDiff, parsePatchFiles } from "@pierre/diffs";
import type { FileDiffMetadata, FileDiffOptions, ThemesType } from "@pierre/diffs";
import { FileTree } from "@pierre/trees";
import type { GitStatus, GitStatusEntry } from "@pierre/trees";

// ---- DOM contract types (produced by the Go web layer) --------------------

type Layout = "split" | "stacked";

interface FileEntry {
  path: string;
  oldPath: string;
  status: string; // "A" | "M" | "D" | "R" | ...
  additions: number;
  deletions: number;
  binary: boolean;
}

interface CompareData {
  mode: "compare" | "commit";
  patch: string;
  truncated: boolean;
  files: FileEntry[];
  spec: { base: string; head: string; threeDot: boolean };
}

// ---- Constants ------------------------------------------------------------

const LAYOUT_KEY = "compare.layout";
const WRAP_KEY = "compare.wrap";
const DEFAULT_LAYOUT: Layout = "split";

// Auto light/dark: FileDiff selects the entry matching `themeType`. With
// themeType "system" it follows prefers-color-scheme at runtime.
const THEME: ThemesType = { light: "github-light", dark: "github-dark" };

// ---- Small helpers --------------------------------------------------------

function el<K extends keyof HTMLElementTagNameMap>(
  tag: K,
  className?: string,
  text?: string,
): HTMLElementTagNameMap[K] {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text != null) node.textContent = text;
  return node;
}

function readLayout(): Layout {
  try {
    const v = localStorage.getItem(LAYOUT_KEY);
    if (v === "split" || v === "stacked") return v;
  } catch {
    /* localStorage may be unavailable (private mode); fall through */
  }
  return DEFAULT_LAYOUT;
}

function writeLayout(layout: Layout): void {
  try {
    localStorage.setItem(LAYOUT_KEY, layout);
  } catch {
    /* ignore persistence failures */
  }
}

function readWrap(): boolean {
  try {
    return localStorage.getItem(WRAP_KEY) === "true";
  } catch {
    /* localStorage may be unavailable (private mode); fall through */
  }
  return false;
}

function writeWrap(wrap: boolean): void {
  try {
    localStorage.setItem(WRAP_KEY, String(wrap));
  } catch {
    /* ignore persistence failures */
  }
}

// "stacked" is the user-facing name for the unified diff style.
function diffStyleFor(layout: Layout): "unified" | "split" {
  return layout === "split" ? "split" : "unified";
}

function diffOptionsFor(
  layout: Layout,
  wrap: boolean,
): FileDiffOptions<undefined> {
  return {
    theme: THEME,
    themeType: "system",
    diffStyle: diffStyleFor(layout),
    overflow: wrap ? "wrap" : "scroll",
    // We render our own header row above, so disable the library header.
    disableFileHeader: true,
  };
}

function gitStatusFor(status: string): GitStatus {
  switch (status.toUpperCase()[0]) {
    case "A":
      return "added";
    case "D":
      return "deleted";
    case "R":
      return "renamed";
    case "M":
    default:
      return "modified";
  }
}

function sectionId(index: number): string {
  return `file-${index}`;
}

// ---- Main -----------------------------------------------------------------

function readData(): CompareData | null {
  const script = document.getElementById("compare-data");
  if (!script || !script.textContent) return null;
  try {
    return JSON.parse(script.textContent) as CompareData;
  } catch (err) {
    console.error("compare: failed to parse #compare-data", err);
    return null;
  }
}

function buildFileHeader(index: number, file: FileEntry): HTMLElement {
  const header = el("div", "diff-file-header");

  const path = el("span", "diff-file-path");
  if (file.oldPath && file.oldPath !== file.path) {
    path.append(
      el("span", "diff-file-oldpath", file.oldPath),
      el("span", "diff-file-arrow", " → "),
      el("span", "diff-file-newpath", file.path),
    );
  } else {
    path.textContent = file.path;
  }
  header.append(path);

  const stats = el("span", "diff-file-stats");
  if (file.additions > 0) {
    stats.append(el("span", "diff-file-add", `+${file.additions}`));
  }
  if (file.deletions > 0) {
    stats.append(el("span", "diff-file-del", `-${file.deletions}`));
  }
  header.append(stats);

  if (file.binary) {
    header.append(el("span", "diff-file-badge diff-file-binary", "BIN"));
  }

  header.dataset.fileIndex = String(index);
  return header;
}

function renderDiffs(
  root: HTMLElement,
  data: CompareData,
  metaByPath: Map<string, FileDiffMetadata>,
  layout: Layout,
  wrap: boolean,
): FileDiff[] {
  root.textContent = "";
  const instances: FileDiff[] = [];

  if (data.files.length === 0) {
    root.append(el("div", "diff-empty", "No changes to display."));
    return instances;
  }

  data.files.forEach((file, index) => {
    const section = el("section", "diff-file");
    section.id = sectionId(index);
    section.dataset.path = file.path;
    section.append(buildFileHeader(index, file));

    const body = el("div", "diff-file-body");
    section.append(body);
    root.append(section);

    if (file.binary) {
      body.append(el("div", "diff-file-note", "Binary file not shown."));
      return;
    }

    const meta = metaByPath.get(file.path);
    if (!meta) {
      body.append(el("div", "diff-file-note", "No textual diff available."));
      return;
    }

    const instance = new FileDiff(diffOptionsFor(layout, wrap));
    instance.render({ fileDiff: meta, containerWrapper: body });
    instances.push(instance);
  });

  return instances;
}

function buildTree(
  root: HTMLElement,
  data: CompareData,
): FileTree | null {
  root.textContent = "";
  if (data.files.length === 0) return null;

  const paths = data.files.map((f) => f.path);
  const gitStatus: GitStatusEntry[] = data.files.map((f) => ({
    path: f.path,
    status: gitStatusFor(f.status),
  }));

  const indexByPath = new Map(data.files.map((f, i) => [f.path, i]));

  const tree = new FileTree({
    paths,
    gitStatus,
    initialExpansion: "open",
    // "compact" preset: 24px rows + 0.8 density factor (tighter level-gap,
    // row-gap and padding) so the sidebar reads as a dense file index rather
    // than an airy list — matching the reference diff UIs.
    density: "compact",
    onSelectionChange: (selected) => {
      const path = selected[0];
      if (path == null) return;
      const index = indexByPath.get(String(path));
      if (index == null) return;
      const section = document.getElementById(sectionId(index));
      section?.scrollIntoView({ behavior: "smooth", block: "start" });
    },
  });
  tree.render({ containerWrapper: root });
  sizeTreeRoot(root, tree);
  return tree;
}

// The @pierre/trees FileTree virtualizes its rows into a scroll viewport, so the
// mount needs a *definite* height or it collapses to zero and renders nothing
// (its README sets `mount.style.height` for exactly this reason — the inner
// container flexes to fill the mount, so a mount of height 0 shows nothing).
//
// Sizing the mount to the full viewport (the naive fix) leaves a tall band of
// the tree's own background below the last row whenever the tree is shorter than
// the screen. Instead we size the mount to the tree's *content* height, capped at
// the viewport: a short tree hugs its rows, a long tree scrolls within the cap.
//
// The content height can't be read directly (the inner container flexes to fill
// whatever height the mount has, so its scrollHeight just echoes the mount). We
// momentarily pin the mount to 1px so the virtualizer's scroll spacer reports the
// true total content height, read it, then apply the capped value. This is
// synchronous — no paint happens between the pin and the restore, so it doesn't
// flicker. Re-runs on window resize and on every tree change (expand/collapse).
function sizeTreeRoot(root: HTMLElement, tree: FileTree): void {
  const cap = () => Math.max(160, window.innerHeight - 24);

  const contentHeight = (): number | null => {
    const container = root.firstElementChild as HTMLElement | null;
    const scroll = container?.shadowRoot?.querySelector<HTMLElement>(
      '[data-file-tree-virtualized-scroll]',
    );
    if (!scroll) return null;
    const prev = root.style.height;
    root.style.height = "1px";
    const content = scroll.scrollHeight;
    root.style.height = prev;
    return content;
  };

  const apply = () => {
    const content = contentHeight();
    root.style.height = `${Math.min(cap(), content ?? cap())}px`;
  };

  apply();
  window.addEventListener("resize", apply);
  // Fires on expand/collapse and any other tree mutation, so the mount keeps
  // hugging its content as rows appear and disappear.
  tree.subscribe(apply);
}

function wireDiffControls(
  layoutButtons: HTMLButtonElement[],
  wrapButton: HTMLButtonElement | null,
  getInstances: () => FileDiff[],
  initialLayout: Layout,
  initialWrap: boolean,
): void {
  let layout = initialLayout;
  let wrap = initialWrap;

  const reflectControls = () => {
    for (const btn of layoutButtons) {
      btn.classList.toggle("active", btn.dataset.diffLayout === layout);
    }
    wrapButton?.classList.toggle("active", wrap);
    wrapButton?.setAttribute("aria-pressed", String(wrap));
  };

  const rerender = () => {
    for (const instance of getInstances()) {
      instance.setOptions(diffOptionsFor(layout, wrap));
      instance.rerender();
    }
  };

  for (const btn of layoutButtons) {
    btn.addEventListener("click", (ev) => {
      ev.preventDefault();
      const next = btn.dataset.diffLayout;
      if (next !== "split" && next !== "stacked") return;
      layout = next;
      writeLayout(layout);
      reflectControls();
      rerender();
    });
  }

  wrapButton?.addEventListener("click", (ev) => {
    ev.preventDefault();
    wrap = !wrap;
    writeWrap(wrap);
    reflectControls();
    rerender();
  });

  // Reflect the initial (possibly persisted) options on the controls.
  reflectControls();
}

function main(): void {
  const data = readData();
  if (!data) return;

  const diffRoot = document.getElementById("diff-root");
  const treeRoot = document.getElementById("tree-root");
  if (!diffRoot) return;

  const layout = readLayout();
  const wrap = readWrap();

  // parsePatchFiles returns one ParsedPatch per commit; each has a `files`
  // array of FileDiffMetadata. Flatten across patches and index by path.
  const metaByPath = new Map<string, FileDiffMetadata>();
  if (data.patch) {
    try {
      for (const patch of parsePatchFiles(data.patch)) {
        for (const meta of patch.files) {
          metaByPath.set(meta.name, meta);
        }
      }
    } catch (err) {
      console.error("compare: failed to parse patch", err);
    }
  }

  const instances = renderDiffs(diffRoot, data, metaByPath, layout, wrap);
  if (treeRoot) buildTree(treeRoot, data);

  const buttons = Array.from(
    document.querySelectorAll<HTMLButtonElement>("button[data-diff-layout]"),
  );
  const wrapButton = document.querySelector<HTMLButtonElement>(
    "button[data-diff-wrap]",
  );
  if (buttons.length > 0 || wrapButton) {
    wireDiffControls(buttons, wrapButton, () => instances, layout, wrap);
  }
}

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