#!/usr/bin/env bash
# Create disposable projects and verify generated files and hook behavior.
# Run with Bash, including Git Bash on Windows:
#   bash bootstrap/selftest.sh [track...]    (default: all four tracks)
# Repeat after catalog changes; a previous manual run does not prove regression safety.
# The same guarantees, plus the hook transport and malformed-input cases, run
# without Bash or Git in: node --test agent-kit/hooks/claude-runtime.test.mjs
set -u
KIT="$(cd "$(dirname "$0")/.." && pwd)"
TMP="${TMPDIR:-/tmp}/kit-selftest-$$"
TRILHAS=("$@")
[ ${#TRILHAS[@]} -eq 0 ] && TRILHAS=(web-saas mobile-expo godot-game unreal-game)
falhas=0

ok()   { printf "    ok    %s\n" "$1"; }
fail() { printf "    FAIL %s\n" "$1"; falhas=$((falhas+1)); }

# The expected self-test line comes from the kit's own engine, not from a number
# pasted here: a hard-coded count goes stale the day a case is added, and a stale
# expectation fails every track for a reason unrelated to the track.
esperado=$(node "$KIT/hooks/scope-guard.mjs" --self-test 2>&1 | tail -1)
case "$esperado" in
  "self-test: "*" passed, 0 failed") echo "kit engine: $esperado" ;;
  *) echo "kit engine self-test is not clean: $esperado"; exit 1 ;;
esac

# Ask a generated project's guard for a decision. Paths are handed to node as
# arguments (Git Bash converts those to native paths); a path pasted into the
# JSON by the shell stays "/tmp/..." and means another directory to node.
#   decide <project> <agent|-> <Write|Bash> <path-or-command> [cwd] [CLAUDE_PROJECT_DIR|-]
decide() {
  node -e '
    const { spawnSync } = require("child_process"), path = require("path");
    const [proj, agent, tool, value, cwdArg, projectDir] = process.argv.slice(1);
    const cwd = cwdArg ? path.resolve(cwdArg) : path.resolve(proj);
    const evt = { tool_name: tool, cwd, tool_input: tool === "Bash" ? { command: value } : { file_path: value } };
    if (agent !== "-") evt.agent_type = agent;
    const env = { ...process.env }; delete env.CLAUDE_PROJECT_DIR;
    if (projectDir && projectDir !== "-") env.CLAUDE_PROJECT_DIR = path.resolve(projectDir);
    const r = spawnSync(process.execPath, [path.join(proj, ".claude/hooks/scope-guard.mjs")], { cwd, env, input: JSON.stringify(evt), encoding: "utf8" });
    console.log(/"permissionDecision":"deny"/.test(r.stdout) ? "deny" : r.status === 0 ? "allow" : "error-" + r.status);
  ' "$@"
}
expect() { # expect <wanted> <label> <decide args...>
  local wanted="$1" label="$2"; shift 2
  local got; got=$(decide "$@")
  [ "$got" = "$wanted" ] && ok "$label" || fail "$label (wanted $wanted, got $got)"
}

for t in "${TRILHAS[@]}"; do
  echo "== track: $t =="
  alvo="$TMP/$t"
  resp="$TMP/$t.json"
  mkdir -p "$TMP"

  node "$KIT/bootstrap/new-project.mjs" --track "$t" --print-answers > "$resp" 2>/dev/null \
    || { fail "--print-answers"; continue; }

  # Fill project-specific answers with plausible nonempty values so the
  # generated project is exercised rather than passing with empty placeholders.
  # Team-derived values (QA agent, owners, reviewers, callable ids) are absent
  # from the template on purpose: the generator computes them.
  node -e '
    const fs=require("fs"); const p=process.argv[1]; const t=process.argv[2];
    const r=JSON.parse(fs.readFileSync(p,"utf8"));
    r.name="selftest-"+t;
    for (const k of Object.keys(r.placeholders)) {
      r.placeholders[k] = ({
        PROJECT:"Selftest", ENGINE:t, REPORT_LANGUAGE:"en",
        CHECK_COMMAND:"npm test", HEADLESS_CMD:"echo headless",
        CANON_PATHS:"docs/canon/", DECISION_LOG:"docs/DECISIONS.md",
        DOCS_ROOT:"docs/", RESEARCH_DIR:"docs/research/", EVIDENCE_DIR:"docs/qa/",
        MIGRATIONS_DIR:"supabase/migrations/", DB:"Postgres + RLS",
        GENERATED_FILES:"src/routeTree.gen.ts", MONEY_PATHS:"src/lib/billing/",
        GAMEPLAY_PATHS:"src/core/", RULES_PATHS:"data/rules/", TESTS_DIR:"tests/",
        TOKENS_DIR:"src/styles.css", RISK_CLASS:"routine | logic | money",
        ENGINE_CRAFT_SKILL:"engine-craft",
      })[k] ?? ("selftest-"+k.toLowerCase());
    }
    fs.writeFileSync(p, JSON.stringify(r,null,2));
  ' "$resp" "$t" || { fail "fill answers"; continue; }

  out=$(node "$KIT/bootstrap/new-project.mjs" --answers "$resp" --target "$alvo" 2>&1)
  echo "$out" | grep -qF -- "--self-test: $esperado" && ok "scope-guard --self-test in generated project ($esperado)" || { fail "--self-test"; echo "$out" | sed 's/^/      /' | head -12; }
  echo "$out" | grep -q -- "--check: .*0 error(s)" && ok "scope-guard --check: 0 errors" || fail "--check: $(echo "$out" | grep -i -- '--check:' | head -1)"
  # Warnings are catalog or scope defects worth reading, not generator failures.
  if ! echo "$out" | grep -q -- "--check: .* 0 warning(s)"; then
    node "$alvo/.claude/hooks/scope-guard.mjs" --check 2>&1 | grep "^warn" | sed 's/^/    note  /'
  fi
  echo "$out" | grep -q "delegation: the lead can call all" && ok "lead allowlist equals the installed team" || fail "delegation: $(echo "$out" | grep -i 'delegation:' | head -1)"

  # 1. A ready project must have no unresolved placeholders.
  # The literal word <PLACEHOLDER> explains the mechanism; ignore that one word.
  restou=$(grep -rhoE "<[A-Z][A-Z0-9_]{2,}>" "$alvo/.claude" "$alvo/docs" 2>/dev/null | grep -v "^<PLACEHOLDER>$" | sort -u)
  if [ -z "$restou" ]; then ok "no unresolved placeholders"
  else fail "remaining placeholder: $(echo "$restou" | tr '\n' ' ')"; fi

  # 2. Generated scopes.json must be valid and actually fail closed.
  node -e '
    const fs=require("fs");const s=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));
    const err=[];
    if(!s.lead) err.push("missing lead");
    if(!Object.keys(s.write||{}).length) err.push("no writable roles");
    for(const [a,d] of Object.entries(s.write||{})) {
      for(const p of [...(d.allow||[]),...(d.deny||[])]) {
        if(p.includes("*")&&!/^[^*]+\*$/.test(p)) err.push(a+": invalid glob "+p);
        if(p.includes("//")) err.push(a+": doubled slash never matches: "+p);
      }
      if(!(d.allow||[]).length) err.push(a+": empty allow");
    }
    if(!(s.shell||{})["workflow-subagent"]) err.push("missing workflow-subagent identity");
    if(err.length){console.error(err.join("; "));process.exit(1)}
  ' "$alvo/.claude/hooks/scopes.json" && ok "scopes.json consistent" || fail "scopes.json"

  # 3. The lead names no role it cannot call (C1): every catalog role in its
  #    file is installed, or is named as one to install / "when installed".
  fantasma=$(node -e '
    const fs=require("fs"),path=require("path");const [a,kit]=process.argv.slice(1);
    const cat=JSON.parse(fs.readFileSync(path.join(kit,"catalog/catalog.json"),"utf8"));
    const lead=JSON.parse(fs.readFileSync(a+"/.claude/hooks/scopes.json","utf8")).lead;
    const team=new Set(fs.readdirSync(a+"/.claude/agents").map(f=>f.replace(/\.md$/,"")));
    const body=fs.readFileSync(a+"/.claude/agents/"+lead+".md","utf8");const bad=new Set();
    for(const m of body.matchAll(/`([a-z][a-z0-9-]+)`/g)){
      if(!cat.agents[m[1]]||team.has(m[1]))continue;
      if(/add $/.test(body.slice(0,m.index))||/^ when installed/.test(body.slice(m.index+m[0].length)))continue;
      bad.add(m[1]);
    }
    console.log([...bad].join(" "));
  ' "$alvo" "$KIT")
  [ -z "$fantasma" ] && ok "lead routes only to installed roles" || fail "lead is told to use roles that are not installed: $fantasma"

  # 4. Live controls through the hook transport.
  rev=$(node -e '
    const s=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
    console.log((s.read_only||[]).find(a=>((s.shell||{})[a]||{}).tier&&s.shell[a].tier!=="none")||(s.read_only||[])[0]||"");
  ' "$alvo/.claude/hooks/scopes.json")
  lead=$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).lead)' "$alvo/.claude/hooks/scopes.json")
  if [ -n "$rev" ]; then
    mkdir -p "$alvo/docs/deep"
    expect deny  "reviewer '$rev' DENIED writing src/x.ts (fail-closed)"            "$alvo" "$rev"  Write src/x.ts
    expect allow "positive control: lead ALLOWED in docs/"                           "$alvo" "$lead" Write docs/x.md
    expect allow "C4: lead writes the same absolute path from a subdirectory"        "$alvo" "$lead" Write "$alvo/docs/x.md" "$alvo/docs/deep" "$alvo"
    expect allow "C4: ... and without CLAUDE_PROJECT_DIR"                            "$alvo" "$lead" Write "$alvo/docs/x.md" "$alvo/docs/deep" -
    expect deny  "C4: reviewer still denied from a subdirectory"                     "$alvo" "$rev"  Write "$alvo/src/x.ts" "$alvo/docs/deep" "$alvo"
    expect deny  "C3: git --output is a write for '$rev'"                            "$alvo" "$rev"  Bash "git diff --no-index --output=src/r.patch docs/a docs/b"
    expect deny  "C3: tar cf (old-style flags) is a write for '$rev'"                "$alvo" "$rev"  Bash "tar cf src/r.tar docs"
    expect deny  "C3: sort -o is a write for '$rev'"                                 "$alvo" "$rev"  Bash "sort -o src/r.txt docs/a.txt"
    expect allow "C3: '$rev' keeps read-only git"                                    "$alvo" "$rev"  Bash "git log --oneline -5"
  fi
  vazio=$(printf '' | node "$alvo/.claude/hooks/scope-guard.mjs" 2>&1)
  echo "$vazio" | grep -q '"deny"' && ok "C8: empty hook input is denied" || fail "C8: empty hook input was not denied"
  nulo=$(printf 'null' | node "$alvo/.claude/hooks/scope-guard.mjs" 2>&1)
  echo "$nulo" | grep -q '"deny"' && ok "C8: JSON that is not an event is denied" || fail "C8: 'null' was not denied"

  # 5. C2: the REGISTERED hook command, run from a subdirectory, reaches a decision.
  cmd=$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).hooks.PreToolUse[0].hooks[0].command)' "$alvo/.claude/settings.json")
  evt=$(node -e 'console.log(JSON.stringify({tool_name:"Write",tool_input:{file_path:"src/x.ts"},cwd:require("path").resolve(process.argv[1]),agent_type:process.argv[2]}))' "$alvo/docs/deep" "$rev")
  if (cd "$alvo/docs/deep" && printf '%s' "$evt" | env -u CLAUDE_PROJECT_DIR bash -c "$cmd" 2>&1) | grep -q '"deny"'; then
    ok "C2: registered hook command works from a subdirectory without CLAUDE_PROJECT_DIR"
  else
    fail "C2: registered hook command did not reach a decision from a subdirectory"
  fi

  # 6. Legacy compatibility check when a Codex shim is present.
  if [ -f "$alvo/.codex/hooks/scope-guard.mjs" ]; then
    node "$alvo/.codex/hooks/scope-guard.mjs" --self-test 2>&1 | grep -qF "$esperado" \
      && ok "self-test through Codex shim" || fail "Codex shim"
    [ -f "$alvo/.codex/hooks/scopes.json" ] && fail ".codex/hooks/scopes.json recreated (would be ignored and drift)" || ok "no duplicate config in .codex"
  fi

  # 7. Every skill required by an agent must have been copied.
  faltando=$(node -e '
    const fs=require("fs"),path=require("path");const a=process.argv[1];
    const tem=new Set(fs.existsSync(a+"/.claude/skills")?fs.readdirSync(a+"/.claude/skills"):[]);
    const falta=new Set();
    for(const f of (fs.existsSync(a+"/.claude/agents")?fs.readdirSync(a+"/.claude/agents"):[])){
      const fm=(fs.readFileSync(a+"/.claude/agents/"+f,"utf8").split("---")[1]||"");
      const m=fm.match(/skills:([\s\S]*?)(?:\n[a-z_]+:|$)/); if(!m) continue;
      for(const s of m[1].split(/[\n,·]/).map(x=>x.replace(/^\s*-\s*/,"").trim()).filter(Boolean))
        if(!s.startsWith("<")&&!tem.has(s)) falta.add(s);
    }
    console.log([...falta].join(" "));
  ' "$alvo")
  [ -z "$faltando" ] && ok "all referenced skills copied" || fail "referenced skill missing: $faltando"

  # 8. Minimum task context (C5) and the project's own Git history.
  [ -f "$alvo/CLAUDE.md" ] && [ -f "$alvo/docs/tasks/BACKLOG.md" ] && [ -f "$alvo/docs/tasks/ROADMAP.md" ] \
    && ok "CLAUDE.md, BACKLOG.md and ROADMAP.md written" || fail "project context files missing"
  if git -C "$alvo" rev-parse HEAD >/dev/null 2>&1; then
    ok "Git initialized with an initial commit"
  else
    fail "generated project has no Git history (expected git init + commit)"
  fi

  echo "    ($(find "$alvo" -type f | wc -l) files in $alvo)"
done

echo
if [ "$falhas" -eq 0 ]; then
  echo "ALL PASSED. Removing $TMP"
  rm -rf "$TMP"
  exit 0
else
  echo "$falhas FAIL(S). $TMP was retained for inspection."
  exit 1
fi
