"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 };
}

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) {
  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" });
    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;
}

class WebinarDashPlugin extends PluginBase {
  async onload() {
    this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => {
      const cfg = parseConfig(source);
      const root = el.createDiv({ cls: "webinar-dash" });
      try {
        const pipeline = await this.readPipeline(cfg);
        renderLeftPane(root, pipeline, () => {});
      } catch (err) {
        root.createDiv({ cls: "wd-error", text: `Dashboard failed: ${err.message}` });
      }
    });
  }

  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 });
  }
}

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