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

/**
 * brochure-class calibration fixture.
 *
 * Pre-Task-978 the route's `DEFAULT_VECTOR_THRESHOLD` was 0.72, calibrated
 * against the `savage` query (every score < 0.70, only the relevant row
 * passes the BM25 carve-out). `brochure` is a different shape: keepers
 * cluster in [0.8473, 0.8623], noise in [0.7159, 0.8143]. At 0.72 the
 * floor caught only the lone outlier below 0.72; all 11 in-cluster rows
 * rendered. raised the default to 0.82, the midpoint of the
 * 0.033-wide gap between brochure-keepers and brochure-noise.
 *
 * This fixture pins both ends of that calibration so a future drop back
 * toward 0.72 (or a push past 0.85 that would clip the lowest keeper)
 * fails the test instead of silently regressing /graph search quality.
 */

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;
}

// Real Agent laptop 2026-05-12 snapshot, `brochure` against
// project_embedding for account b3b638d9. BM25 = 0 on every row.
const BROCHURE_VECTOR_ROWS = [
  { nodeId: "k1", name: "Brochure Production — Alex Pelosi Buchanan", score: 0.8623 },
  { nodeId: "k2", name: "Brochure Production — Ross Margetts", score: 0.8523 },
  { nodeId: "k3", name: "Brochure Production — Donna Vincent", score: 0.8521 },
  { nodeId: "k4", name: "Brochure Production — Paul Leslie", score: 0.8509 },
  { nodeId: "k5", name: "Brochure Production — John Savage", score: 0.8474 },
  { nodeId: "k6", name: "Brochure Production — Muvin", score: 0.8473 },
  { nodeId: "n1", name: "Real Agent Product Development", score: 0.8143 },
  { nodeId: "n2", name: "Dan McLeod Partnership", score: 0.7498 },
  { nodeId: "n3", name: "Real Agent Lettings — New Vertical", score: 0.7498 },
  { nodeId: "n4", name: "Real Agent Lettings", score: 0.7487 },
  { nodeId: "n5", name: "Fundraising & Finance", score: 0.7323 },
  { nodeId: "n6", name: "Muvin Pilot", score: 0.7240 },
  { nodeId: "n7", name: "Go-to-Market & Events", score: 0.7159 },
];

function makeBrochureSession() {
  return makeStubSession([
    {
      match: (q) => q.includes("SHOW INDEXES"),
      records: [{ name: "project_embedding", labelsOrTypes: ["Project"] }],
    },
    {
      match: (q) => q.includes("db.index.vector.queryNodes"),
      records: BROCHURE_VECTOR_ROWS.map((r) => ({
        nodeId: r.nodeId,
        nodeLabels: ["Project"],
        node: { properties: { name: r.name } },
        score: r.score,
      })),
    },
    // BM25 returns nothing — no `brochure` token literal in any name.
    { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [] },
  ]);
}

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

describe("brochure-class threshold calibration", () => {
  it("at threshold 0.72 (pre-fix) renders 12 rows — the bug shape", async () => {
    const res = await hybrid(makeBrochureSession(), async () => [0.1], {
      query: "brochure",
      accountId: "acc-1",
      limit: 100,
      labels: ["Project"],
      expandHops: 0,
      vectorThreshold: 0.72,
    });
    // 13 rows merged, lowest (0.7159) below 0.72, 12 rendered.
    expect(res.rawMerged).toBe(13);
    expect(res.results).toHaveLength(12);
    expect(res.suppressed).toBe(1);
    expect(res.bm25Bypass).toBe(0);
  });

  it("at threshold 0.82 (post-fix) renders the 6 keepers exactly", async () => {
    const res = await hybrid(makeBrochureSession(), async () => [0.1], {
      query: "brochure",
      accountId: "acc-1",
      limit: 100,
      labels: ["Project"],
      expandHops: 0,
      vectorThreshold: 0.82,
    });
    expect(res.rawMerged).toBe(13);
    expect(res.results).toHaveLength(6);
    expect(res.suppressed).toBe(7);
    expect(res.bm25Bypass).toBe(0);
    expect(res.results.map((r) => r.properties.name as string).sort()).toEqual([
      "Brochure Production — Alex Pelosi Buchanan",
      "Brochure Production — Donna Vincent",
      "Brochure Production — John Savage",
      "Brochure Production — Muvin",
      "Brochure Production — Paul Leslie",
      "Brochure Production — Ross Margetts",
    ]);
  });

  it("at threshold 0.85 (too tight) starts clipping keepers — regression guard", async () => {
    const res = await hybrid(makeBrochureSession(), async () => [0.1], {
      query: "brochure",
      accountId: "acc-1",
      limit: 100,
      labels: ["Project"],
      expandHops: 0,
      vectorThreshold: 0.85,
    });
    // Two keepers (0.8473, 0.8474) fall below 0.85 — proves 0.82 sits in
    // the gap on purpose, not by accident. Don't move the default this
    // high without rechecking.
    expect(res.results).toHaveLength(4);
    expect(res.suppressed).toBe(9);
  });
});
