"use strict";

// Obsidian injects its own module resolver. Under plain `node --test` it is
// absent, so guard the require and fall back to an empty base class. This is
// what keeps the pure helpers below unit-testable without an Obsidian runtime.
let OB = null;
try {
  OB = require("obsidian");
} catch (_) {
  OB = null;
}
const PluginBase = OB ? OB.Plugin : class {};

const STATIONS = ["Chat box", "ReAct", "Tools", "Memory", "Skills", "Process", "OS"];

const DEFAULTS = {
  script: "raw/sources/Webinar script.md",
  coverage: "wiki/script-coverage.md",
  rawDir: "raw/sources",
  wikiSourceDir: "wiki/sources",
  conceptDir: "wiki/concepts",
};

const RAW_PATH_RE = /^\s*-\s*\*\*Raw path:\*\*\s*`([^`]+)`/m;

function extractRawPath(text) {
  const m = String(text).match(RAW_PATH_RE);
  return m ? m[1].trim() : null;
}

function derivePipeline({ rawFiles, sourcePages }) {
  // First claim on a raw path wins. A later page claiming the same file is a
  // duplicate claim — real catalog drift — and joins `orphaned` rather than
  // being silently dropped. `orphaned` therefore means "source page not paired
  // with a raw file", whatever the reason.
  const claimed = new Map();
  const duplicates = [];
  for (const page of sourcePages) {
    if (!page.rawPath) continue;
    if (claimed.has(page.rawPath)) duplicates.push(page);
    else claimed.set(page.rawPath, page);
  }

  const processed = [];
  const unprocessed = [];
  for (const file of rawFiles) {
    const page = claimed.get(file.path);
    if (page) processed.push(Object.assign({}, file, { page }));
    else unprocessed.push(file);
  }

  // One pass over sourcePages, so a page appears in `orphaned` at most once no
  // matter how many of the three reasons apply to it. Concatenating a separate
  // duplicates array here would double-count a losing claimant whose shared raw
  // path is also missing from disk.
  const rawPaths = new Set(rawFiles.map((f) => f.path));
  const duplicateSet = new Set(duplicates);
  const orphaned = sourcePages.filter(
    (p) => duplicateSet.has(p) || !p.rawPath || !rawPaths.has(p.rawPath)
  );

  unprocessed.sort((a, b) => a.name.localeCompare(b.name));
  processed.sort((a, b) => b.page.name.localeCompare(a.page.name));

  return { processed, unprocessed, orphaned };
}

const VALID_STATUS = new Set(["covered", "partial", "absent"]);
const SEPARATOR_RE = /^\|?\s*:?-{2,}/;

function parseCoverageTable(markdown) {
  const text = String(markdown);
  const meta = { script: null, lastSynced: null };

  const scriptM = text.match(/^\s*-\s*\*\*Script:\*\*\s*`([^`]+)`/m);
  if (scriptM) meta.script = scriptM[1].trim();
  const syncM = text.match(/^\s*-\s*\*\*Last synced:\*\*\s*(\S+)/m);
  if (syncM) meta.lastSynced = syncM[1].trim();

  const rows = [];
  const errors = [];
  let inTable = false;

  text.split(/\r?\n/).forEach((line, i) => {
    const t = line.trim();
    if (!t.startsWith("|")) {
      inTable = false;
      return;
    }
    if (SEPARATOR_RE.test(t)) {
      inTable = true;
      return;
    }
    if (!inTable) return;

    const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1);
    // Split on unescaped pipes only. Obsidian escapes the pipe of a piped
    // wikilink inside a table cell as `\|`, and a plain split("|") tears
    // `[[harness\|alias]]` into two cells, shifting every later column left.
    const cells = body.split(/(?<!\\)\|/).map((c) => c.trim());

    if (cells.length < 3) {
      errors.push({ line: i + 1, text: t, reason: "expected at least 3 columns" });
      return;
    }

    const status = cells[1].toLowerCase();
    if (!VALID_STATUS.has(status)) {
      errors.push({ line: i + 1, text: t, reason: `invalid status "${cells[1]}"` });
      return;
    }

    // Capture up to the first ] | or backslash. Obsidian escapes the pipe in a
    // piped wikilink inside a table cell, so the raw cell reads [[name\|alias]] —
    // excluding the backslash is what keeps the trailing "\" out of the name.
    const linkM = cells[0].match(/\[\[([^\]|\\]+)/);
    const stationCell = cells[2];

    rows.push({
      concept: linkM ? linkM[1].trim() : cells[0],
      status,
      stations:
        stationCell === "—" || stationCell === "-" || stationCell === ""
          ? []
          : stationCell.split(",").map((s) => s.trim()).filter(Boolean),
      pinned: (cells[3] || "").toLowerCase() === "yes",
      line: i + 1,
    });
  });

  return { meta, rows, errors };
}

function groupByStation(rows) {
  const order = ["All stations", ...STATIONS, "No station"];
  const buckets = new Map(order.map((k) => [k, []]));

  for (const row of rows) {
    let key;
    if (row.stations.includes("all")) key = "All stations";
    else if (row.stations.length === 0) key = "No station";
    else key = row.stations[0];
    if (!buckets.has(key)) buckets.set(key, []);
    buckets.get(key).push(row);
  }

  const known = order.filter((k) => buckets.get(k).length > 0);
  const unknown = [...buckets.keys()].filter((k) => !order.includes(k) && buckets.get(k).length > 0);
  return [...known, ...unknown].map((station) => ({ station, rows: buckets.get(station) }));
}

function reconcileConcepts(rows, conceptNames) {
  const named = new Set(conceptNames);
  const rowed = new Set(rows.map((r) => r.concept));
  return {
    rows,
    unsynced: conceptNames.filter((n) => !rowed.has(n)).sort(),
    stale: rows.filter((r) => !named.has(r.concept)),
  };
}

// Defence in depth. The spawn path no longer uses a shell, so nothing here is
// load-bearing against injection today — but the guard costs nothing and would
// still hold if the single-string `shell: true` form is ever needed for a .cmd
// shim. `^` is cmd.exe's escape character; control characters are rejected
// because a NUL byte makes spawn() throw synchronously.
//
// Apostrophes, spaces, cyrillic, em dashes and `!` stay allowed — they appear in
// real filenames in this vault.
const UNSAFE_CHARS = /["`$&|;<>%^\u0000-\u001f\u007f]/;

function isSafeFilename(name) {
  if (typeof name !== "string" || name.length === 0) return false;
  if (UNSAFE_CHARS.test(name)) return false;
  if (name.includes("..")) return false;
  return true;
}

function parseConfig(source) {
  const cfg = Object.assign({}, DEFAULTS);
  for (const line of String(source).split(/\r?\n/)) {
    const m = line.match(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.+?)\s*$/);
    if (m && Object.prototype.hasOwnProperty.call(DEFAULTS, m[1])) {
      cfg[m[1]] = m[2];
    }
  }
  return cfg;
}

// Source types CLAUDE.md documents for raw/sources. Anything else in that
// folder is not a source and stays out of the queue.
const RAW_EXTENSIONS = new Set(["md", "txt", "pdf"]);

const ICONS = {
  terminal: "M4 17l6-6-6-6M12 19h8",
  check: "M20 6 9 17l-5-5",
  minus: "M5 12h14",
  x: "M18 6 6 18M6 6l12 12",
  refresh: "M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.7 3L21 8M21 3v5h-5 M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.7-3L3 16M3 21v-5h5",
};

// Built with createElementNS rather than Obsidian's createSvg helper, whose
// availability varies by version. This works on any Obsidian build.
const SVG_NS = "http://www.w3.org/2000/svg";

function addIcon(parent, name) {
  const svg = document.createElementNS(SVG_NS, "svg");
  svg.setAttribute("viewBox", "0 0 24 24");
  svg.setAttribute("fill", "none");
  svg.setAttribute("aria-hidden", "true");
  const path = document.createElementNS(SVG_NS, "path");
  path.setAttribute("d", ICONS[name]);
  path.setAttribute("stroke", "currentColor");
  path.setAttribute("stroke-width", "1.75");
  path.setAttribute("stroke-linecap", "round");
  path.setAttribute("stroke-linejoin", "round");
  svg.appendChild(path);
  parent.appendChild(svg);
  return svg;
}

function formatBytes(n) {
  return `${n.toLocaleString("en-US")} B`;
}

function renderLeftPane(container, pipeline, onIngest, gate) {
  const pane = container.createDiv({ cls: "wd-pane" });

  const queue = pane.createDiv({ cls: "wd-block" });
  queue.createDiv({
    cls: "wd-eyebrow",
    text: `Queue — ${pipeline.unprocessed.length} unprocessed`,
  });
  if (pipeline.unprocessed.length === 0) {
    queue.createDiv({ cls: "wd-note", text: "Every raw source has a summary page." });
  }
  for (const file of pipeline.unprocessed) {
    const row = queue.createDiv({ cls: "wd-row" });
    const main = row.createDiv({ cls: "wd-row-main" });
    main.createDiv({ cls: "wd-row-name", text: file.name });
    main.createDiv({ cls: "wd-mono", text: formatBytes(file.size) });
    const btn = row.createEl("button", { cls: "wd-btn" });
    addIcon(btn, "terminal");
    btn.createSpan({ text: "Ingest" });
    if (gate && !gate.ok) {
      btn.disabled = true;
      btn.setAttr("title", gate.reason);
    } else {
      btn.addEventListener("click", () => onIngest(file, row));
    }
  }

  const done = pane.createDiv({ cls: "wd-block" });
  done.createDiv({ cls: "wd-eyebrow", text: `Ingested — ${pipeline.processed.length}` });
  const list = done.createDiv({ cls: "wd-done" });
  for (const file of pipeline.processed) {
    const row = list.createDiv({ cls: "wd-done-row" });
    const date = file.page.name.slice(0, 10);
    row.createSpan({ cls: "wd-mono", text: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : "—" });
    row.createSpan({ cls: "wd-done-name", text: file.name.replace(/\.md$/, "") });
  }

  if (pipeline.orphaned.length > 0) {
    const orphan = pane.createDiv({ cls: "wd-block" });
    orphan.createDiv({
      cls: "wd-eyebrow",
      text: `Orphaned — ${pipeline.orphaned.length}`,
    });
    for (const page of pipeline.orphaned) {
      const row = orphan.createDiv({ cls: "wd-done-row" });
      row.createSpan({ cls: "wd-done-name", text: page.name });
      row.createSpan({ cls: "wd-mono", text: page.rawPath || "no raw path" });
    }
  }

  return pane;
}

const STATUS_ICON = { covered: "check", partial: "minus", absent: "x" };

function renderRightPane(container, parsed, reconciled, cfg) {
  const pane = container.createDiv({ cls: "wd-pane" });
  pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" });

  const meta = parsed.meta || {};
  pane.createDiv({
    cls: "wd-mono",
    text: `${meta.script || "no script recorded"} — last synced ${meta.lastSynced || "never"}`,
  });
  if (cfg && cfg.script && meta.script && meta.script !== cfg.script) {
    pane.createDiv({
      cls: "wd-error",
      text: `This dashboard is configured for ${cfg.script}, but the coverage file tracks ${meta.script}.`,
    });
  }

  const counts = { covered: 0, partial: 0, absent: 0 };
  for (const row of parsed.rows) counts[row.status] += 1;
  const total = parsed.rows.length;

  if (total > 0) {
    const meter = pane.createDiv({ cls: "wd-meter" });
    meter.setAttr("role", "img");
    meter.setAttr(
      "aria-label",
      `Coverage: ${counts.covered} covered, ${counts.partial} partial, ${counts.absent} absent of ${total}`
    );
    for (const key of ["covered", "partial", "absent"]) {
      if (counts[key] === 0) continue;
      const seg = meter.createEl("i", { cls: `wd-seg-${key}` });
      seg.style.flex = String(counts[key]);
    }
    const key = pane.createDiv({ cls: "wd-key" });
    for (const name of ["covered", "partial", "absent"]) {
      const span = key.createSpan();
      span.createEl("i", { cls: `wd-seg-${name}` });
      span.createSpan({ text: `${counts[name]} ${name}` });
    }
  }

  if (parsed.errors.length > 0) {
    const box = pane.createDiv({ cls: "wd-error" });
    box.createDiv({ text: `${parsed.errors.length} unparseable row(s):` });
    for (const e of parsed.errors) {
      box.createDiv({ cls: "wd-mono", text: `line ${e.line} — ${e.reason}` });
    }
  }

  const table = pane.createEl("table", { cls: "wd-tbl" });
  const head = table.createEl("thead").createEl("tr");
  for (const h of ["Concept", "Status", "Station", ""]) head.createEl("th", { text: h });
  const body = table.createEl("tbody");

  for (const group of groupByStation(parsed.rows)) {
    const gr = body.createEl("tr", { cls: "wd-grp" });
    gr.createEl("td", { attr: { colspan: "4" }, text: `${group.station} — ${group.rows.length}` });
    for (const row of group.rows) {
      const tr = body.createEl("tr");
      // Obsidian's click handler resolves internal links via data-href, so both
      // attributes are required for the link to open the concept page.
      tr.createEl("td").createEl("a", {
        cls: "internal-link",
        text: row.concept,
        attr: { href: row.concept, "data-href": row.concept },
      });
      const stat = tr.createEl("td").createSpan({ cls: `wd-stat wd-${row.status}` });
      addIcon(stat, STATUS_ICON[row.status]);
      stat.createSpan({ text: row.status });
      tr.createEl("td", { cls: "wd-mono", text: row.stations.join(", ") || "—" });
      tr.createEl("td", { cls: "wd-pin", text: row.pinned ? "pinned" : "" });
    }
  }

  if (reconciled.unsynced.length > 0) {
    const box = pane.createDiv({ cls: "wd-block" });
    box.createDiv({ cls: "wd-eyebrow", text: `Unsynced — ${reconciled.unsynced.length}` });
    box.createDiv({
      cls: "wd-note",
      text: `Concept pages with no row. Run "sync script coverage": ${reconciled.unsynced.join(", ")}`,
    });
  }

  if (reconciled.stale.length > 0) {
    const box = pane.createDiv({ cls: "wd-block" });
    box.createDiv({ cls: "wd-eyebrow", text: `Stale — ${reconciled.stale.length}` });
    box.createDiv({
      cls: "wd-note",
      text: `Rows whose concept page is gone: ${reconciled.stale.map((r) => r.concept).join(", ")}`,
    });
  }

  return pane;
}

class WebinarDashPlugin extends PluginBase {
  async onload() {
    this.running = new Set();
    this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => {
      const cfg = parseConfig(source);
      const root = el.createDiv({ cls: "webinar-dash" });
      await this.renderAll(root, cfg);
    });
  }

  async renderAll(root, cfg) {
    root.empty();
    const gate = this.spawnGate();
    const refresh = () => { this.renderAll(root, cfg); };

    const bar = root.createDiv({ cls: "wd-bar" });
    const sync = bar.createEl("button", { cls: "wd-btn wd-btn-ghost" });
    addIcon(sync, "refresh");
    sync.createSpan({ text: "Sync coverage" });
    if (!gate.ok) {
      sync.disabled = true;
      sync.setAttr("title", gate.reason);
    } else {
      sync.addEventListener("click", () =>
        this.runClaude("__sync__", "sync script coverage", bar, "sync coverage", refresh)
      );
    }

    const grid = root.createDiv({ cls: "wd-grid" });

    try {
      const pipeline = await this.readPipeline(cfg);
      renderLeftPane(grid, pipeline, (file, rowEl) => this.runIngest(file, rowEl, refresh), gate);
    } catch (err) {
      grid.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` });
    }

    try {
      const { parsed, reconciled } = await this.readCoverage(cfg);
      renderRightPane(grid, parsed, reconciled, cfg);
    } catch (err) {
      grid.createDiv({ cls: "wd-error", text: `Coverage failed: ${err.message}` });
    }
  }

  async readCoverage(cfg) {
    const file = this.app.vault.getAbstractFileByPath(cfg.coverage);
    if (!file) throw new Error(`no coverage file at ${cfg.coverage}`);
    const parsed = parseCoverageTable(await this.app.vault.cachedRead(file));

    const conceptDir = cfg.conceptDir.replace(/\/+$/, "") + "/";
    const conceptNames = this.app.vault
      .getFiles()
      .filter((f) => f.path.startsWith(conceptDir) && f.extension === "md")
      .map((f) => f.basename);

    return { parsed, reconciled: reconcileConcepts(parsed.rows, conceptNames) };
  }

  async readPipeline(cfg) {
    const all = this.app.vault.getFiles();
    const rawDir = cfg.rawDir.replace(/\/+$/, "") + "/";
    const wikiDir = cfg.wikiSourceDir.replace(/\/+$/, "") + "/";

    // CLAUDE.md documents raw/sources as holding "markdown/text/pdf exports".
    // Allow-listing only "md" would hide the others from the queue with no
    // warning — the same silent-invisibility failure this dashboard exists to
    // remove. Wiki source pages below stay markdown-only; those really are .md.
    const rawFiles = all
      .filter((f) => f.path.startsWith(rawDir) && RAW_EXTENSIONS.has(f.extension))
      .map((f) => ({ path: f.path, name: f.name, size: f.stat.size }));

    const pageFiles = all.filter((f) => f.path.startsWith(wikiDir) && f.extension === "md");
    const sourcePages = [];
    for (const f of pageFiles) {
      const text = await this.app.vault.cachedRead(f);
      sourcePages.push({ path: f.path, name: f.name, rawPath: extractRawPath(text) });
    }

    return derivePipeline({ rawFiles, sourcePages });
  }

  vaultPath() {
    const adapter = this.app.vault.adapter;
    if (typeof adapter.getBasePath === "function") return adapter.getBasePath();
    return null;
  }

  spawnGate() {
    if (!this.vaultPath()) {
      return { ok: false, reason: "Needs desktop Obsidian." };
    }
    try {
      require("child_process");
    } catch (_) {
      return { ok: false, reason: "child_process unavailable — needs desktop Obsidian." };
    }
    return { ok: true, reason: "" };
  }

  // One subprocess runner for every button. `key` is what makes a run unique in
  // `this.running` — a file path for ingest, a constant for sync — so the guard
  // survives the row re-renders that a DOM-scoped guard could not.
  runClaude(key, prompt, hostEl, label, onSuccess) {
    const Notice = OB ? OB.Notice : null;
    const notify = (msg) => { if (Notice) new Notice(msg); };

    if (this.running.has(key)) {
      notify(`Already running: ${label}.`);
      return;
    }

    const gate = this.spawnGate();
    if (!gate.ok) {
      notify(gate.reason);
      return;
    }
    const { spawn } = require("child_process");
    const base = this.vaultPath();

    // A retry reuses the same host element. Clear the previous run's status and
    // output so they are replaced rather than stacked.
    hostEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove());

    const button = hostEl.querySelector("button");
    if (button) button.disabled = true;
    this.running.add(key);

    const status = hostEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" });
    const output = hostEl.createDiv({ cls: "wd-output", text: "" });
    const started = Date.now();
    const timer = window.setInterval(() => {
      status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`);
    }, 1000);
    this.registerInterval(timer);

    // Bounded as it accumulates, not only when displayed.
    let buffered = "";
    const append = (text) => {
      buffered = (buffered + text).slice(-8000);
      output.setText(buffered);
      output.scrollTop = output.scrollHeight;
    };

    // Node can emit both `error` and `close` for one failure. `settled` keeps the
    // first, more specific message instead of letting `exit null` overwrite it.
    let settled = false;
    const finish = (cls, text) => {
      if (settled) return;
      settled = true;
      window.clearInterval(timer);
      this.running.delete(key);
      status.className = `wd-status ${cls}`;
      status.setText(text);
      if (button) button.disabled = false;
    };

    let child;
    try {
      // See the C1 comment in runIngest: no shell, deliberately.
      child = spawn("claude", ["-p", prompt], { cwd: base });
    } catch (err) {
      append(`\nCould not start claude: ${err.message}`);
      finish("wd-status-failed", "failed");
      return;
    }

    child.stdout.setEncoding("utf8");
    child.stderr.setEncoding("utf8");
    child.stdout.on("data", append);
    child.stderr.on("data", append);

    child.on("error", (err) => {
      append(`\nCould not start claude: ${err.message}\nIs it on PATH?`);
      finish("wd-status-failed", "failed");
    });

    child.on("close", (code) => {
      const secs = Math.round((Date.now() - started) / 1000);
      if (code === 0) {
        finish("wd-status-done", `done in ${secs}s`);
        if (onSuccess) onSuccess();
      } else {
        finish("wd-status-failed", `failed - exit ${code}`);
      }
    });
  }

  runIngest(file, rowEl, onSuccess) {
    const Notice = OB ? OB.Notice : null;

    if (!isSafeFilename(file.name)) {
      rowEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove());
      rowEl.createDiv({
        cls: "wd-output",
        text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`,
      });
      return;
    }

    // `claude` resolves to a real .exe here, so libuv finds it via PATH and
    // PATHEXT with no shell involved. Do NOT add `shell: true`: with a shell,
    // Node concatenates argv without quoting and cmd.exe re-tokenizes, so the
    // prompt arrives as two arguments and the filename is silently discarded.
    // Verified: shell:true yields ["-p","ingest","Webinar script.md"], where the
    // program sees only "ingest". If a .cmd shim ever needs supporting, use the
    // single-string form spawn(`claude -p "ingest ${name}"`, { shell: true }).
    this.runClaude(
      file.path,
      `ingest "${file.name}"`,
      rowEl,
      file.name,
      () => {
        if (Notice) new Notice(`Ingested ${file.name}.`);
        if (onSuccess) onSuccess();
      }
    );
  }
}

module.exports = WebinarDashPlugin;
module.exports.default = WebinarDashPlugin;
module.exports.__test__ = {
  parseConfig, extractRawPath, derivePipeline,
  parseCoverageTable, groupByStation, reconcileConcepts,
  STATIONS, DEFAULTS, isSafeFilename,
};
