/**
 * AEGIS SOAR M3 chunks 1+2 — PROD-POSITIVE TWO-EYES STR DISPOSAL (live).
 *
 * Drives the REAL prod HTTP routes (never in-process storage) as TWO GENUINE,
 * DISTINCT seniors:
 *   • admin  (role admin, AEGIS_ADMIN_PASSWORD) — the MAKER: ingest → start →
 *     escalate-mlco → prepare STR.
 *   • mlco   (role risk_manager, MLCO_PASSWORD) — the CHECKER: finalize → DISPOSITIONED.
 *
 * The two-eyes locked door is proven PAIRED (Rule 18): the maker's self-finalize
 * attempt — with the CORRECT reportHash so the ONLY possible 409 reason is
 * self-approval — is rejected 409 self_approval, then a DIFFERENT senior (mlco)
 * finalizes 200 DISPOSITIONED. Auditor-mutate 403 + anon 401 negatives are paired
 * with the senior positive. The M2 surface is shown unable to dispose (strict-DTO
 * 400 on a state/disposition key).
 *
 * This script ONLY drives HTTP + records statuses/response fields. It performs NO
 * DB access (prod DB is a read-only RLS-bypassing replica reachable only via
 * executeSql; this process's DATABASE_URL is DEV and is deliberately untouched).
 * Durable-effect read-backs (ack cols NULL, DISPOSITIONED, approval maker!=checker,
 * audit entries, leak probe, foreign-tenant count) run separately via executeSql(prod).
 *
 * Secret discipline (Rule 1): passwords are read from process.env and NEVER printed;
 * session cookies/tokens are held in a Jar and NEVER printed. Only non-sensitive
 * business fields (caseId, state, strReportId, narrativeHash, disposition, status
 * codes, guard reason codes) are logged.
 *
 * Synthetic-row discipline: the case subjectRef is a clearly-labelled synthetic
 * handle (`opaque:<tag>-TEST_DELETE_OK`); the row is carry-listed, never deleted
 * (agent prod SQL is read-only; immutable audit-chain rows stay).
 *
 * DEV/TEST tooling only — never ships in the product runtime.
 */

import { createHash } from "node:crypto";

const PROD_URL = process.env.PROD_URL ?? "https://aegis-cyber.replit.app";

// ── secret access (existence-checked, never printed) ─────────────────────────
function need(k: string): string {
  const v = process.env[k];
  if (!v || v.length === 0) throw new Error(`missing required env ${k}`);
  return v;
}

const sha256 = (s: string): string => createHash("sha256").update(s).digest("hex");

// ── cookie jar + real-route HTTP helpers (self-contained) ────────────────────
class Jar {
  private cookies = new Map<string, string>();
  ingest(res: Response): void {
    const setCookies =
      (res.headers as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? [];
    for (const sc of setCookies) {
      const pair = sc.split(";")[0];
      const idx = pair.indexOf("=");
      if (idx > 0) this.cookies.set(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
    }
  }
  header(): string {
    return [...this.cookies.entries()].map(([k, v]) => `${k}=${v}`).join("; ");
  }
}

interface RouteResult {
  status: number;
  json: Record<string, unknown>;
}

async function getCsrf(jar: Jar, xff: string): Promise<string> {
  const res = await fetch(`${PROD_URL}/api/csrf-token`, {
    headers: { cookie: jar.header(), "x-forwarded-for": xff },
  });
  jar.ingest(res);
  return ((await res.json().catch(() => ({}))) as { csrfToken?: string })?.csrfToken ?? "";
}

interface Session {
  jar: Jar;
  csrf: string;
  xff: string;
  username: string;
  role: string;
}

async function login(username: string, password: string, role: string, xff: string): Promise<Session> {
  const jar = new Jar();
  const res = await fetch(`${PROD_URL}/api/auth/login`, {
    method: "POST",
    headers: { "content-type": "application/json", "x-forwarded-for": xff },
    body: JSON.stringify({ username, password }),
  });
  jar.ingest(res);
  const j = (await res.json().catch(() => ({}))) as { success?: boolean };
  if (res.status !== 200 || j?.success !== true) {
    throw new Error(`login(${username}) failed HTTP ${res.status} (success=${j?.success})`);
  }
  const csrf = await getCsrf(jar, xff);
  if (!csrf) throw new Error(`login(${username}) — no CSRF token minted`);
  return { jar, csrf, xff, username, role };
}

async function call(
  method: "GET" | "POST",
  path: string,
  jar: Jar | null,
  csrf: string | null,
  body: unknown,
  xff: string,
): Promise<RouteResult> {
  const headers: Record<string, string> = { "x-forwarded-for": xff };
  if (body !== undefined) headers["content-type"] = "application/json";
  if (jar) headers.cookie = jar.header();
  if (csrf) headers["x-csrf-token"] = csrf;
  const res = await fetch(`${PROD_URL}${path}`, {
    method,
    headers,
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
  return { status: res.status, json };
}

function client(s: Session) {
  return {
    post: (path: string, body: unknown): Promise<RouteResult> =>
      call("POST", path, s.jar, s.csrf, body, s.xff),
    get: (path: string): Promise<RouteResult> => call("GET", path, s.jar, s.csrf, undefined, s.xff),
  };
}

// per-actor RFC-5737 (TEST-NET-3) source IPs so the per-IP login limiter is not
// tripped across reruns (a TEST artifact only — never a product control).
function allocateActorIps(n: number, runId = Date.now()): string[] {
  const ips: string[] = [];
  for (let i = 0; i < n; i++) ips.push(`203.0.113.${((Math.floor(runId / 1000) + i * 7) % 240) + 1}`);
  return ips;
}

const reason = (r: RouteResult): string =>
  String((r.json as { details?: { reason?: string } })?.details?.reason ?? (r.json as { reason?: string })?.reason ?? "");

// ── leg recorder ─────────────────────────────────────────────────────────────
interface Leg { id: string; scenario: string; expect: string; observed: string; pass: boolean }
const legs: Leg[] = [];
function leg(id: string, scenario: string, expect: string, observed: string, pass: boolean): void {
  legs.push({ id, scenario, expect, observed, pass });
}

async function main(): Promise<void> {
  console.log("=".repeat(86));
  console.log("AEGIS SOAR M3 chunks 1+2 — PROD-POSITIVE TWO-EYES STR DISPOSAL (live HTTP)");
  console.log(`spec AS/SOAR/2026/001 | env: PROD | ${PROD_URL} | ${new Date().toISOString()}`);
  console.log("=".repeat(86));

  const adminPw = need("AEGIS_ADMIN_PASSWORD");
  const mlcoPw = need("MLCO_PASSWORD");
  const audPw = need("ENGINEER_OKELLO_PASSWORD");

  const ips = allocateActorIps(4);
  const admin = client(await login("admin", adminPw, "admin", ips[0]));
  const mlco = client(await login("mlco", mlcoPw, "risk_manager", ips[1]));
  const aud = client(await login("okello", audPw, "auditor", ips[2]));
  const anonIp = ips[3];
  console.log("logins OK: admin (maker), mlco (checker), okello (auditor) + anon jar\n");

  const tag = `m3prod2eyes-${Date.now()}`;
  const subjectRef = `opaque:${tag}-TEST_DELETE_OK`;
  const narrative = `PROD two-eyes STR disposal probe ${tag} — synthetic, delete ok`;
  const narrativeHash = sha256(narrative);
  const finalizeReason = `PROD two-eyes finalize rationale ${tag} — synthetic, delete ok`;

  // ── C3a — x-tenant fence at ingest: a payload tenant key is rejected ─────────
  {
    const r = await admin.post("/api/fincrime/ingest", {
      sourceType: "MANUAL_REFERRAL",
      subjectRef: `opaque:${tag}-xtenant-TEST_DELETE_OK`,
      tenantId: "some-other-tenant",
    });
    const pass = r.status === 400;
    leg("C3a", "X-TENANT FENCE — ingest with a payload tenantId key (strict DTO)",
      "HTTP 400 (tenant cannot be asserted from the wire)", `HTTP ${r.status}`, pass);
  }

  // ── INGEST (clean) → OPEN case ───────────────────────────────────────────────
  let caseId = "";
  {
    const r = await admin.post("/api/fincrime/ingest", {
      sourceType: "MANUAL_REFERRAL",
      subjectRef,
      signal: { probe: tag },
    });
    caseId = String(r.json?.caseId ?? "");
    const pass = r.status === 201 && r.json?.state === "OPEN" && r.json?.created === true && caseId.length > 0;
    leg("S1", "INGEST — admin opens one case from a MANUAL_REFERRAL signal",
      "HTTP 201; state OPEN; created true; caseId returned",
      `HTTP ${r.status}; state=${r.json?.state}; created=${r.json?.created}; caseId=${caseId.slice(0, 8)}…`, pass);
    if (!caseId) throw new Error("ingest did not return a caseId — cannot continue");
  }

  // ── START → UNDER_INVESTIGATION ──────────────────────────────────────────────
  {
    const r = await admin.post(`/api/fincrime/cases/${caseId}/start`, {});
    const pass = r.status === 200 && r.json?.state === "UNDER_INVESTIGATION";
    leg("S2", "START — admin starts the investigation (OPEN → UNDER_INVESTIGATION)",
      "HTTP 200; state UNDER_INVESTIGATION", `HTTP ${r.status}; state=${r.json?.state}`, pass);
  }

  // ── ESCALATE → PENDING_MLCO_REVIEW ───────────────────────────────────────────
  {
    const r = await admin.post(`/api/fincrime/cases/${caseId}/escalate-mlco`, {
      summary: `escalate to MLCO for review — ${tag}`,
    });
    const pass = r.status === 200 && r.json?.state === "PENDING_MLCO_REVIEW";
    leg("S3", "ESCALATE — admin escalates (UNDER_INVESTIGATION → PENDING_MLCO_REVIEW)",
      "HTTP 200; state PENDING_MLCO_REVIEW", `HTTP ${r.status}; state=${r.json?.state}`, pass);
  }

  // ── C3b — auditor PREPARE denial (paired positive: G admin prepare) ──────────
  {
    const r = await aud.post(`/api/fincrime/cases/${caseId}/str`, { narrative: "auditor must not prepare" });
    const pass = r.status === 403;
    leg("C3b", "AUDITOR-MUTATE DENIAL — auditor attempts STR prepare (role not senior)",
      "HTTP 403 (paired positive: admin prepare 201 below)", `HTTP ${r.status}`, pass);
  }

  // ── G — PREPARE STR (admin = MAKER) ─────────────────────────────────────────
  let strReportId = "";
  {
    const r = await admin.post(`/api/fincrime/cases/${caseId}/str`, { narrative });
    strReportId = String(r.json?.strReportId ?? "");
    const pass =
      r.status === 201 &&
      r.json?.status === "PREPARED" &&
      r.json?.narrativeHash === narrativeHash &&
      /^[0-9a-f]{64}$/.test(String(r.json?.narrativeHash));
    leg("G", "PREPARE POSITIVE — admin (maker) prepares an STR on PENDING_MLCO_REVIEW",
      "HTTP 201; status PREPARED; narrativeHash = sha256(narrative)",
      `HTTP ${r.status}; status=${r.json?.status}; hashMatch=${r.json?.narrativeHash === narrativeHash}`, pass);
    if (!strReportId) throw new Error("prepare did not return a strReportId — cannot continue");
  }

  // ── M2 FENCE — escalate with a state:DISPOSITIONED key is a strict-DTO 400 ───
  {
    const r = await admin.post(`/api/fincrime/cases/${caseId}/escalate-mlco`, {
      summary: "x",
      state: "DISPOSITIONED",
    });
    const pass = r.status === 400;
    leg("M2F", "M2 FENCE — M2 route cannot dispose (state:DISPOSITIONED key, strict DTO)",
      "HTTP 400 (no M2 edge writes DISPOSITIONED)", `HTTP ${r.status}`, pass);
  }

  // ── C2a — SELF-APPROVAL: admin (maker) finalize with CORRECT hash → 409 ─────
  {
    const r = await admin.post(`/api/fincrime/cases/${caseId}/finalize`, {
      reportHash: narrativeHash,
      reason: finalizeReason,
    });
    const rs = reason(r);
    const pass = r.status === 409 && rs === "self_approval";
    leg("C2a", "SELF-APPROVAL DENIAL — maker finalizes own STR (correct hash → only reason is self-approval)",
      "HTTP 409 reason=self_approval (case NOT disposed)", `HTTP ${r.status}; reason=${rs}`, pass);
  }

  // ── C3c — anon finalize denial (valid CSRF, no session) ──────────────────────
  {
    const anonJar = new Jar();
    const anonCsrf = await getCsrf(anonJar, anonIp);
    const r = await call("POST", `/api/fincrime/cases/${caseId}/finalize`, anonJar, anonCsrf,
      { reportHash: narrativeHash, reason: "anon must not finalize" }, anonIp);
    const pass = r.status === 401 || r.status === 403;
    leg("C3c", "ANON DENIAL — unauthenticated finalize (CSRF satisfied, no session)",
      "HTTP 401/403 (case NOT disposed)", `HTTP ${r.status}`, pass);
  }

  // ── C3d — auditor finalize denial ────────────────────────────────────────────
  {
    const r = await aud.post(`/api/fincrime/cases/${caseId}/finalize`,
      { reportHash: narrativeHash, reason: "auditor must not finalize" });
    const pass = r.status === 403;
    leg("C3d", "AUDITOR-MUTATE DENIAL — auditor attempts finalize (role not senior)",
      "HTTP 403 (case NOT disposed)", `HTTP ${r.status}`, pass);
  }

  // ── C2b — FINALIZE (mlco = CHECKER, different senior) → DISPOSITIONED ────────
  let disposition = "";
  let approvalId = "";
  {
    const r = await mlco.post(`/api/fincrime/cases/${caseId}/finalize`, {
      reportHash: narrativeHash,
      reason: finalizeReason,
    });
    disposition = String(r.json?.disposition ?? "");
    approvalId = String(r.json?.approvalId ?? "");
    const pass = r.status === 200 && r.json?.state === "DISPOSITIONED" && approvalId.length > 0;
    leg("C2b", "TWO-EYES FINALIZE POSITIVE — mlco (checker) finalizes the maker's STR",
      "HTTP 200; state DISPOSITIONED; disposition hard-derived; approvalId returned",
      `HTTP ${r.status}; state=${r.json?.state}; disposition=${disposition}; approvalId=${approvalId.slice(0, 8)}…`, pass);
  }

  // ── DOUBLE-FINALIZE — the door is now locked (already DISPOSITIONED) ─────────
  {
    const r = await mlco.post(`/api/fincrime/cases/${caseId}/finalize`,
      { reportHash: narrativeHash, reason: finalizeReason });
    const rs = reason(r);
    const pass = r.status === 409 && rs === "state_conflict";
    leg("LOCK", "DOUBLE-FINALIZE — re-finalize a DISPOSITIONED case",
      "HTTP 409 reason=state_conflict (locked door)", `HTTP ${r.status}; reason=${rs}`, pass);
  }

  // ── R3 — scoped STR read (senior) returns the case STR ───────────────────────
  {
    const r = await admin.get(`/api/fincrime/cases/${caseId}/str`);
    const pass = r.status === 200 && String(r.json?.id ?? "") === strReportId;
    leg("R3", "SCOPED READ — senior (admin) reads the case STR (criterion 3 surface)",
      "HTTP 200; returns the prepared STR row", `HTTP ${r.status}; idMatch=${String(r.json?.id ?? "") === strReportId}`, pass);
  }

  // ── report ───────────────────────────────────────────────────────────────────
  console.log("");
  for (const l of legs) {
    console.log("-".repeat(86));
    console.log(`[${l.id}] ${l.scenario}`);
    console.log(`     expect  : ${l.expect}`);
    console.log(`     observe : ${l.observed}`);
    console.log(`     VERDICT : ${l.pass ? "PASS" : "FAIL"}`);
  }
  console.log("-".repeat(86));
  const passed = legs.filter((l) => l.pass).length;
  console.log(`ACCEPTANCE: ${passed}/${legs.length} legs PASS — ${passed === legs.length ? "ALL PASS" : "SEE FAILURES ABOVE"}`);
  console.log("");
  console.log("=== DURABLE-EFFECT READ-BACK KEYS (verify via executeSql prod) ===");
  console.log(JSON.stringify({ caseId, strReportId, disposition, approvalId, narrativeHash, subjectRef, tag }, null, 2));

  if (passed !== legs.length) process.exitCode = 1;
}

main().catch((e) => {
  console.error("FATAL:", e instanceof Error ? e.message : String(e));
  process.exit(1);
});
