#!/usr/bin/env node
// one-shot AdminConversation recovery from preflush stream logs.
//
// Background. (2026-04-22) deferred admin Conversation persistence
// until a 2-user-turn threshold; the gate filtered out single-turn admin chats
// (the common case). fixes the gate forward (any non-empty buffer
// flushes), but five days of admin sessions had already run without writing
// AdminConversation nodes. Their interaction transcripts survive only in the
// per-session preflush stream logs under
//   <maxy_root>/data/accounts/<accountId>/logs/claude-agent-stream-preflush-{sk12}.log
// This script replays them into Neo4j as AdminConversation + Message nodes.
//
// Invocation. Run on the Maxy laptop (192.168.88.10), not in the dev worktree.
//   node platform/scripts/admin-conversation-recover.mjs --dry-run            # preview
//   node platform/scripts/admin-conversation-recover.mjs --apply              # commit
//
// Flags.
//   --dry-run            (default) print proposed writes; do nothing
//   --apply              actually write to Neo4j (mutually exclusive with --dry-run)
//   --maxy-root <path>   override autodetected ~/maxy
//   --account-id <uuid>  restrict to a single account (default: all accounts)
//   --since <iso>        log mtime cutoff (default: 2026-04-22T00:00:00Z)
//   --user-id <id>       override the AdminUser id (default: looked up from Neo4j)
//
// Source identifiers in the preflush log filename only carry the first 12 chars
// of the original sessionKey; the full key was RAM-only and is unrecoverable.
// Reconstructed Conversations carry sessionKey="recovered:<sk12>:<mtime-epoch>"
// so they remain unique without forging the original.

import { readFileSync, statSync, readdirSync, existsSync } from "node:fs";
import { join, basename } from "node:path";
import { homedir } from "node:os";
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);

function parseFlags(argv) {
  const flags = {
    dryRun: true,
    apply: false,
    maxyRoot: join(homedir(), "maxy"),
    accountId: null,
    since: "2026-04-22T00:00:00Z",
    userId: null,
  };
  for (let i = 2; i < argv.length; i++) {
    const a = argv[i];
    if (a === "--dry-run") { flags.dryRun = true; flags.apply = false; }
    else if (a === "--apply") { flags.apply = true; flags.dryRun = false; }
    else if (a === "--maxy-root") flags.maxyRoot = argv[++i];
    else if (a === "--account-id") flags.accountId = argv[++i];
    else if (a === "--since") flags.since = argv[++i];
    else if (a === "--user-id") flags.userId = argv[++i];
    else if (a === "--help" || a === "-h") {
      console.log(readFileSync(import.meta.url.replace("file://", ""), "utf8").split("\n").slice(1, 28).map(l => l.replace(/^\/\/ ?/, "")).join("\n"));
      process.exit(0);
    } else {
      console.error(`unknown flag: ${a}`);
      process.exit(2);
    }
  }
  return flags;
}

function findPreflushLogs(maxyRoot, accountFilter, sinceIso) {
  const accountsDir = join(maxyRoot, "data", "accounts");
  if (!existsSync(accountsDir)) {
    console.error(`accountsDir missing: ${accountsDir}`);
    return [];
  }
  const sinceMs = Date.parse(sinceIso);
  if (Number.isNaN(sinceMs)) {
    console.error(`invalid --since: ${sinceIso}`);
    process.exit(2);
  }
  const out = [];
  for (const accountId of readdirSync(accountsDir)) {
    if (accountFilter && accountId !== accountFilter) continue;
    const logsDir = join(accountsDir, accountId, "logs");
    if (!existsSync(logsDir)) continue;
    for (const name of readdirSync(logsDir)) {
      if (!name.startsWith("claude-agent-stream-preflush-")) continue;
      if (!name.endsWith(".log")) continue;
      const path = join(logsDir, name);
      const stat = statSync(path);
      if (stat.mtimeMs < sinceMs) continue;
      const sk12 = name.slice("claude-agent-stream-preflush-".length, -".log".length);
      out.push({ path, accountId, sk12, mtimeMs: stat.mtimeMs, sizeBytes: stat.size });
    }
  }
  out.sort((a, b) => a.mtimeMs - b.mtimeMs);
  return out;
}

// Parse one preflush log into ordered turns. Each turn carries role + content +
// timestamp; tool-only assistant turns produce empty content (still kept so the
// reconstruction reflects the real interaction shape).
function parseLog(path) {
  const text = readFileSync(path, "utf8");
  const turns = [];
  for (const rawLine of text.split("\n")) {
    if (!rawLine) continue;
    if (!rawLine.startsWith("{")) continue;
    let msg;
    try { msg = JSON.parse(rawLine); } catch { continue; }
    if (!msg || typeof msg !== "object") continue;
    if (msg.type === "user" && msg.message?.content) {
      const content = typeof msg.message.content === "string"
        ? msg.message.content
        : Array.isArray(msg.message.content)
          ? msg.message.content.filter(c => c?.type === "text").map(c => c.text).join("\n")
          : "";
      if (content) turns.push({ role: "user", content, timestamp: msg.timestamp });
    } else if (msg.type === "assistant" && Array.isArray(msg.message?.content)) {
      const textParts = msg.message.content.filter(c => c?.type === "text").map(c => c.text);
      if (textParts.length > 0) {
        turns.push({ role: "assistant", content: textParts.join("\n"), timestamp: msg.timestamp });
      }
    }
  }
  return turns;
}

async function loadDriver(maxyRoot) {
  // The neo4j-driver dep lives under platform/ui — resolve from there so the
  // script works whether invoked from worktree, deployed payload, or laptop.
  const candidates = [
    join(maxyRoot, "platform", "ui", "node_modules", "neo4j-driver"),
    join(process.cwd(), "platform", "ui", "node_modules", "neo4j-driver"),
    join(process.cwd(), "node_modules", "neo4j-driver"),
  ];
  for (const c of candidates) {
    if (existsSync(c)) {
      const mod = require(c);
      return mod.default || mod;
    }
  }
  console.error(`neo4j-driver not found in any of:\n  ${candidates.join("\n  ")}`);
  process.exit(2);
}

function readNeo4jConfig() {
  const uri = process.env.NEO4J_URI;
  const username = process.env.NEO4J_USERNAME ?? "neo4j";
  const password = process.env.NEO4J_PASSWORD;
  if (!uri || !password) {
    console.error("NEO4J_URI and NEO4J_PASSWORD must be set");
    process.exit(2);
  }
  return { uri, username, password };
}

async function resolveUserId(driver, accountId, override) {
  if (override) return override;
  const session = driver.session();
  try {
    const result = await session.run(
      `MATCH (au:AdminUser {accountId: $accountId})
       OPTIONAL MATCH (au)<-[:STARTED_BY]-(c:Conversation)
       WITH au, count(c) AS conversationCount
       RETURN au.userId AS userId
       ORDER BY conversationCount DESC
       LIMIT 1`,
      { accountId },
    );
    const userId = result.records[0]?.get("userId");
    if (!userId) {
      console.error(`no AdminUser found for accountId=${accountId} — pass --user-id explicitly`);
      return null;
    }
    return userId;
  } finally {
    await session.close();
  }
}

async function reconstructOne(driver, file, userId, dryRun) {
  const turns = parseLog(file.path);
  if (turns.length === 0) {
    return { ...file, status: "skipped", reason: "no-turns", turns: 0 };
  }
  const recoveredSessionKey = `recovered:${file.sk12}:${file.mtimeMs}`;
  const conversationId = randomUUID();
  if (dryRun) {
    return {
      ...file,
      status: "would-create",
      conversationId,
      recoveredSessionKey,
      userId,
      turns: turns.length,
      sample: turns.slice(0, 1).map(t => `${t.role}:${t.content.slice(0, 60)}`)[0],
    };
  }
  const session = driver.session();
  try {
    // Apply phase. Mirrors createNewAdminConversation's shape (sublabel +
    // assertion enforced); persistMessage shape mirrored inline so
    // we don't import the platform module from a script.
    //
    // Idempotency: MERGE on the synthetic recoveredSessionKey so a re-run
    // against the same preflush log returns the existing node instead of
    // creating a duplicate (or throwing under the conversation_id_unique
    // constraint). When MERGE matches, ON MATCH leaves the existing
    // conversationId — the local `conversationId` variable is then stale, but
    // we look up the matched id from RETURN and switch to it before message
    // replay.
    const createdAtBase = new Date(file.mtimeMs).toISOString();
    let effectiveConversationId = conversationId;
    let alreadyExists = false;
    await session.executeWrite(async tx => {
      const result = await tx.run(
        `MERGE (au:AdminUser {userId: $userId})
         ON CREATE SET au.accountId = $accountId,
                       au.name = 'Admin',
                       au.createdAt = datetime(),
                       au.scope = 'admin'
         WITH au
         MERGE (c:Conversation {sessionKey: $sessionKey})
         ON CREATE SET c:AdminConversation,
                       c.conversationId = $conversationId,
                       c.accountId = $accountId,
                       c.agentType = 'admin',
                       c.userId = $userId,
                       c.createdBySource = 'admin-conversation-recover',
                       c.recoveredFrom = $sourcePath,
                       c.createdAt = datetime($createdAtBase),
                       c.updatedAt = datetime($createdAtBase)
         MERGE (c)-[:STARTED_BY]->(au)
         WITH c, c.createdBySource = 'admin-conversation-recover' AND c.conversationId = $conversationId AS createdNow
         RETURN c.conversationId AS conversationId, NOT createdNow AS alreadyExists`,
        {
          userId,
          accountId: file.accountId,
          conversationId,
          sessionKey: recoveredSessionKey,
          sourcePath: file.path,
          createdAtBase,
        },
      );
      effectiveConversationId = result.records[0]?.get("conversationId") ?? conversationId;
      alreadyExists = result.records[0]?.get("alreadyExists") === true;
      if (alreadyExists) return;
      let prevId = null;
      for (let i = 0; i < turns.length; i++) {
        const t = turns[i];
        const messageId = randomUUID();
        const ts = t.timestamp ?? new Date(file.mtimeMs + i * 1000).toISOString();
        await tx.run(
          `MATCH (c:Conversation {conversationId: $conversationId})
           CREATE (m:Message {
             messageId: $messageId,
             role: $role,
             content: $content,
             accountId: $accountId,
             createdAt: datetime($ts),
             createdBySource: 'admin-conversation-recover'
           })
           SET m:` + (t.role === "user" ? "UserMessage" : "AssistantMessage") + `
           CREATE (m)-[:PART_OF]->(c)`,
          {
            conversationId,
            messageId,
            role: t.role,
            content: t.content,
            accountId: file.accountId,
            ts,
          },
        );
        if (prevId) {
          await tx.run(
            `MATCH (a:Message {messageId: $prevId})
             MATCH (b:Message {messageId: $thisId})
             CREATE (a)-[:NEXT]->(b)`,
            { prevId, thisId: messageId },
          );
        }
        prevId = messageId;
      }
    });
    return {
      ...file,
      status: alreadyExists ? "already-recovered" : "created",
      conversationId: effectiveConversationId,
      recoveredSessionKey,
      userId,
      turns: turns.length,
    };
  } catch (err) {
    return {
      ...file,
      status: "failed",
      error: err instanceof Error ? err.message : String(err),
      turns: turns.length,
    };
  } finally {
    await session.close();
  }
}

async function reconcileSublabels(driver, dryRun) {
  if (dryRun) return { reconciled: 0, dryRun: true };
  const session = driver.session();
  try {
    const result = await session.run(
      `MATCH (c:Conversation)
       WHERE c.agentType = 'admin' AND NOT c:AdminConversation
       SET c:AdminConversation
       RETURN count(c) AS reconciled`,
    );
    const reconciled = result.records[0]?.get("reconciled");
    return {
      reconciled: typeof reconciled?.toNumber === "function" ? reconciled.toNumber() : Number(reconciled ?? 0),
    };
  } finally {
    await session.close();
  }
}

async function main() {
  const flags = parseFlags(process.argv);
  const mode = flags.apply ? "APPLY" : "DRY-RUN";
  console.log(`[admin-recover] mode=${mode} maxyRoot=${flags.maxyRoot} since=${flags.since} accountFilter=${flags.accountId ?? "all"}`);

  const files = findPreflushLogs(flags.maxyRoot, flags.accountId, flags.since);
  console.log(`[admin-recover] found ${files.length} preflush log(s)`);
  if (files.length === 0) {
    console.log(`[admin-recover] nothing to do`);
    return;
  }

  const Neo4j = await loadDriver(flags.maxyRoot);
  const cfg = readNeo4jConfig();
  const driver = Neo4j.driver(cfg.uri, Neo4j.auth.basic(cfg.username, cfg.password));

  // Verify connectivity before iterating.
  try {
    await driver.verifyConnectivity();
  } catch (err) {
    console.error(`[admin-recover] neo4j connect failed: ${err instanceof Error ? err.message : String(err)}`);
    await driver.close();
    process.exit(2);
  }

  const userIdByAccount = new Map();
  const results = [];
  for (const file of files) {
    let userId = userIdByAccount.get(file.accountId);
    if (userId === undefined) {
      userId = await resolveUserId(driver, file.accountId, flags.userId);
      userIdByAccount.set(file.accountId, userId);
    }
    if (!userId) {
      results.push({ ...file, status: "skipped", reason: "no-user-id", turns: 0 });
      continue;
    }
    const r = await reconstructOne(driver, file, userId, flags.dryRun);
    results.push(r);
    const tag = `[admin-recover:${r.status}]`;
    const summary = r.status === "would-create"
      ? `${tag} ${basename(r.path)} accountId=${r.accountId.slice(0, 8)} sk12=${r.sk12} turns=${r.turns} sample=${JSON.stringify(r.sample ?? "")}`
      : r.status === "created" || r.status === "already-recovered"
        ? `${tag} ${basename(r.path)} conversationId=${r.conversationId.slice(0, 8)} turns=${r.turns}`
        : r.status === "failed"
          ? `${tag} ${basename(r.path)} error=${r.error}`
          : `${tag} ${basename(r.path)} reason=${r.reason}`;
    console.log(summary);
  }

  const reconcile = await reconcileSublabels(driver, flags.dryRun);
  console.log(`[admin-recover] reconcile-sublabels reconciled=${reconcile.reconciled}${reconcile.dryRun ? " (dry-run)" : ""}`);

  const counts = results.reduce((acc, r) => {
    acc[r.status] = (acc[r.status] ?? 0) + 1;
    return acc;
  }, {});
  console.log(`[admin-recover] summary mode=${mode} files=${results.length} ${Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(" ")}`);

  await driver.close();
}

main().catch(err => {
  console.error(`[admin-recover] fatal: ${err instanceof Error ? err.stack : String(err)}`);
  process.exit(1);
});
