import test from "node:test";
import assert from "node:assert/strict";
import {
  ACTION_PROVENANCE_LABELS,
  writeNodeWithEdges,
  type GraphRelationship,
} from "../index.js";

// writeNodeWithEdges now rejects writes whose accountId does not
// match the spawning process's ACCOUNT_ID. These provenance-gate tests
// predate that gate; they pass a literal `expectedAccountId` so they
// exercise only the provenance gate they were written to test.
const TEST_ACCOUNT_ID = "12345678-9abc-def0-1234-56789abcdef0";

// Stub session/tx — captures Cypher calls and returns canned results.
interface StubTx {
  run(cypher: string, params?: Record<string, unknown>): Promise<{
    records: { get(field: string): unknown }[];
    summary?: { counters: { updates(): { relationshipsCreated: number } } };
  }>;
}

function makeStubSession(opts: {
  preCheckLabelsByTarget: Map<string, string[]>;
  edgesCreatedPerCall?: number;
}) {
  const calls: { cypher: string; params: Record<string, unknown> }[] = [];
  const tx: StubTx = {
    async run(cypher: string, params: Record<string, unknown> = {}) {
      calls.push({ cypher, params });
      if (cypher.includes("RETURN elementId(t) AS id, labels(t) AS labels")) {
        const ids = (params.ids as string[]) ?? [];
        const records = ids
          .filter((id) => opts.preCheckLabelsByTarget.has(id))
          .map((id) => ({
            get(field: string) {
              if (field === "id") return id;
              if (field === "labels") return opts.preCheckLabelsByTarget.get(id) ?? [];
              throw new Error(`unknown field ${field}`);
            },
          }));
        return { records };
      }
      if (cypher.includes("CREATE (n:")) {
        return {
          records: [
            {
              get(field: string) {
                if (field === "nodeId") return "new-element-id";
                if (field === "nodeLabels") return ["Person"];
                throw new Error(`unknown field ${field}`);
              },
            },
          ],
        };
      }
      if (cypher.includes("CREATE (a)-[") || cypher.includes("CREATE (b)-[")) {
        return {
          records: [],
          summary: {
            counters: {
              updates() {
                return { relationshipsCreated: opts.edgesCreatedPerCall ?? 1 };
              },
            },
          },
        };
      }
      return { records: [] };
    },
  };
  const session = {
    async executeWrite<T>(work: (tx: StubTx) => Promise<T>): Promise<T> {
      return await work(tx);
    },
  };
  return { session, calls };
}

test("ACTION_PROVENANCE_LABELS includes the documented set", () => {
  for (const label of [
    "Person",
    "UserProfile",
    "AdminUser",
    "Organization",
    "LocalBusiness",
    "CloudflareTunnel",
    "CloudflareHostname",
  ]) {
    assert.ok(
      ACTION_PROVENANCE_LABELS.has(label),
      `${label} should be in ACTION_PROVENANCE_LABELS`,
    );
  }
});

// Task 580 relaxed missing-provenance from a hard reject to a soft warn.
// The write now proceeds; a single structured stderr line carries the labels
// and agent so operators can quantify how often the gap fires.
test("writeNodeWithEdges accepts :Person write missing any inbound PRODUCED edge and emits a warn line", async () => {
  const { session } = makeStubSession({
    preCheckLabelsByTarget: new Map([["other-target", ["LocalBusiness"]]]),
  });
  const relationships: GraphRelationship[] = [
    // No inbound PRODUCED at all — neither Task nor Conversation nor Message.
    { type: "PART_OF", direction: "outgoing", targetNodeId: "other-target" },
  ];
  const captured: string[] = [];
  const originalWrite = process.stderr.write.bind(process.stderr);
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  process.stderr.write = ((chunk: any) => {
    captured.push(typeof chunk === "string" ? chunk : String(chunk));
    return true;
  }) as typeof process.stderr.write;
  try {
    const res = await writeNodeWithEdges({
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      session: session as any,
      labels: ["Person"],
      props: { accountId: TEST_ACCOUNT_ID },
      relationships,
      createdBy: { agent: "maxy-admin", session: "sess-1", tool: "memory-write" },
      expectedAccountId: TEST_ACCOUNT_ID,
    });
    assert.equal(res.edgesCreated, 1);
  } finally {
    process.stderr.write = originalWrite;
  }
  const warnLine = captured.find((l) => l.includes("[graph-write] warn reason=missing-provenance"));
  assert.ok(warnLine, `expected warn line; captured=${JSON.stringify(captured)}`);
  assert.match(warnLine!, /labels=Person/);
  assert.match(warnLine!, /agent=maxy-admin/);
});

test("writeNodeWithEdges accepts :Person write with PRODUCED-from-Task edge", async () => {
  const { session } = makeStubSession({
    preCheckLabelsByTarget: new Map([
      ["task-id", ["Task"]],
    ]),
  });
  const relationships: GraphRelationship[] = [
    { type: "PRODUCED", direction: "incoming", targetNodeId: "task-id" },
  ];
  const res = await writeNodeWithEdges({
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    session: session as any,
    labels: ["Person"],
    props: { accountId: TEST_ACCOUNT_ID },
    relationships,
    createdBy: { agent: "maxy-admin", session: "sess-1", tool: "memory-write" },
    expectedAccountId: TEST_ACCOUNT_ID,
  });
  assert.equal(res.edgesCreated, 1);
});

test("writeNodeWithEdges accepts :Person write with no Task edge when agent='system' (bootstrap exemption)", async () => {
  const { session } = makeStubSession({
    preCheckLabelsByTarget: new Map([["admin-user-id", ["AdminUser"]]]),
  });
  const relationships: GraphRelationship[] = [
    { type: "OWNS", direction: "incoming", targetNodeId: "admin-user-id" },
  ];
  const res = await writeNodeWithEdges({
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    session: session as any,
    labels: ["Person"],
    props: { accountId: TEST_ACCOUNT_ID },
    relationships,
    createdBy: { agent: "system", source: "writeAdminUserAndPerson" },
    expectedAccountId: TEST_ACCOUNT_ID,
  });
  assert.equal(res.edgesCreated, 1);
});

// gate widened to accept:Conversation provenance for direct
// admin asks. The wrapper auto-injects the inbound PRODUCED edge from the
// active :AdminConversation via SESSION_NODE_ID.
test("writeNodeWithEdges accepts :Person write with PRODUCED-from-Conversation edge", async () => {
  const { session } = makeStubSession({
    preCheckLabelsByTarget: new Map([
      // AdminConversation carries the parent :Conversation label too.
      ["conv-id", ["Conversation", "AdminConversation"]],
    ]),
  });
  const relationships: GraphRelationship[] = [
    { type: "PRODUCED", direction: "incoming", targetNodeId: "conv-id" },
  ];
  const res = await writeNodeWithEdges({
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    session: session as any,
    labels: ["Person"],
    props: { accountId: TEST_ACCOUNT_ID },
    relationships,
    createdBy: { agent: "maxy-admin", session: "sess-1", tool: "contact-create" },
    expectedAccountId: TEST_ACCOUNT_ID,
  });
  assert.equal(res.edgesCreated, 1);
});

// Message subtypes (UserMessage, AssistantMessage, AdminMessage)
// also qualify because the labels() array includes the parent :Message label.
test("writeNodeWithEdges accepts :Person write with PRODUCED-from-Message edge", async () => {
  const { session } = makeStubSession({
    preCheckLabelsByTarget: new Map([
      ["msg-id", ["Message", "UserMessage"]],
    ]),
  });
  const relationships: GraphRelationship[] = [
    { type: "PRODUCED", direction: "incoming", targetNodeId: "msg-id" },
  ];
  const res = await writeNodeWithEdges({
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    session: session as any,
    labels: ["Person"],
    props: { accountId: TEST_ACCOUNT_ID },
    relationships,
    createdBy: { agent: "maxy-admin", session: "sess-1", tool: "contact-create" },
    expectedAccountId: TEST_ACCOUNT_ID,
  });
  assert.equal(res.edgesCreated, 1);
});

// Defence-in-depth: a PRODUCED edge whose source is not in
// {Task, Conversation, Message} does not satisfy the gate. Under the
// Task 580 soft semantics the write still proceeds and the warn line fires —
// the source-label allowlist now governs whether the write counts as
// provenance-satisfied for the warn check, not whether it lands.
test("writeNodeWithEdges accepts :Person write with PRODUCED edge from a non-provenance-source label and emits a warn line", async () => {
  const { session } = makeStubSession({
    preCheckLabelsByTarget: new Map([
      ["business-id", ["LocalBusiness"]],
    ]),
  });
  const relationships: GraphRelationship[] = [
    { type: "PRODUCED", direction: "incoming", targetNodeId: "business-id" },
  ];
  const captured: string[] = [];
  const originalWrite = process.stderr.write.bind(process.stderr);
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  process.stderr.write = ((chunk: any) => {
    captured.push(typeof chunk === "string" ? chunk : String(chunk));
    return true;
  }) as typeof process.stderr.write;
  try {
    const res = await writeNodeWithEdges({
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      session: session as any,
      labels: ["Person"],
      props: { accountId: TEST_ACCOUNT_ID },
      relationships,
      createdBy: { agent: "maxy-admin", session: "sess-1", tool: "memory-write" },
      expectedAccountId: TEST_ACCOUNT_ID,
    });
    assert.equal(res.edgesCreated, 1);
  } finally {
    process.stderr.write = originalWrite;
  }
  const warnLine = captured.find((l) => l.includes("[graph-write] warn reason=missing-provenance"));
  assert.ok(warnLine, `expected warn line; captured=${JSON.stringify(captured)}`);
});

test("writeNodeWithEdges accepts non-action-provenance label writes without Task edge", async () => {
  const { session } = makeStubSession({
    preCheckLabelsByTarget: new Map([["doc-id", ["KnowledgeDocument"]]]),
  });
  const relationships: GraphRelationship[] = [
    { type: "ABOUT", direction: "outgoing", targetNodeId: "doc-id" },
  ];
  const res = await writeNodeWithEdges({
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    session: session as any,
    labels: ["Question"], // not in ACTION_PROVENANCE_LABELS
    props: { accountId: TEST_ACCOUNT_ID },
    relationships,
    createdBy: { agent: "maxy-admin", session: "sess-1", tool: "memory-write" },
    expectedAccountId: TEST_ACCOUNT_ID,
  });
  assert.equal(res.edgesCreated, 1);
});
