import { describe, it, expect } from "vitest";
import { bm25Only } from "../index.js";

/**
 * Stub Session records every `run()` call — we assert on the Cypher text and
 * params to pin down the clause-composition invariants from the task brief.
 * No real Neo4j is contacted; this is pure unit coverage of the SQL-building
 * decision layer.
 */
function makeStubSession(records: Array<Record<string, unknown>> = []) {
  const calls: Array<{ query: string; params: Record<string, unknown> }> = [];
  const session = {
    run(query: string, params: Record<string, unknown>) {
      calls.push({ query, params });
      return Promise.resolve({
        records: records.map((r) => ({
          get: (k: string) => r[k],
        })),
      });
    },
  } as unknown as import("neo4j-driver").Session;
  return { session, calls };
}

describe("bm25Only Cypher composition", () => {
  it("omits scope clause when allowedScopes is not set", async () => {
    const { session, calls } = makeStubSession();
    await bm25Only(session, { query: "foo", accountId: "acc-1", limit: 10 });
    expect(calls).toHaveLength(1);
    expect(calls[0].query).not.toContain("node.scope");
    expect(calls[0].params).not.toHaveProperty("allowedScopes");
  });

  it("adds scope clause only when allowedScopes is set", async () => {
    const { session, calls } = makeStubSession();
    await bm25Only(session, {
      query: "foo",
      accountId: "acc-1",
      limit: 10,
      allowedScopes: ["public", "account"],
    });
    expect(calls[0].query).toContain("node.scope IS NULL OR node.scope IN $allowedScopes");
    expect(calls[0].params.allowedScopes).toEqual(["public", "account"]);
  });

  it("omits agent clause when agentSlug is not set", async () => {
    const { session, calls } = makeStubSession();
    await bm25Only(session, { query: "foo", accountId: "acc-1", limit: 10 });
    expect(calls[0].query).not.toContain("node.agents");
    expect(calls[0].params).not.toHaveProperty("agentSlug");
  });

  it("adds agent clause when agentSlug is set", async () => {
    const { session, calls } = makeStubSession();
    await bm25Only(session, {
      query: "foo",
      accountId: "acc-1",
      limit: 10,
      agentSlug: "support",
    });
    expect(calls[0].query).toContain("$agentSlug IN node.agents");
    expect(calls[0].params.agentSlug).toBe("support");
  });

  it("omits keyword clause when keywords is not set", async () => {
    const { session, calls } = makeStubSession();
    await bm25Only(session, { query: "foo", accountId: "acc-1", limit: 10 });
    expect(calls[0].query).not.toContain("node.keywords IS NULL");
    expect(calls[0].params).not.toHaveProperty("keywords");
  });

  it("adds keyword clause with ANY (default) when keywords is set", async () => {
    const { session, calls } = makeStubSession();
    await bm25Only(session, {
      query: "foo",
      accountId: "acc-1",
      limit: 10,
      keywords: ["Alpha", "Beta"],
    });
    expect(calls[0].query).toContain("ANY(kw IN $keywords WHERE kw IN node.keywords)");
    expect(calls[0].params.keywords).toEqual(["alpha", "beta"]);
  });

  it("uses ALL when keywordMatch='all'", async () => {
    const { session, calls } = makeStubSession();
    await bm25Only(session, {
      query: "foo",
      accountId: "acc-1",
      limit: 10,
      keywords: ["a"],
      keywordMatch: "all",
    });
    expect(calls[0].query).toContain("ALL(kw IN $keywords WHERE kw IN node.keywords)");
  });

  it("always applies notTrashed filter", async () => {
    const { session, calls } = makeStubSession();
    await bm25Only(session, { query: "foo", accountId: "acc-1", limit: 10 });
    expect(calls[0].query).toContain("NOT `node`:Trashed");
    expect(calls[0].query).toContain("`node`.deletedAt IS NULL");
  });

  it("escapes Lucene special chars in query param", async () => {
    const { session, calls } = makeStubSession();
    await bm25Only(session, { query: "foo:bar (x)", accountId: "acc-1", limit: 10 });
    expect(calls[0].params.query).toBe("foo\\:bar \\(x\\)");
  });

  it("returns [] when the fulltext index does not exist", async () => {
    const session = {
      run() {
        return Promise.reject(new Error("There is no such fulltext index"));
      },
    } as unknown as import("neo4j-driver").Session;
    const hits = await bm25Only(session, { query: "foo", accountId: "acc-1", limit: 10 });
    expect(hits).toEqual([]);
  });

  it("rethrows non-index errors", async () => {
    const session = {
      run() {
        return Promise.reject(new Error("ServiceUnavailable: database offline"));
      },
    } as unknown as import("neo4j-driver").Session;
    await expect(
      bm25Only(session, { query: "foo", accountId: "acc-1", limit: 10 }),
    ).rejects.toThrow("ServiceUnavailable");
  });
});
