import { describe, it, expect, beforeEach } from "vitest";
import { hybrid, clearIndexCache } from "../index.js";

/**
 * graph expand is one Cypher round-trip per `hybrid()` call,
 * not one per result. Pre-fix the lib looped at index.ts:421-462, issuing
 * `MATCH (n)-[r]-(related)` once per merged node — at slider=2000 that was
 * 2000 round-trips through the driver per search. Post-fix, a single
 * `UNWIND $nodeIds AS nid MATCH (n)-[r]-(related) WHERE elementId(n) = nid …`
 * with `WITH nid, collect({...})[0..20]` returns all expansions in one query.
 *
 * Invariants pinned here:
 *   1. exactly one expand round-trip per hybrid() call (regardless of N)
 *   2. per-result related cap of 20 preserved (slice notation in the lib)
 *   3. each `related` entry carries the neighbour's `elementId` so the
 *      canvas can render edges (post-Task-747 lib shape change)
 *   4. `expandHops: 0` short-circuits the expand round-trip entirely
 */

interface ScriptedRun {
  match: (query: string) => boolean;
  records: Array<Record<string, unknown>>;
}

function record(fields: Record<string, unknown>) {
  return { get: (k: string) => fields[k] };
}

function makeStubSession(scripted: ScriptedRun[]) {
  const calls: Array<{ query: string; params: Record<string, unknown> }> = [];
  const session = {
    run(query: string, params: Record<string, unknown>) {
      calls.push({ query, params });
      const hit = scripted.find((s) => s.match(query));
      if (!hit) return Promise.resolve({ records: [] });
      return Promise.resolve({ records: hit.records.map(record) });
    },
  } as unknown as import("neo4j-driver").Session;
  return { session, calls };
}

beforeEach(() => {
  clearIndexCache();
});

describe("hybrid — batched expand", () => {
  it("issues exactly one expand round-trip for N merged results", async () => {
    const merged = [
      { nodeId: "n1", nodeLabels: ["Person"], node: { properties: { name: "A" } }, score: 0.9 },
      { nodeId: "n2", nodeLabels: ["Person"], node: { properties: { name: "B" } }, score: 0.8 },
      { nodeId: "n3", nodeLabels: ["Person"], node: { properties: { name: "C" } }, score: 0.7 },
    ];
    const { session, calls } = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_person", labelsOrTypes: ["Person"] }],
      },
      {
        match: (q) => q.includes("db.index.vector.queryNodes"),
        records: merged,
      },
      {
        match: (q) => q.includes("UNWIND $nodeIds"),
        records: [
          {
            nid: "n1",
            items: [{ relType: "KNOWS", direction: "outgoing", relatedNodeId: "r1", relatedLabels: ["Person"], related: { properties: { name: "X" } } }],
          },
          {
            nid: "n2",
            items: [{ relType: "KNOWS", direction: "incoming", relatedNodeId: "r2", relatedLabels: ["Person"], related: { properties: { name: "Y" } } }],
          },
        ],
      },
    ]);
    const embed = async () => [0.1, 0.2];
    await hybrid(session, embed, {
      query: "test",
      accountId: "acc-1",
      limit: 10,
      labels: ["Person"],
    });
    const expandCalls = calls.filter((c) => c.params && "nodeIds" in (c.params as Record<string, unknown>));
    expect(expandCalls).toHaveLength(1);
    expect((expandCalls[0].params as { nodeIds: string[] }).nodeIds).toEqual(["n1", "n2", "n3"]);
  });

  it("preserves per-result related cap of 20 via slice notation", async () => {
    const { session, calls } = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_person", labelsOrTypes: ["Person"] }],
      },
      {
        match: (q) => q.includes("db.index.vector.queryNodes"),
        records: [
          { nodeId: "n1", nodeLabels: ["Person"], node: { properties: {} }, score: 0.9 },
        ],
      },
    ]);
    const embed = async () => [0.1];
    await hybrid(session, embed, {
      query: "test",
      accountId: "acc-1",
      limit: 10,
      labels: ["Person"],
    });
    const expandCall = calls.find((c) => c.query.includes("UNWIND $nodeIds"));
    expect(expandCall).toBeDefined();
    expect(expandCall!.query).toContain("[0..20]");
  });

  it("returns related entries carrying neighbour elementId for canvas edge rendering", async () => {
    const { session } = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_person", labelsOrTypes: ["Person"] }],
      },
      {
        match: (q) => q.includes("db.index.vector.queryNodes"),
        records: [
          { nodeId: "n1", nodeLabels: ["Person"], node: { properties: { name: "A" } }, score: 0.9 },
        ],
      },
      {
        match: (q) => q.includes("UNWIND $nodeIds"),
        records: [
          {
            nid: "n1",
            items: [
              {
                relType: "KNOWS",
                direction: "outgoing",
                relatedNodeId: "r1",
                relatedLabels: ["Person"],
                related: { properties: { name: "B" } },
              },
            ],
          },
        ],
      },
    ]);
    const embed = async () => [0.1];
    const res = await hybrid(session, embed, {
      query: "test",
      accountId: "acc-1",
      limit: 10,
      labels: ["Person"],
    });
    expect(res.results[0]?.related[0]?.nodeId).toBe("r1");
  });

  it("skips the expand round-trip entirely when expandHops is 0", async () => {
    const { session, calls } = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_person", labelsOrTypes: ["Person"] }],
      },
      {
        match: (q) => q.includes("db.index.vector.queryNodes"),
        records: [
          { nodeId: "n1", nodeLabels: ["Person"], node: { properties: {} }, score: 0.9 },
        ],
      },
    ]);
    const embed = async () => [0.1];
    await hybrid(session, embed, {
      query: "test",
      accountId: "acc-1",
      limit: 10,
      labels: ["Person"],
      expandHops: 0,
    });
    const expandCalls = calls.filter((c) => c.query.includes("UNWIND $nodeIds"));
    expect(expandCalls).toHaveLength(0);
  });

  it("preserves trashed/scope/agent gates on the related neighbour", async () => {
    const { session, calls } = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_person", labelsOrTypes: ["Person"] }],
      },
      {
        match: (q) => q.includes("db.index.vector.queryNodes"),
        records: [
          { nodeId: "n1", nodeLabels: ["Person"], node: { properties: {} }, score: 0.9 },
        ],
      },
    ]);
    const embed = async () => [0.1];
    await hybrid(session, embed, {
      query: "test",
      accountId: "acc-1",
      limit: 10,
      labels: ["Person"],
      allowedScopes: ["public"],
      agentSlug: "support",
    });
    const expandCall = calls.find((c) => c.query.includes("UNWIND $nodeIds"));
    expect(expandCall).toBeDefined();
    expect(expandCall!.query).toContain("related"); // notTrashed predicate target
    expect(expandCall!.query).toContain("$allowedScopes");
    expect(expandCall!.query).toContain("$agentSlug");
  });
});
