/**
 * @file Proof gallery (C): the screenshots a QC agent captures live at
 * `<lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/*.png` (plus a
 * `ticket/REPORT.html`), grouped by feature slug — the same slug Task B's
 * `lane_features` already tracks. Ported from Shipyard's
 * `dashboard/server/services/proof.js`; the "current feature" special-casing
 * that file needed (it has no DB, so it parsed a branch regex) is dropped —
 * CCAM's `lane_features` row already IS that source of truth.
 *
 * Path containment is the entire security surface here: every path this
 * module touches is resolved, realpath'd, and checked to be inside the
 * lane's own proof root before any read/write/delete reaches `fs`. Ported
 * near-verbatim from Shipyard rather than "cleaned up" — this is the one part
 * of C worth reviewing line-by-line, not abstracting.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */

const fs = require("node:fs");
const path = require("node:path");

const lanesLib = require("./lanes");
const laneFeatures = require("./lane-features");

const IMAGE_RE = /\.(png|jpg|jpeg)$/i;
const SERVEABLE_RE = /\.(png|jpg|jpeg|html)$/i;

/** Where a lane's proof lives — inside its OWN cwd, not LANES_ROOT (an
 *  adopted lane's cwd need not be under LANES_ROOT at all). */
function proofRoot(lane) {
  return path.join(lane.cwd, ".playwright-mcp", "proof");
}

const badName = (s) =>
  !s || typeof s !== "string" || s.includes("/") || s.includes("\\") || s.includes("..");

function countFiles(dir) {
  let c = 0;
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
    if (e.isDirectory()) c += countFiles(path.join(dir, e.name));
    else c++;
  }
  return c;
}

function latestMtime(dir) {
  let mt = 0;
  const walk = (d) => {
    for (const e of fs.readdirSync(d, { withFileTypes: true })) {
      const full = path.join(d, e.name);
      if (e.isDirectory()) walk(full);
      else {
        const s = fs.statSync(full);
        if (s.mtimeMs > mt) mt = s.mtimeMs;
      }
    }
  };
  try {
    walk(dir);
  } catch {
    /* best effort */
  }
  return mt / 1000;
}

/**
 * Every feature slug with proof (or an activated-but-not-yet-shot feature
 * from `lane_features`), most recently touched first.
 *
 * @param {number} laneId
 * @returns {Array<{slug: string, groups: object, ticket_report: string, mtime: number}>}
 */
function listProof(laneId) {
  const lane = lanesLib.getLane(laneId);
  if (!lane) return [];
  const base = proofRoot(lane);
  const feats = [];

  if (fs.existsSync(base) && fs.statSync(base).isDirectory()) {
    for (const slug of fs.readdirSync(base).sort()) {
      const d = path.join(base, slug);
      try {
        if (!fs.statSync(d).isDirectory()) continue;
      } catch {
        continue;
      }

      const groups = {};
      for (const grp of fs.readdirSync(d).sort()) {
        if (grp === "ticket") continue;
        const g = path.join(d, grp);
        try {
          if (!fs.statSync(g).isDirectory()) continue;
        } catch {
          continue;
        }
        const imgs = fs
          .readdirSync(g)
          .filter((f) => IMAGE_RE.test(f))
          .sort();
        if (imgs.length) groups[grp] = imgs;
      }

      let ticketReport = "";
      if (fs.existsSync(path.join(d, "ticket", "REPORT.html"))) {
        ticketReport = `${slug}/ticket/REPORT.html`;
      }

      feats.push({ slug, groups, ticket_report: ticketReport, mtime: latestMtime(d) });
    }
  }

  // Surface an activated feature with no proof dir yet.
  const have = new Set(feats.map((f) => f.slug));
  for (const f of laneFeatures.listFeatures(laneId)) {
    if (!have.has(f.slug)) {
      feats.push({ slug: f.slug, groups: {}, ticket_report: "", mtime: 0 });
    }
  }

  feats.sort((a, b) => (b.mtime || 0) - (a.mtime || 0));
  return feats;
}

/**
 * Resolve one proof file, containment-checked. Returns the absolute realpath
 * or null on any failure — missing lane, missing file, traversal, symlink
 * escape, or wrong extension.
 */
function proofFile(laneId, slug, group, file) {
  const lane = lanesLib.getLane(laneId);
  if (!lane) return null;

  let base;
  try {
    base = fs.realpathSync(proofRoot(lane));
  } catch {
    return null;
  }

  let p;
  try {
    p = fs.realpathSync(path.join(base, slug, group, file));
  } catch {
    return null;
  }

  if (
    (p === base || p.startsWith(base + path.sep)) &&
    fs.existsSync(p) &&
    fs.statSync(p).isFile() &&
    SERVEABLE_RE.test(p)
  ) {
    return p;
  }
  return null;
}

/**
 * Delete proof screenshots at one of three granularities:
 *   { slug, group, images: [...] } -> unlink those files, prune the group dir if now empty
 *   { slug, group }                -> remove the whole group dir
 *   { slug }                       -> remove every group dir EXCEPT ticket/
 * Never touches `ticket/`, never reaches outside the lane's proof root.
 *
 * @returns {{deleted: number}}
 */
function deleteProof(laneId, { slug, group, images } = {}) {
  const lane = lanesLib.getLane(laneId);
  if (!lane) throw Object.assign(new Error(`no lane ${laneId}`), { code: "ENOLANE" });
  if (badName(slug)) throw Object.assign(new Error("invalid slug"), { code: "EBADPATH" });

  let base;
  try {
    base = fs.realpathSync(proofRoot(lane));
  } catch {
    throw Object.assign(new Error("no proof for this lane"), { code: "ENOFEATURE" });
  }
  const inBase = (p) => p === base || p.startsWith(base + path.sep);

  let featDir;
  try {
    featDir = fs.realpathSync(path.join(base, slug));
  } catch {
    throw Object.assign(new Error("feature not found"), { code: "ENOFEATURE" });
  }
  if (!inBase(featDir) || !fs.statSync(featDir).isDirectory()) {
    throw Object.assign(new Error("invalid feature dir"), { code: "EBADPATH" });
  }

  let deleted = 0;

  if (group !== undefined) {
    if (badName(group) || group === "ticket") {
      throw Object.assign(new Error("invalid group"), { code: "EBADPATH" });
    }
    let grpDir;
    try {
      grpDir = fs.realpathSync(path.join(featDir, group));
    } catch {
      throw Object.assign(new Error("group not found"), { code: "ENOFEATURE" });
    }
    if (!inBase(grpDir) || !fs.statSync(grpDir).isDirectory()) {
      throw Object.assign(new Error("invalid group dir"), { code: "EBADPATH" });
    }

    if (Array.isArray(images) && images.length) {
      for (const img of images) {
        if (badName(img) || !IMAGE_RE.test(img)) {
          throw Object.assign(new Error("invalid image"), { code: "EBADPATH" });
        }
        let f;
        try {
          f = fs.realpathSync(path.join(grpDir, img));
        } catch {
          continue;
        }
        if (inBase(f) && fs.statSync(f).isFile() && IMAGE_RE.test(f)) {
          fs.unlinkSync(f);
          deleted++;
        }
      }
      try {
        if (!fs.readdirSync(grpDir).length) fs.rmdirSync(grpDir);
      } catch {
        /* ok */
      }
    } else {
      deleted = countFiles(grpDir);
      fs.rmSync(grpDir, { recursive: true, force: true });
    }
  } else {
    for (const e of fs.readdirSync(featDir, { withFileTypes: true })) {
      if (!e.isDirectory() || e.name === "ticket") continue;
      const g = path.join(featDir, e.name);
      deleted += countFiles(g);
      fs.rmSync(g, { recursive: true, force: true });
    }
  }

  return { deleted };
}

/**
 * Port of Shipyard's `ensure_proof_link`: converge the clone-root `proof/`
 * onto `.playwright-mcp/proof` so every proof write lands in one place
 * regardless of an MCP's `--output-dir` convention. A pre-existing REAL
 * `proof/` dir is merged into the canonical root first (no clobber), then
 * replaced with a relative symlink. Idempotent — a no-op when the symlink
 * already points at the right place.
 *
 * Never auto-invoked by this codebase; exposed only as the explicit
 * `ccam lanes proof-link` primitive.
 *
 * @param {object} lane - A hydrated lane row (must have `cwd`).
 * @returns {{linked: boolean}} `linked: false` when nothing changed.
 */
function ensureProofLink(lane) {
  const canon = proofRoot(lane);
  const stray = path.join(lane.cwd, "proof");
  const relTarget = path.join(".playwright-mcp", "proof");

  let strayStat = null;
  try {
    strayStat = fs.lstatSync(stray);
  } catch {
    /* doesn't exist yet */
  }

  if (strayStat && strayStat.isSymbolicLink()) {
    if (fs.readlinkSync(stray) === relTarget) return { linked: false };
    fs.rmSync(stray, { force: true });
  } else if (strayStat && strayStat.isDirectory()) {
    fs.mkdirSync(canon, { recursive: true });
    mergeDirInto(stray, canon);
    fs.rmSync(stray, { recursive: true, force: true });
  } else if (strayStat) {
    // A stray file named `proof` — refuse to clobber; leave it alone.
    return { linked: false };
  }

  fs.mkdirSync(canon, { recursive: true });
  fs.symlinkSync(relTarget, stray);
  return { linked: true };
}

/** Recursively copy `src`'s contents into `dest`, never overwriting an
 *  existing file at the destination (no-clobber merge). */
function mergeDirInto(src, dest) {
  for (const e of fs.readdirSync(src, { withFileTypes: true })) {
    const s = path.join(src, e.name);
    const d = path.join(dest, e.name);
    if (e.isDirectory()) {
      fs.mkdirSync(d, { recursive: true });
      mergeDirInto(s, d);
    } else if (!fs.existsSync(d)) {
      fs.copyFileSync(s, d);
    }
  }
}

module.exports = {
  proofRoot,
  listProof,
  proofFile,
  deleteProof,
  ensureProofLink,
};
