#!/usr/bin/env node
/**
 * Boot + shape smoke test for the writer-craft MCP server.
 *
 * Verifies without a live Neo4j connection:
 *   1. Cypher forbidden-pattern gate (Task 458 fold-in): no NULLS LAST /
 *      NULLS FIRST / RETURNING patterns in the Cypher emitted by tool sources.
 *   2. Every compiled tool module loads without throwing.
 *   3. Every exported handler is a function with the expected name.
 *   4. AUTHORSHIP_MODES enum covers the five spec-required values.
 *   5. TAGGABLE_PRIMARY_LABELS covers the three content labels.
 *   6. FORMAT_VALUES covers the six writing-format values (Task 462).
 *   7. voiceCorpusWhereWithFormat() appends the format predicate.
 * *   8. voice-retrieve-conditioning discriminates error from no-data (Task 493):
 *      status:"error" on blank identity and on a forced lookup throw, never
 *      collapsing a failed lookup into the no-data payload.
 *   9. voice-tag-content validates mode and format enums, requires format,
 *      and returns zero counts on empty nodeIds (with format supplied).
 *  11. voice-ingest-session-text: filterOperatorText() keeps real turns
 *      and excludes slash commands, paste blocks, and system reminders.
 *
 * Exit code: 0 on pass, 1 on any assertion failure.
 */
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { readdirSync } from "node:fs";

const __dirname = dirname(fileURLToPath(import.meta.url));
const TOOLS_SRC = join(__dirname, "../src/tools");
const LIB_SRC = join(__dirname, "../src/lib");

const errors = [];
function check(name, fn) {
  try {
    const result = fn();
    if (result && typeof result.then === "function") {
      return result.then(
        () => console.log(`  ok   ${name}`),
        (err) => {
          errors.push({ name, err });
          console.error(`  FAIL ${name}: ${err.message}`);
        },
      );
    }
    console.log(`  ok   ${name}`);
  } catch (err) {
    errors.push({ name, err });
    console.error(`  FAIL ${name}: ${err.message}`);
  }
}

console.log("[writer-craft smoke] starting");

// ---------------------------------------------------------------------------
// 1. Cypher forbidden-pattern gate (Task 458).
//    No NULLS LAST / NULLS FIRST / RETURNING in tool source files.
//    These patterns are Postgres-isms; neo4j-driver silently ignores or
//    errors on them. The gate runs on .ts source files so it catches issues
//    before compilation.
// ---------------------------------------------------------------------------
console.log("\n-- Cypher gate --");
const FORBIDDEN_PATTERNS = [/NULLS\s+LAST/i, /NULLS\s+FIRST/i, /\bRETURNING\b/];

function scanFile(filePath) {
  const content = readFileSync(filePath, "utf-8");
  const hits = [];
  for (const pat of FORBIDDEN_PATTERNS) {
    if (pat.test(content)) hits.push(pat.toString());
  }
  return hits;
}

function scanDir(dirPath) {
  const allHits = {};
  for (const entry of readdirSync(dirPath)) {
    if (!entry.endsWith(".ts")) continue;
    const filePath = join(dirPath, entry);
    const hits = scanFile(filePath);
    if (hits.length > 0) allHits[filePath] = hits;
  }
  return allHits;
}

await check("no forbidden Cypher patterns in tool sources", () => {
  const toolHits = scanDir(TOOLS_SRC);
  const libHits = scanDir(LIB_SRC);
  const allHits = { ...toolHits, ...libHits };
  const fileList = Object.keys(allHits);
  assert.equal(
    fileList.length,
    0,
    `Forbidden Cypher patterns found:\n${fileList.map((f) => `  ${f}: ${allHits[f].join(", ")}`).join("\n")}`,
  );
});

// ---------------------------------------------------------------------------
// 2. Module load + export shape checks.
// ---------------------------------------------------------------------------
console.log("\n-- Module shapes --");
const tagMod = await import("../dist/tools/voice-tag-content.js");
const distilMod = await import("../dist/tools/voice-distil-profile.js");
const retrieveMod = await import("../dist/tools/voice-retrieve-conditioning.js");
const feedbackMod = await import("../dist/tools/voice-record-feedback.js");
const ingestMod = await import("../dist/tools/voice-ingest-session-text.js");
const corpusMod = await import("../dist/lib/voice-corpus.js");

await check("voice-tag-content exports voiceTagContent function", () => {
  assert.equal(typeof tagMod.voiceTagContent, "function");
});
await check("voice-tag-content exports AUTHORSHIP_MODES set of 5", () => {
  const expected = [
    "human-only",
    "human-led-agent-assisted",
    "agent-led-human-reviewed",
    "agent-only",
    "unknown",
  ];
  assert.equal(tagMod.AUTHORSHIP_MODES.size, 5);
  for (const m of expected) assert.ok(tagMod.AUTHORSHIP_MODES.has(m), `missing ${m}`);
});
await check("voice-distil-profile exports voiceDistilProfile function", () => {
  assert.equal(typeof distilMod.voiceDistilProfile, "function");
});
await check("voice-retrieve-conditioning exports voiceRetrieveConditioning function", () => {
  assert.equal(typeof retrieveMod.voiceRetrieveConditioning, "function");
});
await check("voice-record-feedback exports voiceRecordFeedback function", () => {
  assert.equal(typeof feedbackMod.voiceRecordFeedback, "function");
});
await check("voice-ingest-session-text exports required functions", () => {
  assert.equal(typeof ingestMod.voiceIngestSessionText, "function");
  assert.equal(typeof ingestMod.filterOperatorText, "function");
});

// ---------------------------------------------------------------------------
// 3. voice-corpus exports — format axis (Task 462).
// ---------------------------------------------------------------------------
console.log("\n-- voice-corpus format exports --");
await check("VOICE_CORPUS_WHERE is non-empty string with both predicates", () => {
  assert.equal(typeof corpusMod.VOICE_CORPUS_WHERE, "string");
  assert.ok(corpusMod.VOICE_CORPUS_WHERE.includes("authorshipMode IN"));
  assert.ok(corpusMod.VOICE_CORPUS_WHERE.includes("human-only"));
  assert.ok(corpusMod.VOICE_CORPUS_WHERE.includes("Section"));
});
await check("TAGGABLE_PRIMARY_LABELS covers the three content labels", () => {
  const labels = [...corpusMod.TAGGABLE_PRIMARY_LABELS];
  assert.deepEqual(labels.sort(), ["KnowledgeDocument", "Message", "SocialPost"]);
});
await check("FORMAT_VALUES covers all six writing formats", () => {
  const expected = ["text", "email", "social-post", "article", "marketing-copy", "note"];
  const actual = [...corpusMod.FORMAT_VALUES].sort();
  assert.deepEqual(actual.sort(), expected.sort());
});
await check("voiceCorpusWhereWithFormat appends format predicate", () => {
  const result = corpusMod.voiceCorpusWhereWithFormat("email");
  assert.ok(typeof result === "string");
  assert.ok(result.includes(corpusMod.VOICE_CORPUS_WHERE));
  assert.ok(result.includes("n.format = $format"));
  assert.ok(result.includes("email") || result.includes("$format"));
});

// ---------------------------------------------------------------------------
// 4. deriveFormat is gone (Task 471) — format is operator-supplied editorial.
// ---------------------------------------------------------------------------
console.log("\n-- format is operator-supplied --");
await check("deriveFormat export is removed from voice-corpus", () => {
  assert.equal(corpusMod.deriveFormat, undefined);
});

// ---------------------------------------------------------------------------
// 5. Error-vs-no-data discrimination for voice-retrieve-conditioning (Task 493).
//    Blank identity and a forced lookup failure must both return status:"error",
//    distinct from the genuine no-data state — so the drafting agent never
//    reports "no profile exists" when the lookup actually errored.
// ---------------------------------------------------------------------------
console.log("\n-- error-vs-no-data discrimination --");
await check(
  "voice-retrieve-conditioning returns status:error on missing accountId",
  async () => {
    const result = await retrieveMod.voiceRetrieveConditioning({
      accountId: "",
      userId: "u",
      brief: { format: "text", length: "short" },
    });
    assert.equal(result.status, "error");
    assert.equal(result.styleCard, null);
    assert.deepEqual(result.exemplars, []);
  },
);
await check(
  "voice-retrieve-conditioning returns status:error on missing userId",
  async () => {
    const result = await retrieveMod.voiceRetrieveConditioning({
      accountId: "acc",
      userId: "",
      brief: { format: "email", length: "long" },
    });
    assert.equal(result.status, "error");
    assert.equal(result.styleCard, null);
    assert.deepEqual(result.exemplars, []);
  },
);
await check(
  "voice-retrieve-conditioning returns status:error (not no-data) when the lookup throws",
  async () => {
    // Force the Neo4j call to fail: an empty NEO4J_URI makes getSession()
    // throw inside the try block. Save/restore the env and reset the driver
    // singleton so neither this check nor later checks inherit the empty URI.
    const { closeDriver } = await import("../dist/lib/neo4j.js");
    const savedUri = process.env.NEO4J_URI;
    try {
      process.env.NEO4J_URI = "";
      await closeDriver();
      const result = await retrieveMod.voiceRetrieveConditioning({
        accountId: "acc",
        userId: "user",
        brief: { format: "text", length: "short" },
      });
      assert.equal(result.status, "error");
      assert.notEqual(result.status, "no-data");
      assert.equal(result.styleCard, null);
      assert.deepEqual(result.exemplars, []);
    } finally {
      if (savedUri === undefined) delete process.env.NEO4J_URI;
      else process.env.NEO4J_URI = savedUri;
      await closeDriver();
    }
  },
);

// ---------------------------------------------------------------------------
// 6. voice-tag-content enum validation.
// ---------------------------------------------------------------------------
console.log("\n-- voice-tag-content validation --");
await check("voice-tag-content rejects an invalid mode", async () => {
  await assert.rejects(
    () =>
      tagMod.voiceTagContent({
        nodeIds: ["x"],
        mode: "not-a-mode",
        accountId: "acc",
        format: "text",
      }),
    /invalid authorshipMode/,
  );
});
await check("voice-tag-content rejects an invalid format", async () => {
  await assert.rejects(
    () =>
      tagMod.voiceTagContent({
        nodeIds: ["x"],
        mode: "human-only",
        accountId: "acc",
        format: "bad-format",
      }),
    /invalid format/,
  );
});
await check("voice-tag-content rejects empty accountId", async () => {
  await assert.rejects(
    () =>
      tagMod.voiceTagContent({
        nodeIds: ["x"],
        mode: "human-only",
        accountId: "",
        format: "text",
      }),
    /accountId is required/,
  );
});
await check("voice-tag-content rejects missing format", async () => {
  await assert.rejects(
    () =>
      tagMod.voiceTagContent({
        nodeIds: ["x"],
        mode: "human-only",
        accountId: "acc",
      }),
    /format is required/,
  );
});
await check("voice-tag-content returns zero counts on empty nodeIds (no DB hit)", async () => {
  const result = await tagMod.voiceTagContent({
    nodeIds: [],
    mode: "human-only",
    accountId: "acc",
    format: "text",
  });
  assert.deepEqual(result, { updated: 0, skipped: 0, skippedReasons: {} });
});

// ---------------------------------------------------------------------------
// 7. voice-ingest-session-text: operator-turn filter (no DB required).
// ---------------------------------------------------------------------------
console.log("\n-- operator-turn filter --");

await check("filterOperatorText: real turn is kept (and trimmed)", () => {
  assert.equal(ingestMod.filterOperatorText("  Hello world  "), "Hello world");
});
await check("filterOperatorText: slash command is excluded", () => {
  assert.equal(ingestMod.filterOperatorText("/end"), null);
});
await check("filterOperatorText: system-reminder is excluded", () => {
  assert.equal(
    ingestMod.filterOperatorText("<system-reminder>some injected text</system-reminder>"),
    null,
  );
});
await check("filterOperatorText: empty / whitespace-only is excluded", () => {
  assert.equal(ingestMod.filterOperatorText("   "), null);
});
await check("filterOperatorText: non-string is excluded", () => {
  assert.equal(ingestMod.filterOperatorText(undefined), null);
  assert.equal(ingestMod.filterOperatorText(42), null);
});
await check("filterOperatorText: paste block >= 4096 chars is excluded", () => {
  assert.equal(ingestMod.filterOperatorText("x".repeat(4096)), null);
  assert.equal(ingestMod.filterOperatorText("x".repeat(4097)), null);
});
await check("filterOperatorText: 4095 chars is included", () => {
  const body = "x".repeat(4095);
  assert.equal(ingestMod.filterOperatorText(body), body);
});

// turns-array semantics: a mixed array filters down to the real turns.
await check(
  "turns array: 3 real turns + 3 excluded → filterOperatorText keeps 3",
  () => {
    const turns = [
      "First real turn",
      "Second real turn",
      "/end", // slash command
      "<system-reminder>injected</system-reminder>", // harness injection
      "Third real turn",
      "   ", // whitespace only
    ];
    const kept = turns.map((t) => ingestMod.filterOperatorText(t)).filter((t) => t !== null);
    assert.deepEqual(kept, ["First real turn", "Second real turn", "Third real turn"]);
  },
);

// ---------------------------------------------------------------------------
// Final summary.
// ---------------------------------------------------------------------------
if (errors.length > 0) {
  console.error(`\n[writer-craft smoke] FAILED — ${errors.length} assertion(s)`);
  process.exit(1);
}
console.log("\n[writer-craft smoke] all passed");
