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

/**
 * BM25 carve-out under the vector threshold.
 *
 * The carve-out preserves rows that entered via the BM25 path even when
 * their vector cosine is below the threshold. Justification: a literal
 * token match (BM25 hit) is the operator's strongest "yes I meant this"
 * signal — a mediocre embedding score should not silently suppress it.
 *
 * The implementation uses a `bm25Hit` boolean (not `bm25Score > 0`)
 * because min-max normalisation pins the lowest BM25 row's score to 0.
 * If the carve-out tested `bm25Score > 0` it would falsely drop the
 * weakest BM25 hit in any set with >1 BM25 results — even though that
 * row IS a literal match. This file pins both:
 *   1. a row with weak vector + strong BM25 survives (basic carve-out)
 *   2. the row with the WEAKEST BM25 in a multi-BM25 set survives even
 *      after min-max normalisation collapses its bm25Score to 0
 */

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[]) {
  return {
    run(query: string) {
      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;
}

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

describe("vectorThreshold — bm25 carve-out", () => {
  it("preserves a weak-vector row when BM25 hit raw score is strong (mirrors savage case)", async () => {
    // Reflects the Real Agent regression: relevant Brochure has BM25 ≈ 0.74
    // (single token match) and vector ≈ 0.65 (mediocre semantic match).
    // Threshold 0.7 would drop it on vector alone; carve-out keeps it.
    const session = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_project", labelsOrTypes: ["Project"] }],
      },
      {
        match: (q) => q.includes("db.index.vector.queryNodes"),
        records: [
          { nodeId: "brochure", nodeLabels: ["Project"], node: { properties: { name: "Brochure Production — John Savage" } }, score: 0.65 },
          { nodeId: "noise-1", nodeLabels: ["Project"], node: { properties: {} }, score: 0.55 },
        ],
      },
      {
        match: (q) => q.includes("db.index.fulltext.queryNodes"),
        records: [
          { nodeId: "brochure", nodeLabels: ["Project"], node: { properties: { name: "Brochure Production — John Savage" } }, score: 10 },
        ],
      },
    ]);
    const res = await hybrid(session, async () => [0.1], {
      query: "savage",
      accountId: "a",
      limit: 100,
      labels: ["Project"],
      expandHops: 0,
      vectorThreshold: 0.7,
    });
    expect(res.results.map((r) => r.nodeId)).toEqual(["brochure"]);
    expect(res.bm25Bypass).toBe(1);
    expect(res.suppressed).toBe(1); // noise-1 dropped
  });

  it("preserves the WEAKEST-BM25 row whose normalised score collapses to 0", async () => {
    // Two BM25 hits with raw scores [10, 5] → normalised [1.0, 0.0].
    // Both have weak vector cosine (below threshold). Without the
    // bm25Hit flag, the row whose normalised score is exactly 0.0 would
    // get falsely suppressed by a `bm25Score > 0` check — even though
    // it IS a literal-token match. The flag is the orthogonal signal.
    const session = makeStubSession([
      {
        match: (q) => q.includes("SHOW INDEXES"),
        records: [{ name: "vec_project", labelsOrTypes: ["Project"] }],
      },
      {
        match: (q) => q.includes("db.index.vector.queryNodes"),
        records: [
          { nodeId: "strong-bm25", nodeLabels: ["Project"], node: { properties: {} }, score: 0.4 },
          { nodeId: "weak-bm25", nodeLabels: ["Project"], node: { properties: {} }, score: 0.3 },
        ],
      },
      {
        match: (q) => q.includes("db.index.fulltext.queryNodes"),
        records: [
          { nodeId: "strong-bm25", nodeLabels: ["Project"], node: { properties: {} }, score: 10 },
          { nodeId: "weak-bm25", nodeLabels: ["Project"], node: { properties: {} }, score: 5 },
        ],
      },
    ]);
    const res = await hybrid(session, async () => [0.1], {
      query: "anything",
      accountId: "a",
      limit: 100,
      labels: ["Project"],
      expandHops: 0,
      vectorThreshold: 0.7,
    });
    const ids = res.results.map((r) => r.nodeId).sort();
    expect(ids).toEqual(["strong-bm25", "weak-bm25"]);
    // Both kept by carve-out (both vector below threshold; both BM25 hits).
    expect(res.bm25Bypass).toBe(2);
    expect(res.suppressed).toBe(0);
    // Weakest BM25 row's normalised score IS 0.0 — pin the artefact so a
    // future "let's just check bm25Score > 0" refactor breaks the test.
    const weak = res.results.find((r) => r.nodeId === "weak-bm25");
    expect(weak?.bm25Score).toBe(0);
  });
});
