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

/**
 * Hybrid tests cover the two decisions that make this lib non-trivial:
 *   1. overlapping vector+BM25 nodeIds combine as 0.7*vec + 0.3*bm25_norm
 *   2. degradeOnEmbedFailure=true with a failing embed returns bm25-only
 *      and annotates mode="bm25"
 * Deeper integration (index discovery, expand, keyword subscriptions) is
 * covered by the downstream MCP tool tests against a real Neo4j.
 */

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

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

function makeStubSession(scripted: StubRun[]) {
  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 — score combination", () => {
  it("combines overlapping nodeIds as 0.7*vec + 0.3*bm25_norm (expand skipped)", async () => {
    const session = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_knowledge", labelsOrTypes: ["KnowledgeDocument"] }],
      },
      {
        match: (q) => q.includes("db.index.vector.queryNodes"),
        records: [
          {
            nodeId: "n1",
            nodeLabels: ["KnowledgeDocument"],
            node: { properties: { title: "A" } },
            score: 0.9,
          },
          {
            nodeId: "n2",
            nodeLabels: ["KnowledgeDocument"],
            node: { properties: { title: "B" } },
            score: 0.5,
          },
        ],
      },
      {
        match: (q) => q.includes("db.index.fulltext.queryNodes"),
        records: [
          {
            nodeId: "n1",
            nodeLabels: ["KnowledgeDocument"],
            node: { properties: { title: "A" } },
            score: 10,
          },
          {
            nodeId: "n3",
            nodeLabels: ["KnowledgeDocument"],
            node: { properties: { title: "C" } },
            score: 5,
          },
        ],
      },
    ]).session;

    const embed = async () => [0.1, 0.2, 0.3];
    const res = await hybrid(session, embed, {
      query: "test",
      accountId: "acc-1",
      limit: 10,
      expandHops: 0,
    });

    expect(res.mode).toBe("hybrid");
    // bm25 normalised: [10,5] -> [1,0]; n1: 0.7*0.9 + 0.3*1 = 0.93
    //                  n2 (vec only): 0.7*0.5 + 0.3*0 = 0.35
    //                  n3 (bm25 only): 0.7*0 + 0.3*0 = 0
    const n1 = res.results.find((r) => r.nodeId === "n1");
    const n2 = res.results.find((r) => r.nodeId === "n2");
    const n3 = res.results.find((r) => r.nodeId === "n3");
    expect(n1?.score).toBeCloseTo(0.93);
    expect(n2?.score).toBeCloseTo(0.35);
    expect(n3?.score).toBeCloseTo(0);
    // Ranking: n1 > n2 > n3
    expect(res.results.map((r) => r.nodeId)).toEqual(["n1", "n2", "n3"]);
  });

  // operator-facing per-row component scores. Without these the
  // hybrid combine is opaque: a row scoring 0.35 cannot be distinguished
  // between "vector hit, bm25 absent" and "weak partial match in both".
  it("populates vectorScore + bm25Score on every result", async () => {
    const session = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_knowledge", labelsOrTypes: ["KnowledgeDocument"] }],
      },
      {
        match: (q) => q.includes("db.index.vector.queryNodes"),
        records: [
          { nodeId: "n1", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "A" } }, score: 0.9 },
          { nodeId: "n2", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "B" } }, score: 0.5 },
        ],
      },
      {
        match: (q) => q.includes("db.index.fulltext.queryNodes"),
        records: [
          { nodeId: "n1", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "A" } }, score: 10 },
          { nodeId: "n3", nodeLabels: ["KnowledgeDocument"], node: { properties: { title: "C" } }, score: 5 },
        ],
      },
    ]).session;
    const res = await hybrid(session, async () => [0.1, 0.2, 0.3], {
      query: "test", accountId: "acc-1", limit: 10, expandHops: 0,
    });
    const n1 = res.results.find((r) => r.nodeId === "n1");
    const n2 = res.results.find((r) => r.nodeId === "n2");
    const n3 = res.results.find((r) => r.nodeId === "n3");
    // n1: hit by both. raw vector cosine 0.9; bm25 normalised top = 1.0.
    expect(n1?.vectorScore).toBeCloseTo(0.9);
    expect(n1?.bm25Score).toBeCloseTo(1.0);
    // n2: vector-only. bm25 absent → bm25Score = 0.
    expect(n2?.vectorScore).toBeCloseTo(0.5);
    expect(n2?.bm25Score).toBe(0);
    // n3: bm25-only. vectorScore = 0; bm25 normalised bottom = 0.
    expect(n3?.vectorScore).toBe(0);
    expect(n3?.bm25Score).toBe(0);
  });
});

describe("hybrid — embed degrade", () => {
  const failingEmbed = async () => {
    throw new Error("Ollama unreachable");
  };

  it("throws when degradeOnEmbedFailure is false (default)", async () => {
    const { session } = makeStubSession([
      { match: () => true, records: [] },
    ]);
    await expect(
      hybrid(session, failingEmbed, { query: "x", accountId: "a", limit: 5 }),
    ).rejects.toThrow("Ollama unreachable");
  });

  it("returns bm25-only result with mode='bm25' when degradeOnEmbedFailure is true", async () => {
    const { session } = makeStubSession([
      {
        match: (q) => q.includes("db.index.fulltext.queryNodes"),
        records: [
          {
            nodeId: "n1",
            nodeLabels: ["KnowledgeDocument"],
            node: { properties: { title: "hit" } },
            score: 3,
          },
        ],
      },
    ]);

    const res = await hybrid(session, failingEmbed, {
      query: "x",
      accountId: "a",
      limit: 5,
      degradeOnEmbedFailure: true,
    });

    expect(res.mode).toBe("bm25");
    expect(res.embedError).toContain("Ollama unreachable");
    expect(res.results).toHaveLength(1);
    expect(res.results[0]?.nodeId).toBe("n1");
    // score is raw BM25 score (no hybrid combine in degrade path)
    expect(res.results[0]?.score).toBe(3);
    // related[] is [] in degrade path (no expand)
    expect(res.results[0]?.related).toEqual([]);
    // degraded path also populates components: vectorScore=0
    // (no embed) and bm25Score is the normalised value (single hit → 1.0).
    expect(res.results[0]?.vectorScore).toBe(0);
    expect(res.results[0]?.bm25Score).toBe(1.0);
  });

  // multi-hit degraded path: bm25Score is min-max normalised
  // across the local hit set, so the operator can still see "this row is
  // the strongest bm25 match (1.0) vs this row is the weakest (0.0)".
  it("normalises bm25Score across hits in degrade path", async () => {
    const { session } = makeStubSession([
      {
        match: (q) => q.includes("db.index.fulltext.queryNodes"),
        records: [
          { nodeId: "top", nodeLabels: ["KnowledgeDocument"], node: { properties: {} }, score: 10 },
          { nodeId: "mid", nodeLabels: ["KnowledgeDocument"], node: { properties: {} }, score: 5 },
          { nodeId: "low", nodeLabels: ["KnowledgeDocument"], node: { properties: {} }, score: 0 },
        ],
      },
    ]);
    const res = await hybrid(session, failingEmbed, {
      query: "x", accountId: "a", limit: 5, degradeOnEmbedFailure: true,
    });
    expect(res.mode).toBe("bm25");
    const byId = new Map(res.results.map((r) => [r.nodeId, r]));
    expect(byId.get("top")?.bm25Score).toBeCloseTo(1.0);
    expect(byId.get("mid")?.bm25Score).toBeCloseTo(0.5);
    expect(byId.get("low")?.bm25Score).toBeCloseTo(0.0);
    expect(byId.get("top")?.vectorScore).toBe(0);
    expect(byId.get("mid")?.vectorScore).toBe(0);
    expect(byId.get("low")?.vectorScore).toBe(0);
  });

  it("returns empty results with mode='bm25' when embed fails and index is missing", async () => {
    const session = {
      run: (q: string) => {
        if (q.includes("db.index.fulltext.queryNodes")) {
          return Promise.reject(new Error("There is no such fulltext index"));
        }
        return Promise.resolve({ records: [] });
      },
    } as unknown as import("neo4j-driver").Session;

    const res = await hybrid(session, failingEmbed, {
      query: "x",
      accountId: "a",
      limit: 5,
      degradeOnEmbedFailure: true,
    });
    expect(res.mode).toBe("bm25");
    expect(res.results).toEqual([]);
  });
});

describe("hybrid — label filter without vector index (Task 478)", () => {
  // Pre-Task-478 the lib returned an empty result the moment the requested
  // labels had no vector index, hiding BM25 hits for labels like
  // :Organization that are covered by the universal fulltext index but lack
  // a per-label vector index. Post-fix the vector half is skipped while
  // BM25 still runs with the label filter.
  it("returns BM25 hits when label has no vector index but BM25 matches", async () => {
    const { session } = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_knowledge", labelsOrTypes: ["KnowledgeDocument"] }],
      },
      {
        match: (q) => q.includes("db.index.fulltext.queryNodes"),
        records: [
          {
            nodeId: "org-biosymm",
            nodeLabels: ["Organization"],
            node: { properties: { name: "BioSymm Technologies Ltd" } },
            score: 7,
          },
        ],
      },
    ]);
    const embed = async () => [0.1];
    const res = await hybrid(session, embed, {
      query: "BioSymm",
      accountId: "a",
      limit: 5,
      labels: ["Organization"],
      expandHops: 0,
    });
    expect(res.results).toHaveLength(1);
    expect(res.results[0]?.nodeId).toBe("org-biosymm");
    expect(res.results[0]?.labels).toContain("Organization");
    // BM25-only contributor: vectorScore = 0, bm25Score normalised to 1.0
    // (single hit set), combined = 0.3 * 1.0 = 0.3 under default weights.
    expect(res.results[0]?.vectorScore).toBe(0);
    expect(res.results[0]?.bm25Score).toBeCloseTo(1.0);
    expect(res.mode).toBe("hybrid");
  });

  it("returns empty honestly when label has no vector index and BM25 also misses", async () => {
    const { session } = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_knowledge", labelsOrTypes: ["KnowledgeDocument"] }],
      },
      // No fulltext stub → BM25 returns [].
    ]);
    const embed = async () => [0.1];
    const res = await hybrid(session, embed, {
      query: "x",
      accountId: "a",
      limit: 5,
      labels: ["UnknownLabel"],
    });
    expect(res.results).toEqual([]);
    expect(res.mode).toBe("hybrid");
  });

  it("queries only the vector indexes that exist when labels mix indexed and non-indexed", async () => {
    const vectorCalls: string[] = [];
    const { session } = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [
          { name: "person_embedding", labelsOrTypes: ["Person"] },
          { name: "vec_knowledge", labelsOrTypes: ["KnowledgeDocument"] },
        ],
      },
      {
        match: (q) => q.includes("db.index.vector.queryNodes"),
        records: [
          { nodeId: "p1", nodeLabels: ["Person"], node: { properties: { givenName: "Ricky" } }, score: 0.8 },
        ],
      },
      {
        match: (q) => q.includes("db.index.fulltext.queryNodes"),
        records: [
          { nodeId: "org-1", nodeLabels: ["Organization"], node: { properties: { name: "BioSymm" } }, score: 5 },
          { nodeId: "p1", nodeLabels: ["Person"], node: { properties: { givenName: "Ricky" } }, score: 4 },
        ],
      },
    ]);
    // Patch session.run to capture vector indexName params so we can assert
    // Organization (no index) is silently skipped while Person is queried.
    const origRun = session.run.bind(session);
    session.run = ((q: string, params: Record<string, unknown>) => {
      if (q.includes("db.index.vector.queryNodes")) {
        vectorCalls.push(params.indexName as string);
      }
      return origRun(q, params);
    }) as typeof session.run;

    const res = await hybrid(session, async () => [0.1], {
      query: "BioSymm",
      accountId: "a",
      limit: 10,
      labels: ["Person", "Organization"],
      expandHops: 0,
    });

    // Only the Person vector index ran. Organization vector index does not
    // exist; the loop body skipped it instead of bailing.
    expect(vectorCalls).toEqual(["person_embedding"]);
    // BM25 returned both rows under the label gate (the lib forwards
    // `labels` to bm25Only verbatim — the post-bail invariant we assert).
    const ids = res.results.map((r) => r.nodeId).sort();
    expect(ids).toEqual(["org-1", "p1"]);
  });
});
