#!/usr/bin/env node
// =============================================================================
// conversation-archive-ingest.mjs — thin parse-and-prepare helper for the
// `conversation-archive` skill (Task 435).
//
// Contract (post-Task 435): this script does ONLY the deterministic work —
// argv + env validation, source-file resolution, per-source normalisation,
// archiveSha256 + conversationIdentity computation, owner + participant
// verification, prior-archive cursor lookup, delta slicing, sessionization,
// stable archive-title computation. It emits ONE JSON line on stdout
// containing the prepared sessions array (turn-attributed text per session +
// per-session cursor metadata) plus every identity/scope value the
// `memory-ingest` MCP call needs. The script does NOT classify, does NOT
// call `memory-ingest`, does NOT loop.
//
// The dispatched specialist drives the rest in-turn: for each session in the
// emitted JSON, it reads the turn text, produces a typed-section JSON
// chunking, and invokes `memory-ingest` with that session's sections + the
// per-session cursor (`lastIngestedMessageHash`, `lastIngestedMessageAt`).
// The first session's call carries `cleanupPriorChunks: true` iff the run
// was invoked with `--rebuild`.
//
// Pre-Task-435 the script ran the Haiku classify + per-session memory-ingest
// itself; that path was deleted in Task 424 (server-side classifier removed)
// and finished off here in Task 435 (driver-side cleanup). The empty
// classifier stub the .mjs carried between 424 and 435 produced one raw
// unenriched `:Conversation` chunk per session — that path is gone too.
//
// Argv (positional): <archive-path>
// Argv (flags):       --source <enum>
//                     --participant-person-ids <csv>
//                     --scope <admin|public>
//                     [--rebuild]
//                     [--timezone <iana>]
//                     [--date-format <DD/MM/YY|MM/DD/YY|DD/MM/YYYY|MM/DD/YYYY>]
//
// Identity: `process.env.ACCOUNT_ID` and `process.env.USER_ID` are the only
// inputs. Both are plumbed by `spawn-env.ts` into every Bash subprocess;
// missing/malformed → LOUD-FAIL at phase=argv. The writer derives the owner
// :AdminUser elementId from (accountId, userId) one Cypher hop away.
//
// Sessions split at SESSION_GAP_HOURS=8 (fixed constant). The legacy
// `--session-gap-hours` flag is REMOVED; passing it FAILs at phase=argv.
//
// Stdout (success): one JSON line. See SKILL.md for the shape.
// Stderr: `[conversation-archive] FAIL phase=<…> reason="..."` on failure.
// Exit 0 on success; non-zero on failure.
// =============================================================================

import {
  existsSync,
  mkdirSync,
  mkdtempSync,
  readdirSync,
  rmSync,
  statSync,
  writeFileSync,
} from "node:fs";
import { dirname as pathDirname, join, resolve, dirname } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));

// Fixed session gap — pre-902 this was a CLI flag with a 12h default; oversize
// sessions on dense archives drove operators to re-issue with a smaller gap,
// which combined with cleanup-by-archiveSha256 to silently destroy 138 chunks
// (Adam Mackay incident, 2026-05-04). The flag is gone; the in-turn classify
// path absorbs oversize sessions on the specialist side now.
const SESSION_GAP_HOURS = 8;

const platformRoot =
  process.env.MAXY_PLATFORM_ROOT?.trim() ||
  resolve(__dirname, "..", "..", "..");

const NORMALISERS_PATH = resolve(
  platformRoot, "plugins", "memory", "mcp", "dist", "lib",
  "conversation-normalisers", "index.js",
);
const PIPELINE_PATH = resolve(
  platformRoot, "plugins", "memory", "mcp", "dist", "lib",
  "conversation-pipeline",
);
const NEO4J_LIB_PATH = resolve(
  platformRoot, "plugins", "memory", "mcp", "dist", "lib", "neo4j.js",
);
const UUID_LIB_PATH = resolve(
  platformRoot, "plugins", "memory", "mcp", "dist", "lib", "uuid.js",
);

const startedMs = Date.now();

function fail(phase, fields) {
  const fieldStr = Object.entries(fields)
    .map(([k, v]) =>
      typeof v === "string" && (v.includes(" ") || v.includes("="))
        ? `${k}="${v.replace(/"/g, '\\"')}"`
        : `${k}=${v ?? "-"}`,
    )
    .join(" ");
  process.stderr.write(`[conversation-archive] FAIL phase=${phase} ${fieldStr}\n`);
  process.exit(1);
}

// ---------------------------------------------------------------------------
// Argv parsing
// ---------------------------------------------------------------------------
const VALID_SOURCES = new Set([
  "whatsapp",
  "telegram",
  "signal",
  "linkedin-messages",
  "zoom-transcript",
  "meeting-minutes",
  "imessage",
  "slack",
  "x-dm",
  "email",
  "other",
]);

const BOOLEAN_FLAGS = new Set(["rebuild"]);

function camelCase(s) {
  return s.replace(/-([a-z])/g, (_m, c) => c.toUpperCase());
}

function parseArgv(argv) {
  const args = argv.slice(2);
  let archive = null;
  const flags = {};
  for (let i = 0; i < args.length; i++) {
    const a = args[i];
    if (!a.startsWith("--")) {
      if (archive == null) archive = a;
      else fail("argv", { reason: `unexpected positional argument "${a}"` });
      continue;
    }
    const key = a.slice(2);
    if (key === "session-gap-hours") {
      fail("argv", {
        reason: `--session-gap-hours is no longer supported; gap is fixed at 8h. Use --rebuild on the prior export bytes if you need to re-classify.`,
      });
    }
    if (BOOLEAN_FLAGS.has(camelCase(key))) {
      flags[camelCase(key)] = true;
      continue;
    }
    const v = args[++i];
    if (v == null) fail("argv", { reason: `flag --${key} requires a value` });
    flags[camelCase(key)] = v;
  }
  if (!archive) fail("argv", { reason: "missing positional <archive>" });
  if (!flags.source) fail("argv", { reason: "missing --source" });
  if (!VALID_SOURCES.has(flags.source)) {
    fail("argv", { reason: `invalid --source "${flags.source}" (whatsapp|telegram|signal|linkedin-messages|zoom-transcript|meeting-minutes|imessage|slack|x-dm|email|other)` });
  }
  if (!flags.participantPersonIds) {
    fail("argv", {
      reason: "missing --participant-person-ids (csv of operator-confirmed :Person/:AdminUser elementIds, owner excluded)",
    });
  }
  if (!flags.scope) fail("argv", { reason: "missing --scope" });
  if (flags.scope !== "admin" && flags.scope !== "public") {
    fail("argv", { reason: `invalid --scope "${flags.scope}" (admin|public)` });
  }
  return { archive, flags };
}

// ---------------------------------------------------------------------------
// Archive resolution. WhatsApp ships zip-or-dir-or-_chat.txt; other sources
// pass a single file path the normaliser interprets directly.
// ---------------------------------------------------------------------------
function resolveSourceFile(archivePath, source) {
  const abs = resolve(archivePath);
  if (!existsSync(abs)) fail("argv", { reason: `archive path not found: ${abs}` });
  const st = statSync(abs);

  if (source === "whatsapp") {
    if (st.isFile() && abs.endsWith(".zip")) {
      const tmp = mkdtempSync(join(tmpdir(), "conversation-archive-"));
      const unzip = spawnSync("unzip", ["-q", "-o", abs, "-d", tmp], { encoding: "utf8" });
      if (unzip.status !== 0) {
        rmSync(tmp, { recursive: true });
        fail("argv", {
          reason: "unzip failed",
          archive: abs,
          stderr: (unzip.stderr || "").slice(0, 200),
        });
      }
      const chat = findChatTxt(tmp);
      if (!chat) {
        rmSync(tmp, { recursive: true });
        fail("argv", { reason: "_chat.txt not found in zip", archive: abs });
      }
      return { sourceFile: chat, cleanup: () => rmSync(tmp, { recursive: true }) };
    }
    if (st.isDirectory()) {
      const chat = findChatTxt(abs);
      if (!chat) fail("argv", { reason: "_chat.txt not found in directory", archive: abs });
      return { sourceFile: chat, cleanup: () => {} };
    }
  }

  if (st.isFile()) {
    return { sourceFile: abs, cleanup: () => {} };
  }
  fail("argv", { reason: `unsupported archive shape for source=${source}: ${abs}` });
  return { sourceFile: abs, cleanup: () => {} };
}

function findChatTxt(dir) {
  const entries = readdirSync(dir, { withFileTypes: true });
  for (const e of entries) {
    if (e.isFile() && e.name === "_chat.txt") return join(dir, e.name);
  }
  for (const e of entries) {
    if (e.isDirectory()) {
      const nested = findChatTxt(join(dir, e.name));
      if (nested) return nested;
    }
  }
  return null;
}

// ---------------------------------------------------------------------------
// Stable archive title — computed once per ingest from inputs immutable for
// the conversation (source, accountId, sortedParticipantElementIds, archive
// dateRange) so re-ingests produce the same string. Memory-ingest writes it
// ON CREATE and COALESCE-on-MATCH; the UI reads `:ConversationArchive.title`
// directly. Format:
//   `<source> · <owner> ↔ <other1>, <other2>, … · <YYYY-MM-DD>→<YYYY-MM-DD>`
// Names: AdminUser uses `displayName` then `slug`; Person composes
// `givenName + familyName`. Missing names fall back to short elementId
// prefixes — same degraded format the migration uses.
// ---------------------------------------------------------------------------
function isoToYmd(iso) {
  const m = typeof iso === "string" ? iso.match(/^(\d{4}-\d{2}-\d{2})/) : null;
  return m ? m[1] : "?";
}

function pickNameForRow(labels, props) {
  if (Array.isArray(labels) && labels.includes("AdminUser")) {
    const dn = typeof props.displayName === "string" ? props.displayName.trim() : "";
    if (dn) return dn;
    const slug = typeof props.slug === "string" ? props.slug.trim() : "";
    if (slug) return slug;
  }
  if (Array.isArray(labels) && labels.includes("Person")) {
    const given = typeof props.givenName === "string" ? props.givenName.trim() : "";
    const family = typeof props.familyName === "string" ? props.familyName.trim() : "";
    const full = [given, family].filter((s) => s.length > 0).join(" ");
    if (full) return full;
  }
  return "";
}

async function computeArchiveTitle({
  source, accountId, ownerElementId, participantElementIds,
  firstMessageAt, lastMessageAt, getSession,
}) {
  const session = getSession();
  const nameByElementId = new Map();
  try {
    const ids = [ownerElementId, ...participantElementIds];
    const r = await session.run(
      `UNWIND $ids AS id
       MATCH (n) WHERE elementId(n) = id AND n.accountId = $accountId
       RETURN elementId(n) AS elemId, labels(n) AS labels, properties(n) AS props`,
      { ids, accountId },
    );
    for (const rec of r.records) {
      const elemId = rec.get("elemId");
      const labels = rec.get("labels");
      const props = rec.get("props") || {};
      const name = pickNameForRow(labels, props);
      if (name) nameByElementId.set(elemId, name);
    }
  } finally {
    await session.close().catch(() => {});
  }
  const ownerName = nameByElementId.get(ownerElementId) || ownerElementId.slice(0, 8);
  const otherNames = participantElementIds
    .map((id) => nameByElementId.get(id) || id.slice(0, 8))
    .join(", ");
  return `${source} · ${ownerName} ↔ ${otherNames} · ${isoToYmd(firstMessageAt)}→${isoToYmd(lastMessageAt)}`;
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
  const { archive, flags } = parseArgv(process.argv);
  const source = flags.source;

  let UUID_REGEX;
  try {
    ({ UUID_REGEX } = await import(UUID_LIB_PATH));
  } catch (err) {
    fail("import", {
      reason: "failed to import compiled uuid lib",
      detail: err instanceof Error ? err.message : String(err),
    });
  }

  const accountId = process.env.ACCOUNT_ID?.trim();
  if (!accountId) {
    fail("argv", { reason: "ACCOUNT_ID env missing — bin must be invoked under an authenticated agent context" });
  }
  if (!UUID_REGEX.test(accountId)) {
    fail("argv", { reason: `ACCOUNT_ID env malformed (not a UUID): "${accountId}"` });
  }
  const userId = process.env.USER_ID?.trim();
  if (!userId) {
    fail("argv", { reason: "USER_ID env missing — bin must be invoked under an authenticated user session" });
  }
  if (!UUID_REGEX.test(userId)) {
    fail("argv", { reason: `USER_ID env malformed (not a UUID): "${userId}"` });
  }

  const participantElementIds = flags.participantPersonIds
    .split(",")
    .map((s) => s.trim())
    .filter((s) => s.length > 0);
  if (participantElementIds.length === 0) {
    fail("argv", { reason: "--participant-person-ids must list at least one elementId" });
  }
  const scope = flags.scope;
  const timezone = flags.timezone || "Europe/London";
  const dateFormat = flags.dateFormat;
  const rebuild = flags.rebuild === true;

  // Imports — fail loudly if any compiled dist missing
  let getNormaliser;
  let sessionize, toTurnText, findDeltaCursor;
  let deriveConversationIdentity, deriveMessageContentHash;
  let verifyParticipantElementIds;
  let turnTextCachePath;
  let getSession;
  try {
    ({ getNormaliser } = await import(NORMALISERS_PATH));
    ({ sessionize } = await import(join(PIPELINE_PATH, "sessionize.js")));
    ({ toTurnText } = await import(join(PIPELINE_PATH, "to-turn-text.js")));
    ({ findDeltaCursor } = await import(join(PIPELINE_PATH, "delta-cursor.js")));
    ({ deriveConversationIdentity, deriveMessageContentHash } =
      await import(join(PIPELINE_PATH, "derive-keys.js")));
    ({ verifyParticipantElementIds } = await import(join(PIPELINE_PATH, "sender-bind.js")));
    ({ turnTextCachePath } = await import(join(PIPELINE_PATH, "turn-text-cache-path.js")));
    ({ getSession } = await import(NEO4J_LIB_PATH));
  } catch (err) {
    fail("import", {
      reason: "failed to import compiled dist",
      detail: err instanceof Error ? err.message : String(err),
    });
  }

  // Resolve owner :AdminUser elementId from (accountId, userId).
  let ownerElementId;
  {
    const lookupSession = getSession();
    try {
      const r = await lookupSession.run(
        `MATCH (au:AdminUser { accountId: $accountId, userId: $userId })
         RETURN elementId(au) AS ownerElementId LIMIT 1`,
        { accountId, userId },
      );
      const row = r.records[0]?.get("ownerElementId");
      if (typeof row !== "string" || !row) {
        await lookupSession.close().catch(() => {});
        fail("argv", {
          reason: `USER_ID does not resolve to an AdminUser under ACCOUNT_ID (accountId=${accountId.slice(0, 8)}, userId=${userId.slice(0, 8)})`,
        });
      }
      ownerElementId = row;
    } catch (err) {
      await lookupSession.close().catch(() => {});
      fail("argv", {
        reason: `AdminUser lookup failed: ${err instanceof Error ? err.message : String(err)}`,
      });
    }
    await lookupSession.close().catch(() => {});
  }

  // Resolve source file
  const { sourceFile, cleanup } = resolveSourceFile(archive, source);

  // Run the source-specific normaliser
  let normaliserResult;
  try {
    const normaliser = getNormaliser(source);
    normaliserResult = await normaliser({
      filePath: sourceFile,
      accountId,
      timezone,
      opts: dateFormat ? { dateFormat } : undefined,
    });
  } catch (err) {
    cleanup();
    fail("parse", { reason: err instanceof Error ? err.message : String(err) });
  }
  const allLines = normaliserResult.parsedLines;
  const archiveSha256 = normaliserResult.archiveSha256;
  const archiveSourceFile = normaliserResult.archiveSourceFile;

  // Zero matched timestamps means this isn't a chat archive — route through
  // document-ingest instead. LOUD-FAIL surfaces misroute as an actionable
  // signal rather than a silent empty ingest.
  if (allLines.length === 0) {
    cleanup();
    fail("parse", {
      reason: `no timestamp prefixes detected — this archive contains no chat messages with the expected source=${source} grammar; route through document-ingest with mode='document', not conversation-archive-ingest`,
    });
  }

  // Verify operator-confirmed participants resolve to graph nodes with the
  // right accountId and labels. Per-line senderName binding is gone; only
  // owner + operator-confirmed participants attach via :PARTICIPANT_IN.
  let session = getSession();
  try {
    await verifyParticipantElementIds({
      session, accountId, ownerElementId, participantElementIds,
    });
  } catch (err) {
    await session.close().catch(() => {});
    cleanup();
    if (err && err.userFacing) {
      process.stderr.write(`[conversation-archive] FAIL ${err.message}\n`);
      process.exit(1);
    }
    fail("argv", { reason: err instanceof Error ? err.message : String(err) });
  }

  // Derive conversationIdentity and look up prior :ConversationArchive (Task
  // 397). Under `--rebuild` we skip the prior-cursor lookup entirely; the
  // run is treated as first-ingest, and the specialist's first memory-ingest
  // call will carry `cleanupPriorChunks: true` to drop prior chunks for this
  // archiveSha256.
  const conversationIdentity = deriveConversationIdentity({
    accountId,
    participantElementIds: [ownerElementId, ...participantElementIds],
    source,
  });
  let priorArchive = null;
  let priorChunkCountForSha = 0;
  if (!rebuild) {
    try {
      const r = await session.run(
        `MATCH (a:ConversationArchive { conversationIdentity: $cid, source: $source })
         OPTIONAL MATCH (a)-[:HAS_SECTION]->(c:Section { archiveSha256: $sha })
         RETURN elementId(a) AS elemId,
                a.lastIngestedMessageHash AS lastHash,
                a.lastIngestedMessageAt   AS lastAt,
                count(c)                  AS chunksForSha LIMIT 1`,
        { cid: conversationIdentity, source, sha: archiveSha256 },
      );
      if (r.records[0] && r.records[0].get("elemId")) {
        priorArchive = {
          elemId: r.records[0].get("elemId"),
          lastHash: r.records[0].get("lastHash"),
          lastAt: r.records[0].get("lastAt"),
        };
        const cs = r.records[0].get("chunksForSha");
        priorChunkCountForSha =
          typeof cs === "number" ? cs : cs?.toNumber?.() ?? 0;
      }
    } catch (err) {
      await session.close().catch(() => {});
      cleanup();
      fail("delta-cursor-missing", { reason: `conversationArchive lookup failed: ${err instanceof Error ? err.message : String(err)}` });
    }
  }
  await session.close().catch(() => {});

  // Compute deltaStart. Three branches:
  //   (i) --rebuild: deltaStart=0, kind=rebuild, prepare entire archive.
  //  (ii) no prior archive (or no cursor): first-ingest, deltaStart=0.
  // (iii) prior archive + cursor: standard delta-append. Same-bytes (no
  //       cursor advance possible) AND chunks exist for this sha → emit
  //       empty-delta JSON; specialist surfaces a noop message.
  let deltaStart = 0;
  let deltaKind = rebuild ? "rebuild" : "first-ingest";
  if (!rebuild && priorArchive && priorArchive.lastHash) {
    const cursor = findDeltaCursor(allLines, priorArchive.lastHash);
    if (cursor.kind === "missing") {
      cleanup();
      fail("delta-cursor-missing", {
        reason: `prior cursor not found in re-export (operator deleted prior messages, or this is a different chat archive)`,
        priorArchive: priorArchive.elemId,
        lastIngestedMessageAt: priorArchive.lastAt,
      });
    }
    if (cursor.kind === "empty") {
      cleanup();
      process.stdout.write(JSON.stringify({
        archiveElementId: priorArchive.elemId,
        conversationIdentity,
        archiveSha256,
        archiveSourceFile,
        source,
        scope,
        accountId,
        ownerElementId,
        participantElementIds,
        rebuild,
        parsed: normaliserResult.counters.parsed,
        mediaSkipped: normaliserResult.counters.mediaSkipped,
        systemSkipped: normaliserResult.counters.systemSkipped,
        delta: { kind: "empty-delta", deltaStart: allLines.length, deltaMessages: 0 },
        dateRange: { first: allLines[0].dateSent, last: allLines[allLines.length - 1].dateSent },
        senderHistogram: normaliserResult.counters.senderHistogram ?? [],
        priorChunkCount: priorChunkCountForSha,
        priorLastIngestedMessageAt: priorArchive.lastAt,
        sessions: [],
        ms: Date.now() - startedMs,
      }) + "\n");
      process.exit(0);
    }
    deltaStart = cursor.deltaStart;
    deltaKind = "delta";
  }
  const deltaLines = allLines.slice(deltaStart);

  // Sessionize delta at the fixed 8h gap.
  const sessions = sessionize(deltaLines, SESSION_GAP_HOURS);

  // Stable archive title — computed once from the full archive's date range
  // (not the delta), so a delta-append re-ingest produces the same title.
  const archiveTitle = await computeArchiveTitle({
    source, accountId, ownerElementId, participantElementIds,
    firstMessageAt: allLines[0].dateSent,
    lastMessageAt: allLines[allLines.length - 1].dateSent,
    getSession,
  });

  // Build the per-session payload the specialist iterates in-turn. Each entry
  // carries the turn text the specialist classifies + the cursor values the
  // specialist passes through to `memory-ingest` for atomic checkpointing.
  //
  // Task 468: also write turnText to a deterministic tempfile per session
  // (path = turnTextCachePath(conversationIdentity, archiveSha256, sessionIndex)).
  // The MCP-side writer reads this file to slice `:Section.body` server-side
  // instead of trusting an LLM-supplied body field. The bin does NOT clean up
  // the tempfiles; the writer unlinks each one on successful ingest. Stale
  // files from aborted runs are benign — re-running the bin with the same
  // archive bytes overwrites them (deterministic key).
  const sessionPayloads = sessions.map((s, sIdx) => {
    const sessionIndex = sIdx + 1;
    const lastMessage = s.messages[s.messages.length - 1];
    const turnText = toTurnText(s);
    const cachePath = turnTextCachePath({
      conversationIdentity,
      archiveSha256,
      sessionIndex,
    });
    try {
      mkdirSync(pathDirname(cachePath), { recursive: true });
      writeFileSync(cachePath, turnText, "utf8");
    } catch (err) {
      cleanup();
      fail("write", {
        reason: `failed to write turnText cache: ${err instanceof Error ? err.message : String(err)}`,
        path: cachePath,
        sessionIndex,
      });
    }
    return {
      sessionIndex,
      totalSessions: sessions.length,
      messageCount: s.messages.length,
      firstMessageAt: s.firstMessageAt,
      lastMessageAt: s.lastMessageAt,
      lastMessageHash: deriveMessageContentHash({
        dateSent: lastMessage.dateSent,
        body: lastMessage.body,
      }),
      turnText,
      // Diagnostic only. The MCP writer reconstructs this path from
      // (conversationIdentity, archiveSha256, sessionIndex) — it does not
      // consume turnTextPath from the payload. Surfaced so an operator can
      // `ls` the cache when debugging a stuck ingest.
      turnTextPath: cachePath,
    };
  });

  cleanup();

  process.stdout.write(JSON.stringify({
    archiveElementId: priorArchive ? priorArchive.elemId : null,
    conversationIdentity,
    archiveSha256,
    archiveSourceFile,
    archiveTitle,
    source,
    scope,
    accountId,
    ownerElementId,
    participantElementIds,
    rebuild,
    parsed: normaliserResult.counters.parsed,
    mediaSkipped: normaliserResult.counters.mediaSkipped,
    systemSkipped: normaliserResult.counters.systemSkipped,
    delta: { kind: deltaKind, deltaStart, deltaMessages: deltaLines.length },
    dateRange: { first: allLines[0].dateSent, last: allLines[allLines.length - 1].dateSent },
    senderHistogram: normaliserResult.counters.senderHistogram ?? [],
    priorChunkCount: priorChunkCountForSha,
    priorLastIngestedMessageAt: priorArchive ? priorArchive.lastAt : null,
    sessions: sessionPayloads,
    ms: Date.now() - startedMs,
  }) + "\n");
  process.exit(0);
}

main().catch((err) => {
  fail("uncaught", { reason: err instanceof Error ? err.message : String(err) });
});
