#!/usr/bin/env bash
# verify-state.sh — machine-checkable slice of CHECKPOINTS.md (C2, C6, C8)
# plus the hard rules in core/roles.md. Run from a project root (the
# directory containing feature_list.json), or pass that directory as $1.
#
# Canonical copy: qatrolabs-core/scripts/verify-state.sh. Project instances
# are verbatim copies; scripts/verify.sh checks they haven't drifted.
#
# Not automated here (still human/reviewer judgment): C1 doc quality,
# C3 architecture conformance, C4 test quality, C5 session hygiene.
# C7's machine-checkable slice (INDEX coverage, lesson frontmatter,
# category recurrence -> promotion) is S8 below; whether a promoted rule
# is a *good* rule stays human judgment. C8's "requirements.md never
# edited after done" is
# covered indirectly: S7 hash-pins the approved requirements via
# specs/<name>/approval.md (written by scripts/approve-spec.sh) and fails
# on any post-approval edit.
set -u
ROOT="${1:-.}"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m'
ok()   { printf "${GREEN}[OK]${NC}    %s\n" "$1"; }
warn() { printf "${YELLOW}[WARN]${NC}  %s\n" "$1"; }
fail() { printf "${RED}[FAIL]${NC}  %s\n" "$1"; }

if [ ! -f "$ROOT/feature_list.json" ]; then
  fail "no feature_list.json in '$ROOT' — run from a project root or pass it as \$1"
  exit 1
fi

if ! command -v node >/dev/null 2>&1; then
  fail "node is required to parse feature_list.json"
  exit 1
fi

node - "$ROOT" <<'NODE'
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");

const ROOT = process.argv[2];
const p = (...seg) => path.join(ROOT, ...seg);
let failures = 0, warnings = 0;
const ok = (m) => console.log(`[OK]    ${m}`);
const warn = (m) => { warnings++; console.log(`[WARN]  ${m}`); };
const fail = (m) => { failures++; console.log(`[FAIL]  ${m}`); };

let data;
try {
  data = JSON.parse(fs.readFileSync(p("feature_list.json"), "utf8"));
} catch (e) {
  fail(`feature_list.json unparseable: ${e.message}`);
  process.exit(1);
}

const VALID = new Set(["pending", "spec_ready", "in_progress", "done", "blocked"]);
const features = data.features || [];
const byName = new Map(features.map(f => [f.name, f]));

console.log("── S1. feature_list.json schema ─────────────────────────");
const ids = new Set();
for (const f of features) {
  if (!Number.isInteger(f.id)) fail(`feature "${f.name}": id must be an integer (got ${JSON.stringify(f.id)})`);
  else if (ids.has(f.id)) fail(`duplicate feature id ${f.id}`);
  else ids.add(f.id);
  if (typeof f.name !== "string" || !/^[a-z0-9]+(_[a-z0-9]+)*$/.test(f.name))
    fail(`feature ${f.id}: name must be a snake_case slug (got ${JSON.stringify(f.name)})`);
  if (!VALID.has(f.status)) fail(`feature ${f.id} (${f.name}): invalid status "${f.status}"`);
  if (!Array.isArray(f.acceptance) || f.acceptance.length === 0)
    fail(`feature ${f.id} (${f.name}): acceptance must be a non-empty array`);
}
if (failures === 0) ok(`schema valid (${features.length} features)`);

console.log("");
console.log("── S2. C2 — state coherence ─────────────────────────────");
const inProgress = features.filter(f => f.status === "in_progress");
if (inProgress.length > 1)
  fail(`${inProgress.length} features in_progress (max 1): ${inProgress.map(f => f.name).join(", ")}`);
else ok(`at most one feature in_progress (found ${inProgress.length})`);

if (fs.existsSync(p("progress", "current.md"))) {
  if (inProgress.length === 1) {
    const current = fs.readFileSync(p("progress", "current.md"), "utf8");
    if (!current.includes(inProgress[0].name))
      warn(`progress/current.md doesn't mention the in_progress feature "${inProgress[0].name}"`);
    else ok("progress/current.md mentions the in_progress feature");
  }
} else {
  fail("progress/current.md missing");
}

console.log("");
console.log("── S3. C6/C8 — specs, deltas, extends ───────────────────");
const needsSpec = new Set(["spec_ready", "in_progress", "done"]);
for (const f of features) {
  if (f.extends !== undefined) {
    const target = byName.get(f.extends);
    if (!target) { fail(`feature ${f.id} (${f.name}): extends unknown feature "${f.extends}"`); continue; }
    if (!target.sdd) fail(`feature ${f.id} (${f.name}): extends target "${f.extends}" is not "sdd": true`);
    if (needsSpec.has(f.status)) {
      if (target.status !== "done")
        fail(`feature ${f.id} (${f.name}): is ${f.status} but extends target "${f.extends}" is ${target.status}, not done`);
      const deltasDir = p("specs", f.extends, "deltas");
      const deltaDir = fs.existsSync(deltasDir)
        ? fs.readdirSync(deltasDir).find(d => /^\d{4}-/.test(d) && d.slice(5) === f.name)
        : undefined;
      if (!deltaDir) {
        fail(`feature ${f.id} (${f.name}): is ${f.status} but no specs/${f.extends}/deltas/000N-${f.name}/ folder`);
      } else {
        for (const fn of ["requirements-delta.md", "design-delta.md", "tasks-delta.md"])
          if (!fs.existsSync(path.join(deltasDir, deltaDir, fn)))
            fail(`feature ${f.id} (${f.name}): missing specs/${f.extends}/deltas/${deltaDir}/${fn}`);
      }
    }
    continue;
  }
  if (f.sdd && needsSpec.has(f.status)) {
    for (const fn of ["requirements.md", "design.md", "tasks.md"])
      if (!fs.existsSync(p("specs", f.name, fn)))
        fail(`feature ${f.id} (${f.name}): is ${f.status} but missing specs/${f.name}/${fn}`);
  }
}
ok("spec/delta folder checks done");

console.log("");
console.log("── S4. C6 — tasks checked off on done features ──────────");
for (const f of features) {
  if (!f.sdd || f.status !== "done" || f.extends !== undefined) continue;
  const tasksPath = p("specs", f.name, "tasks.md");
  if (!fs.existsSync(tasksPath)) continue; // already failed in S3
  const unchecked = fs.readFileSync(tasksPath, "utf8").split("\n").filter(l => /^\s*-\s*\[ \]/.test(l));
  if (unchecked.length)
    fail(`feature ${f.id} (${f.name}): done but ${unchecked.length} unchecked task(s) in specs/${f.name}/tasks.md`);
}
ok("task checklist checks done");

console.log("");
console.log("── S5. C6 — EARS lint on requirements files ─────────────");
const SOFT = /\b(may|could|should|supports?|might)\b/i;
// Every requirement must use one of the five EARS patterns
// (core/docs/sdd-process.md): Ubiquitous ("The system SHALL"),
// Event (WHEN), State (WHILE), Optional (WHERE), Unwanted (IF ... THEN).
const EARS_START = /^(The system SHALL\b|WHEN\b|WHILE\b|WHERE\b|IF\b)/;
const findDeltaDir = (f) => {
  const deltasDir = p("specs", f.extends, "deltas");
  const deltaDir = fs.existsSync(deltasDir)
    ? fs.readdirSync(deltasDir).find(d => /^\d{4}-/.test(d) && d.slice(5) === f.name)
    : undefined;
  return deltaDir ? path.join(deltasDir, deltaDir) : undefined;
};
const lintEars = (reqPath) => {
  const rel = path.relative(ROOT, reqPath);
  const body = fs.readFileSync(reqPath, "utf8");
  const blocks = body.split(/^## /m).slice(1).filter(b => /^R\d+\b/.test(b));
  if (blocks.length === 0) { fail(`${rel}: no "## R<n>" requirement found`); return; }
  for (const b of blocks) {
    const lines = b.split("\n");
    const heading = lines[0].trim();
    const id = heading.match(/^R\d+/)[0];
    // "## R6 (removes R2)" is a pure removal annotation (delta files,
    // core/docs/change-management.md) — it carries no behavior, so no SHALL.
    if (/^R\d+\s*\(removes\s+R\d+(,\s*R\d+)*\)$/.test(heading)) continue;
    const text = lines.slice(1).join("\n").trim();
    if (!EARS_START.test(text))
      fail(`${rel} ${id}: does not start with an EARS pattern (The system SHALL / WHEN / WHILE / WHERE / IF)`);
    if (/^IF\b/.test(text) && !/\bTHEN\b/.test(text))
      fail(`${rel} ${id}: IF requirement has no THEN`);
    if (!/\bSHALL\b/.test(text)) fail(`${rel} ${id}: no SHALL`);
    const shallCount = (text.match(/\bSHALL(?: NOT)?\b/g) || []).length;
    if (shallCount > 1) fail(`${rel} ${id}: ${shallCount} SHALLs — split into separate requirements`);
    if (SOFT.test(text)) warn(`${rel} ${id}: soft verb (may/could/should/supports) — EARS wants SHALL only`);
  }
};
for (const f of features) {
  if (!f.sdd || !needsSpec.has(f.status)) continue;
  if (f.extends !== undefined) {
    const dir = findDeltaDir(f);
    if (!dir) continue; // already failed in S3
    const reqPath = path.join(dir, "requirements-delta.md");
    if (fs.existsSync(reqPath)) lintEars(reqPath); // missing file already failed in S3
  } else {
    const reqPath = p("specs", f.name, "requirements.md");
    if (fs.existsSync(reqPath)) lintEars(reqPath); // missing file already failed in S3
  }
}
ok("EARS lint done");

console.log("");
console.log("── S6. C6 — R<n> -> test traceability recorded ──────────");
for (const f of features) {
  if (!f.sdd || f.status !== "done" || f.extends !== undefined) continue;
  const reqPath = p("specs", f.name, "requirements.md");
  const implPath = p("progress", `impl_${f.name}.md`);
  if (!fs.existsSync(reqPath)) continue;
  const rids = [...fs.readFileSync(reqPath, "utf8").matchAll(/^## (R\d+)/gm)].map(m => m[1]);
  if (!fs.existsSync(implPath)) {
    fail(`feature ${f.id} (${f.name}): done but no progress/impl_${f.name}.md with a traceability map`);
    continue;
  }
  const impl = fs.readFileSync(implPath, "utf8");
  for (const rid of rids)
    if (!new RegExp(`\\b${rid}\\b`).test(impl))
      fail(`feature ${f.id} (${f.name}): ${rid} not mentioned in progress/impl_${f.name}.md traceability map`);
}
ok("traceability presence checks done");

console.log("");
console.log("── S7. approval gate — recorded and hash-pinned ─────────");
const pastGate = new Set(["in_progress", "done"]);
for (const f of features) {
  if (!f.sdd || !pastGate.has(f.status)) continue;
  let dir, reqFile;
  if (f.extends !== undefined) {
    dir = findDeltaDir(f);
    if (!dir) continue; // already failed in S3
    reqFile = "requirements-delta.md";
  } else {
    dir = p("specs", f.name);
    reqFile = "requirements.md";
  }
  const approvalPath = path.join(dir, "approval.md");
  if (!fs.existsSync(approvalPath)) {
    fail(`feature ${f.id} (${f.name}): is ${f.status} but has no ${approvalPath} — approval gate skipped (run approve-spec.sh at spec_ready)`);
    continue;
  }
  const approval = fs.readFileSync(approvalPath, "utf8");
  const m = approval.match(/^requirements_sha256:\s*([0-9a-f]{64})\s*$/m);
  if (!m) {
    fail(`${approvalPath}: no valid "requirements_sha256:" line`);
    continue;
  }
  const reqPath = path.join(dir, reqFile);
  if (!fs.existsSync(reqPath)) continue; // already failed in S3
  const actual = crypto.createHash("sha256").update(fs.readFileSync(reqPath)).digest("hex");
  if (actual !== m[1])
    fail(`feature ${f.id} (${f.name}): ${reqFile} was edited after approval (hash mismatch vs ${approvalPath}) — changes go through a delta`);
}
ok("approval gate checks done");

console.log("");
console.log("── S8. C7 — memory: index coverage and promotion ────────");
const lessonsDir = p("memory", "lessons");
if (fs.existsSync(p("memory"))) {
  const lessonFiles = fs.existsSync(lessonsDir)
    ? fs.readdirSync(lessonsDir).filter(n => n.endsWith(".md")).sort()
    : [];
  const indexPath = p("memory", "INDEX.md");
  const index = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, "utf8") : null;
  if (index === null && lessonFiles.length)
    fail("memory/INDEX.md missing but memory/lessons/ has entries");
  const byCategory = new Map();
  for (const file of lessonFiles) {
    const rel = `memory/lessons/${file}`;
    const body = fs.readFileSync(path.join(lessonsDir, file), "utf8");
    const fm = body.match(/^---\n([\s\S]*?)\n---/);
    const field = (k) => fm && (fm[1].match(new RegExp(`^${k}:\\s*(.+)$`, "m")) || [])[1]?.trim();
    if (!fm) { fail(`${rel}: no frontmatter (see core/memory-model.md schema)`); continue; }
    const category = field("category");
    // Legacy lessons (pre-category schema) warn instead of failing, so
    // upgrading an existing project never turns a green harness red.
    // Uncategorized lessons are excluded from recurrence counting.
    if (!category) warn(`${rel}: no "category:" in frontmatter — add one so the promotion rule can count it (core/memory-model.md)`);
    else {
      if (!byCategory.has(category)) byCategory.set(category, []);
      byCategory.get(category).push({ rel, promoted: field("promoted") });
    }
    if (index !== null && !index.includes(rel))
      fail(`memory/INDEX.md has no entry for ${rel}`);
  }
  for (const [category, entries] of byCategory) {
    if (entries.length < 2) continue;
    const unpromoted = entries.filter(e => !e.promoted);
    if (unpromoted.length)
      warn(`lesson category "${category}" recurred ${entries.length}x — promote it to a hard rule and record "promoted: <where>" in: ${unpromoted.map(e => e.rel).join(", ")}`);
    else
      ok(`lesson category "${category}" recurred ${entries.length}x and is promoted`);
  }
  ok(`memory checks done (${lessonFiles.length} lesson(s), ${byCategory.size} categor${byCategory.size === 1 ? "y" : "ies"})`);
} else {
  ok("no memory/ directory — skipped");
}

console.log("");
console.log("── S9. sandbox — in_progress feature is isolated ────────");
const TRUNK = new Set(["main", "master", "trunk"]);
const activeIP = features.filter(f => f.status === "in_progress");
if (activeIP.length === 0) {
  ok("no in_progress feature — sandbox check skipped");
} else {
  let sb = null;
  const sbPath = p(".qatro", "sandbox-state.json");
  if (fs.existsSync(sbPath)) { try { sb = JSON.parse(fs.readFileSync(sbPath, "utf8")); } catch { sb = null; } }
  if (!sb) {
    fail(`feature "${activeIP[0].name}" is in_progress but has no active sandbox — run \`qatro sandbox start\` (core/sandbox-model.md)`);
  } else if (TRUNK.has(sb.branch)) {
    fail(`sandbox branch must not be a trunk branch (got "${sb.branch}")`);
  } else {
    ok(`sandbox active (tier: ${sb.tier}, branch: ${sb.branch})`);
  }
}

console.log("");
if (failures) {
  console.log(`[FAIL]  verify-state: ${failures} failure(s), ${warnings} warning(s)`);
  process.exit(1);
}
console.log(`[OK]    verify-state: all checks passed (${warnings} warning(s))`);
NODE
exit $?
