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

/**
 * vector-cosine threshold filter.
 *
 * Pre-fix: kNN vector index returned every node in scope, regardless of
 * similarity. With limit > population, all rows passed through to the
 * UI. Real Agent regression: chip=Project q=savage returned all 12
 * Project nodes (vector cosine 0.5–0.7 on 11 noise rows; only Brochure
 * Production — John Savage was relevant).
 *
 * This file pins:
 *   1. threshold drops rows below the floor
 *   2. counts (rawMerged / suppressed) reflect the partition
 *   3. threshold=0 matches "no threshold" semantically (show all)
 *   4. undefined threshold preserves legacy behaviour (no filter)
 */

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 filter", () => {
  it("suppresses rows with vector cosine below the threshold (no BM25 hits)", async () => {
    // Mirror the Real Agent savage-case shape: 4 nodes, vector scores
    // [0.9, 0.65, 0.55, 0.50], no BM25 hits. Threshold 0.7 keeps only
    // the top row.
    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: "n1", nodeLabels: ["Project"], node: { properties: { name: "relevant" } }, score: 0.9 },
          { nodeId: "n2", nodeLabels: ["Project"], node: { properties: { name: "noise-a" } }, score: 0.65 },
          { nodeId: "n3", nodeLabels: ["Project"], node: { properties: { name: "noise-b" } }, score: 0.55 },
          { nodeId: "n4", nodeLabels: ["Project"], node: { properties: { name: "noise-c" } }, score: 0.50 },
        ],
      },
      // BM25 returns nothing for this query.
      { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [] },
    ]);
    const res = await hybrid(session, async () => [0.1, 0.2, 0.3], {
      query: "savage",
      accountId: "acc-1",
      limit: 100,
      labels: ["Project"],
      expandHops: 0,
      vectorThreshold: 0.7,
    });
    expect(res.results.map((r) => r.nodeId)).toEqual(["n1"]);
    expect(res.rawMerged).toBe(4);
    expect(res.suppressed).toBe(3);
    expect(res.bm25Bypass).toBe(0);
    expect(res.threshold).toBe(0.7);
  });

  it("undefined threshold preserves legacy behaviour (no filter)", async () => {
    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: "n1", nodeLabels: ["Project"], node: { properties: {} }, score: 0.5 },
          { nodeId: "n2", nodeLabels: ["Project"], node: { properties: {} }, score: 0.3 },
        ],
      },
      { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [] },
    ]);
    const res = await hybrid(session, async () => [0.1], {
      query: "x",
      accountId: "a",
      limit: 10,
      labels: ["Project"],
      expandHops: 0,
      // vectorThreshold omitted
    });
    expect(res.results).toHaveLength(2);
    expect(res.suppressed).toBe(0);
    expect(res.threshold).toBeNull();
  });

  it("threshold=0 keeps every row (operator's show-all override)", async () => {
    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: "n1", nodeLabels: ["Project"], node: { properties: {} }, score: 0.5 },
          { nodeId: "n2", nodeLabels: ["Project"], node: { properties: {} }, score: 0.3 },
        ],
      },
      { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [] },
    ]);
    const res = await hybrid(session, async () => [0.1], {
      query: "x",
      accountId: "a",
      limit: 10,
      labels: ["Project"],
      expandHops: 0,
      vectorThreshold: 0,
    });
    expect(res.results).toHaveLength(2);
    expect(res.suppressed).toBe(0);
    expect(res.threshold).toBe(0);
  });

  it("filter applies BEFORE slice — under-fill regression guard", async () => {
    // 5 rows: 2 above threshold, 3 below. limit=3.
    // Wrong order (slice then filter) would yield 2 rows from the top-3.
    // Correct order (filter then sort then slice) yields both above-threshold
    // rows because they survive the filter and limit ≥ kept-set.
    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: "n1", nodeLabels: ["Project"], node: { properties: {} }, score: 0.95 },
          { nodeId: "n2", nodeLabels: ["Project"], node: { properties: {} }, score: 0.60 },
          { nodeId: "n3", nodeLabels: ["Project"], node: { properties: {} }, score: 0.55 },
          { nodeId: "n4", nodeLabels: ["Project"], node: { properties: {} }, score: 0.50 },
          { nodeId: "n5", nodeLabels: ["Project"], node: { properties: {} }, score: 0.85 },
        ],
      },
      { match: (q) => q.includes("db.index.fulltext.queryNodes"), records: [] },
    ]);
    const res = await hybrid(session, async () => [0.1], {
      query: "x",
      accountId: "a",
      limit: 3,
      labels: ["Project"],
      expandHops: 0,
      vectorThreshold: 0.8,
    });
    expect(res.results.map((r) => r.nodeId).sort()).toEqual(["n1", "n5"]);
    expect(res.rawMerged).toBe(5);
    expect(res.suppressed).toBe(3);
  });
});
