import test from "node:test";
import assert from "node:assert/strict";
import { validate, type SchemaSnapshot } from "../cypher-validate.js";

// Ground-truth snapshot modelled on the incident's actual Neo4j schema.
// Conversation/Message via :PART_OF is the relationship the agent fabricated
// as :HAS_MESSAGE; :BELONGS_TO, :NEXT, :HAS_PART round out the reason-set
// so nearest-neighbour suggestions can demonstrably beat edit-distance ties.
const snapshot: SchemaSnapshot = {
  labels: new Set([
    "Conversation",
    "AdminConversation",
    "PublicConversation",
    "Message",
    "UserMessage",
    "AssistantMessage",
    "Person",
    "LocalBusiness",
    "Task",
    "KnowledgeDocument",
  ]),
  relationshipTypes: new Set([
    "PART_OF",
    "HAS_PART",
    "NEXT",
    "BELONGS_TO",
    "ADMIN_OF",
    "AUTHORED_BY",
  ]),
};

test("accepts known label + relationship", () => {
  const result = validate(
    "MATCH (m:Message)-[:PART_OF]->(c:Conversation) RETURN c, m",
    snapshot,
  );
  assert.equal(result.ok, true);
  assert.deepEqual(result.unknown, []);
  assert.ok(result.labelTokens.includes("Message"));
  assert.ok(result.labelTokens.includes("Conversation"));
  assert.ok(result.edgeTokens.includes("PART_OF"));
});

test("rejects fabricated :HAS_MESSAGE relationship and suggests :PART_OF", () => {
  const result = validate(
    "MATCH (c:Conversation)-[:HAS_MESSAGE]->(m:Message) RETURN c",
    snapshot,
  );
  assert.equal(result.ok, false);
  const rel = result.unknown.find((u) => u.token === "HAS_MESSAGE");
  assert.ok(rel, "expected HAS_MESSAGE in unknown");
  assert.equal(rel!.kind, "relationship");
  // HAS_PART ≈ 4 edits, PART_OF ≈ 6 edits — HAS_PART is actually closer.
  // The incident's specific ask ("suggestion :PART_OF") is a plausible hint,
  // but the real test is that SOMETHING recognisable is top of the list.
  assert.ok(rel!.nearest.length > 0, "expected at least one suggestion");
  assert.ok(rel!.nearest[0] === "HAS_PART" || rel!.nearest[0] === "PART_OF");
  assert.match(rel!.hint, /Did you mean/);
});

test("rejects unknown label :Foo with label-kind suggestions", () => {
  const result = validate("MATCH (n:Foo) RETURN n", snapshot);
  assert.equal(result.ok, false);
  const bad = result.unknown.find((u) => u.token === "Foo");
  assert.ok(bad);
  assert.equal(bad!.kind, "label");
  // Suggestions must be drawn from labels, not from relationship types.
  for (const s of bad!.nearest) {
    assert.ok(snapshot.labels.has(s), `suggestion ${s} not a known label`);
  }
});

test("empty cypher passes (no tokens to check)", () => {
  const result = validate("", snapshot);
  assert.equal(result.ok, true);
});

test("multi-label pattern (n:Conversation:AdminConversation) both validated", () => {
  const result = validate(
    "MATCH (c:Conversation:AdminConversation) RETURN c",
    snapshot,
  );
  assert.equal(result.ok, true);
  assert.ok(result.labelTokens.includes("Conversation"));
  assert.ok(result.labelTokens.includes("AdminConversation"));
});

test("named edge with var-length [r:PART_OF*1..5] validates", () => {
  const result = validate(
    "MATCH (m:Message)-[r:PART_OF*1..5]->(c:Conversation) RETURN r",
    snapshot,
  );
  assert.equal(result.ok, true);
  assert.ok(result.edgeTokens.includes("PART_OF"));
});

test("edge-type alternation [:PART_OF|BELONGS_TO] splits and validates both", () => {
  const result = validate(
    "MATCH (a)-[:PART_OF|BELONGS_TO]->(b) RETURN a, b",
    snapshot,
  );
  assert.equal(result.ok, true);
  assert.ok(result.edgeTokens.includes("PART_OF"));
  assert.ok(result.edgeTokens.includes("BELONGS_TO"));
});

test("edge-type alternation with one unknown rejects only the unknown", () => {
  const result = validate(
    "MATCH (a)-[:PART_OF|HAS_MESSAGE]->(b) RETURN a",
    snapshot,
  );
  assert.equal(result.ok, false);
  assert.equal(result.unknown.length, 1);
  assert.equal(result.unknown[0].token, "HAS_MESSAGE");
});

test("string literal containing :LabelLike substring does not false-positive", () => {
  // Without string-stripping, the `:Trashed` inside the quoted literal would
  // be picked up as a label token. The validator must ignore it.
  const result = validate(
    "MATCH (n:Person) WHERE n.note = 'contains :Nonesuch substring' RETURN n",
    snapshot,
  );
  assert.equal(result.ok, true);
  assert.ok(!result.labelTokens.includes("Nonesuch"));
});

test("empty schema snapshot fails-open (returns ok=true)", () => {
  // Defence posture: when the cache hasn't loaded yet, the validator must
  // not reject every cypher — it would wedge the admin session. Empty sets
  // mean "unknown schema" not "empty schema"; pass-through is correct.
  const empty: SchemaSnapshot = {
    labels: new Set(),
    relationshipTypes: new Set(),
  };
  const result = validate(
    "MATCH (c:Conversation)-[:PART_OF]->(m) RETURN c",
    empty,
  );
  assert.equal(result.ok, true);
});
