#!/usr/bin/env -S node --loader tsx
/**
 * admin persist audit harness.
 *
 * Compares JSONL canonical state against Neo4j projection for a given
 * sessionId. Prints one [admin-persist-audit] divergence line per
 * (sdkTurnUuid, expected) gap; non-zero exit on any mismatch. Designed to
 * run against an operator-supplied stream log fixture WITHOUT re-executing
 * the live session — JSONL is the only ground truth this script consults.
 *
 * Usage:
 *   tsx platform/scripts/admin-persist-audit.ts \
 *       --conversation-id=<uuid> \
 *       --account-id=<uuid> \
 *       --jsonl=<path>            # optional override; otherwise resolved from accountId+sessionId
 *       --session-id=<uuid>       # required if --jsonl not provided
 *
 * Exit codes:
 *   0 = no divergences
 *   1 = at least one Message or Component absent from Neo4j
 *   2 = invocation error (missing args, file unreadable, Neo4j unreachable)
 *
 * Why audit-only and not also auto-heal: the heal-on-resume writer at
 * server/routes/admin/sessions.ts handles the live path. This harness exists
 * for forensic investigation against operator-supplied JSONLs (e.g. the
 * 2026-05-07 stream log that motivated this task) where the live session
 * has long since terminated.
 */

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { resolve } from "node:path";
import process from "node:process";

import { replayJsonl, resolveJsonlPath } from "../ui/app/lib/claude-agent/jsonl-replay";
import { ACCOUNTS_DIR } from "../ui/app/lib/claude-agent/account";
import { getRecentMessages, getSession } from "../ui/app/lib/neo4j-store";
import { PERSISTENT_COMPONENTS } from "../lib/persistent-components/src/index";

// `AuditArgs` and `runAudit` are exported so the test
// (`platform/scripts/__tests__/admin-persist-audit.test.ts`) can mock the
// Neo4j surface and drive the audit with synthetic JSONL + Neo4j seeds,
// verifying the divergence-line shape and non-zero exit code without
// re-executing the live session or requiring a real graph connection.
export interface AuditArgs {
  sessionId: string;
  accountId: string;
  jsonlPath: string;
}

function parseArgs(argv: string[]): AuditArgs | { error: string } {
  const out: Partial<AuditArgs> = {};
  let sessionId: string | undefined;
  let jsonlOverride: string | undefined;
  for (const a of argv) {
    const m = a.match(/^--([a-z-]+)=(.+)$/);
    if (!m) continue;
    const [, key, val] = m;
    if (key === "conversation-id") out.sessionId = val;
    else if (key === "account-id") out.accountId = val;
    else if (key === "session-id") sessionId = val;
    else if (key === "jsonl") jsonlOverride = val;
  }
  if (!out.sessionId) return { error: "--conversation-id required" };
  if (!out.accountId) return { error: "--account-id required" };
  if (jsonlOverride) {
    out.jsonlPath = resolve(jsonlOverride);
  } else if (sessionId) {
    const accountDir = resolve(ACCOUNTS_DIR, out.accountId);
    out.jsonlPath = resolveJsonlPath(homedir(), accountDir, sessionId);
  } else {
    return { error: "either --jsonl or --session-id required" };
  }
  return out as AuditArgs;
}

export async function runAudit(args: AuditArgs): Promise<number> {
  const { sessionId, jsonlPath } = args;

  if (!existsSync(jsonlPath)) {
    console.error(`[admin-persist-audit] jsonl absent path=${jsonlPath} convId=${sessionId.slice(0, 8)}`);
    return 2;
  }

  // JSONL replay — derives the canonical message stream and the expected
  // component side-effects.
  const replay = replayJsonl(jsonlPath);
  if (replay.malformedLines > 0) {
    console.error(`[admin-persist-audit] jsonl-malformed-lines convId=${sessionId.slice(0, 8)} count=${replay.malformedLines}`);
  }

  // Neo4j projection — what the resume route would have rendered pre-940.
  let neo4j: Awaited<ReturnType<typeof getRecentMessages>>;
  try {
    neo4j = await getRecentMessages(sessionId, 1000);
  } catch (err) {
    console.error(`[admin-persist-audit] neo4j-read-failed convId=${sessionId.slice(0, 8)} reason=${err instanceof Error ? err.message : String(err)}`);
    return 2;
  }

  // Build a set of (role, content) keys present in Neo4j for fast lookup.
  // Same matching key the resume route uses, for parity.
  const neo4jByKey = new Map<string, typeof neo4j[number]>();
  for (const n of neo4j) neo4jByKey.set(`${n.role}\x1f${n.content}`, n);

  let divergences = 0;
  for (const j of replay.messages) {
    const key = `${j.role}\x1f${j.content}`;
    const match = neo4jByKey.get(key);
    if (!match) {
      // Whole message absent from Neo4j.
      console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=message missing reason=neo4j-row-absent`);
      divergences += 1;
      // Each missing message implies its components are also missing — emit
      // one divergence line per absent component for forensic completeness.
      for (const c of j.components) {
        console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=component component_name=${c.name} ordinal=${c.ordinal} missing reason=neo4j-row-absent`);
        divergences += 1;
      }
      continue;
    }
    // Message exists; cross-check component count (Neo4j carries components
    // as siblings of :Message via :HAS_COMPONENT).
    const neoComps = match.components ?? [];
    if (neoComps.length < j.components.length) {
      for (let i = neoComps.length; i < j.components.length; i++) {
        const c = j.components[i];
        console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} sdkTurnUuid=${j.messageId.slice(0, 8)} expected=component component_name=${c.name} ordinal=${c.ordinal} missing reason=neo4j-row-absent`);
        divergences += 1;
      }
    }
  }

  // Every PERSISTENT_COMPONENTS:Component row must have a
  // sibling :KnowledgeDocument with a matching attachmentId. A missing
  // sibling means the disk-write failed mid-render and the writer's
  // :KnowledgeDocument MERGE didn't fire. Surfaced as `kd-row-absent`.
  const projectionSession = getSession();
  try {
    const componentRowsResult = await projectionSession.run(
      `MATCH (m:Message {sessionId: $sessionId})-[:HAS_COMPONENT]->(c:Component)
       WHERE c.name IN $names
       OPTIONAL MATCH (k:KnowledgeDocument {accountId: c.accountId, attachmentId: c.attachmentId})
         WHERE c.attachmentId IS NOT NULL
       RETURN c.componentId AS componentId,
              c.name AS componentName,
              c.accountId AS accountId,
              c.attachmentId AS attachmentId,
              k IS NOT NULL AS hasProjection`,
      { sessionId, names: Array.from(PERSISTENT_COMPONENTS) },
    );

    for (const record of componentRowsResult.records) {
      const componentId = record.get("componentId") as string;
      const componentName = record.get("componentName") as string;
      const attachmentId = record.get("attachmentId") as string | null;
      const hasProjection = record.get("hasProjection") === true;
      if (!attachmentId) {
        console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} componentId=${componentId.slice(0, 8)} expected=knowledgedoc component_name=${componentName} missing reason=empty-attachment-id`);
        divergences += 1;
        continue;
      }
      if (!hasProjection) {
        console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} componentId=${componentId.slice(0, 8)} expected=knowledgedoc component_name=${componentName} missing reason=kd-row-absent attachmentId=${attachmentId.slice(0, 8)}`);
        divergences += 1;
      }
    }
  } finally {
    await projectionSession.close();
  }

  if (divergences === 0) {
    console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} jsonlMessages=${replay.messages.length} neo4jMessages=${neo4j.length} divergences=0 status=ok`);
    return 0;
  }
  console.log(`[admin-persist-audit] convId=${sessionId.slice(0, 8)} jsonlMessages=${replay.messages.length} neo4jMessages=${neo4j.length} divergences=${divergences} status=mismatch`);
  return 1;
}

async function main(): Promise<number> {
  const parsed = parseArgs(process.argv.slice(2));
  if ("error" in parsed) {
    console.error(`[admin-persist-audit] usage error: ${parsed.error}`);
    return 2;
  }
  return runAudit(parsed);
}

// only invoke main() when this script is executed directly.
// Importing the module (e.g. from `admin-persist-audit.test.ts`) reads
// `runAudit` and `AuditArgs` without firing the top-level argv parser or
// process.exit, which would otherwise kill the vitest worker before any
// assertion ran. `import.meta.url` resolves to the script path under tsx;
// the suffix check matches both the .ts source and any compiled .js output.
const invokedDirectly = (() => {
  try {
    const entry = process.argv[1];
    return typeof entry === "string" && import.meta.url.endsWith(entry.replace(/\\/g, "/").split("/").pop() ?? "");
  } catch {
    return false;
  }
})();

if (invokedDirectly) {
  main()
    .then((code) => process.exit(code))
    .catch((err) => {
      console.error(`[admin-persist-audit] crashed: ${err instanceof Error ? err.stack : String(err)}`);
      process.exit(2);
    });
}
