#!/usr/bin/env node
// Build an Obsidian-style D3 force-directed graph from a folder's contents.
// Nodes = files + folders. Edges = parent -> child (folder containment).
// Outputs a single self-contained HTML file. Requires Node >= 18.3 (util.parseArgs).

import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { resolve, relative, join, dirname, basename, extname, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const DEFAULT_IGNORES = new Set([
  ".git", ".hg", ".svn",
  "node_modules", ".next", ".nuxt", ".turbo", ".vercel",
  "dist", "build", "out", ".output", ".cache", ".parcel-cache",
  "__pycache__", ".venv", "venv", "env", ".tox",
  ".pytest_cache", ".mypy_cache", ".ruff_cache",
  ".idea", ".vscode", ".DS_Store",
  "target", "coverage", ".nyc_output", "vendor",
]);

function parseCli() {
  const { values, positionals } = parseArgs({
    allowPositionals: true,
    options: {
      output: { type: "string", short: "o" },
      ignore: { type: "string", multiple: true, default: [] },
      "no-default-ignores": { type: "boolean", default: false },
      "max-files": { type: "string", default: "5000" },
      help: { type: "boolean", short: "h", default: false },
    },
  });

  if (values.help || positionals.length === 0) {
    process.stdout.write(usage());
    process.exit(values.help ? 0 : 1);
  }

  return {
    folder: positionals[0],
    output: values.output ?? null,
    extraIgnores: values.ignore ?? [],
    noDefaultIgnores: values["no-default-ignores"],
    maxFiles: Number.parseInt(values["max-files"], 10) || 5000,
  };
}

function usage() {
  return `Usage: node build_graph.mjs <folder> [options]

Render a folder's contents as an Obsidian-style D3 force graph (single HTML file).

Options:
  -o, --output <path>      Output HTML path (default: <folder>/folder-graph.html)
      --ignore <name>      Extra directory/file name to skip (repeatable)
      --no-default-ignores Drop the built-in skip list (.git, node_modules, dist, ...)
      --max-files <N>      Cap on file nodes (default: 5000; folders are not capped)
  -h, --help               Show this help
`;
}

function walk(rootAbs, ignores, maxFiles, outputAbs) {
  const nodes = [];
  const links = [];
  const seen = new Map();

  const nodeId = (absPath) => {
    if (absPath === rootAbs) return ".";
    return relative(rootAbs, absPath).split(sep).join("/");
  };

  const addNode = (absPath, kind) => {
    const id = nodeId(absPath);
    if (seen.has(id)) return id;
    const ext = kind === "file"
      ? extname(absPath).slice(1).toLowerCase()
      : "";
    const depth = absPath === rootAbs
      ? 0
      : relative(rootAbs, absPath).split(sep).length;
    nodes.push({
      id,
      name: absPath === rootAbs ? basename(rootAbs) : basename(absPath),
      kind,
      ext,
      depth,
      path: absPath,
    });
    seen.set(id, nodes.length - 1);
    return id;
  };

  addNode(rootAbs, "folder");

  let fileCount = 0;
  let truncated = false;

  // Iterative DFS, sorted for stable output.
  const stack = [rootAbs];
  while (stack.length > 0) {
    const dirAbs = stack.pop();
    const parentId = nodeId(dirAbs);

    let entries;
    try {
      entries = readdirSync(dirAbs, { withFileTypes: true });
    } catch {
      continue; // permission denied, vanished, etc.
    }

    // Filter and sort: dirs first then files, each alphabetical.
    const dirs = [];
    const files = [];
    for (const e of entries) {
      if (ignores.has(e.name)) continue;
      let isDir = e.isDirectory();
      let isFile = e.isFile();
      if (e.isSymbolicLink()) {
        try {
          const st = statSync(join(dirAbs, e.name));
          isDir = st.isDirectory();
          isFile = st.isFile();
        } catch {
          continue;
        }
      }
      if (isDir) dirs.push(e.name);
      else if (isFile) files.push(e.name);
    }
    dirs.sort();
    files.sort();

    for (const d of dirs) {
      const childAbs = join(dirAbs, d);
      addNode(childAbs, "folder");
      links.push({ source: parentId, target: nodeId(childAbs) });
      stack.push(childAbs);
    }

    for (const f of files) {
      if (fileCount >= maxFiles) {
        truncated = true;
        break;
      }
      const childAbs = join(dirAbs, f);
      if (childAbs === outputAbs) continue; // don't include the file we're writing
      addNode(childAbs, "file");
      links.push({ source: parentId, target: nodeId(childAbs) });
      fileCount++;
    }
    if (truncated) break;
  }

  return {
    nodes,
    links,
    stats: {
      root: rootAbs,
      node_count: nodes.length,
      link_count: links.length,
      file_count: fileCount,
      truncated,
      max_files: maxFiles,
    },
  };
}

function htmlEscape(s) {
  return s
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;");
}

function render(template, nodes, links, stats, title) {
  const payload = { nodes, links, stats, title };
  // Escape </ so the JSON can't break out of <script>
  const json = JSON.stringify(payload).replace(/<\//g, "<\\/");
  return template
    .replaceAll("{{TITLE}}", htmlEscape(title))
    .replaceAll("{{DATA_JSON}}", json);
}

function main() {
  const args = parseCli();

  const folderAbs = resolve(args.folder);
  let st;
  try {
    st = statSync(folderAbs);
  } catch {
    process.stderr.write(`error: ${folderAbs} not found\n`);
    process.exit(1);
  }
  if (!st.isDirectory()) {
    process.stderr.write(`error: ${folderAbs} is not a directory\n`);
    process.exit(1);
  }

  const ignores = args.noDefaultIgnores ? new Set() : new Set(DEFAULT_IGNORES);
  for (const i of args.extraIgnores) ignores.add(i);

  const outputAbs = args.output
    ? resolve(args.output)
    : join(folderAbs, "folder-graph.html");

  const templatePath = resolve(__dirname, "..", "assets", "graph.template.html");
  const template = readFileSync(templatePath, "utf8");

  const { nodes, links, stats } = walk(folderAbs, ignores, args.maxFiles, outputAbs);
  const title = `folder-graph: ${basename(folderAbs)}`;
  const html = render(template, nodes, links, stats, title);

  writeFileSync(outputAbs, html, "utf8");

  process.stdout.write(`wrote ${outputAbs}\n`);
  process.stdout.write(
    `  nodes: ${stats.node_count}  links: ${stats.link_count}` +
    `  files: ${stats.file_count}` +
    (stats.truncated ? "  (truncated)" : "") + "\n"
  );
}

main();
