/**
 * detect_changes + blast radius tests (issue #1553).
 *
 * Blast-radius rubric fixtures: a fixture graph where the same change
 * yields direct/near/transitive classifications; fan-in escalation
 * boundary tested at the exact threshold.
 *
 * detect_changes output for a fixture diff is byte-stable (same diff +
 * graph → same output, regardless of Set iteration order).
 */
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";

import {
  GraphStore,
  nodeIdFor,
  type EdgeIR,
  type StoreFileIR,
  type SymbolIR,
} from "./graph-store.js";
import {
  classifyRisk,
  computeBlastRadius,
  type AffectedSymbol,
  findDirectlyAffectedSymbols,
  byteSpanToLines,
  rangesOverlap,
  FAN_IN_ESCALATION_THRESHOLD,
} from "./detect-changes.js";
import type { DiffHunk } from "./git-invoker.js";
import type { FileIR } from "@remnic/core";

// ──────────────────────────────────────────────────────────────────────────
// Fixture helpers
// ──────────────────────────────────────────────────────────────────────────

function sym(
  qualifiedName: string,
  name: string,
  startByte: number,
  endByte: number,
  kind: SymbolIR["kind"] = "function",
): SymbolIR {
  return { qualifiedName, name, span: { startByte, endByte }, kind };
}

function edge(
  srcQualifiedName: string,
  dstQualifiedName: string,
  type = "CALLS",
  confidence = 0.9,
  provenance: EdgeIR["provenance"] = "heuristic",
): EdgeIR {
  return { srcQualifiedName, dstQualifiedName, type, confidence, provenance };
}

async function tempStore(): Promise<{ store: GraphStore; dir: string }> {
  const dir = await mkdtemp(path.join(tmpdir(), "detect-changes-test-"));
  const store = await GraphStore.open({
    dbPath: path.join(dir, "graph.sqlite"),
    repoRoot: dir,
  });
  return { store, dir };
}

/**
 * Unwrap computeBlastRadius for characterization tests — the store is
 * healthy here so the result is always ok. Regression tests for the
 * store-failure path call computeBlastRadius directly and assert the
 * tagged shape (rule 22).
 */
function blast(
  store: GraphStore,
  ids: ReadonlySet<string>,
  depth: number,
): readonly AffectedSymbol[] {
  const r = computeBlastRadius(store, ids, depth);
  if (!r.ok) throw new Error("expected blast radius ok in characterization test");
  return r.affected;
}

async function dispose(store: GraphStore, dir: string): Promise<void> {
  await store.close();
  await rm(dir, { recursive: true, force: true });
}

// ──────────────────────────────────────────────────────────────────────────
// Line-range overlap tests (rule 35: half-open intervals)
// ──────────────────────────────────────────────────────────────────────────

test("rangesOverlap: half-open boundaries", () => {
  // [1,5) vs [5,10) → NO overlap (boundary touch)
  assert.equal(
    rangesOverlap({ startLine: 1, endLine: 5 }, { startLine: 5, endLine: 10 }),
    false,
  );
  // [1,5) vs [4,10) → overlap
  assert.equal(
    rangesOverlap({ startLine: 1, endLine: 5 }, { startLine: 4, endLine: 10 }),
    true,
  );
  // [1,5) vs [1,5) → overlap (same range)
  assert.equal(
    rangesOverlap({ startLine: 1, endLine: 5 }, { startLine: 1, endLine: 5 }),
    true,
  );
  // [1,5) vs [0,1) → NO overlap (hunk ends exactly at symbol start)
  assert.equal(
    rangesOverlap({ startLine: 1, endLine: 5 }, { startLine: 0, endLine: 1 }),
    false,
  );
  // Single-line: [3,4) vs [3,4) → overlap
  assert.equal(
    rangesOverlap({ startLine: 3, endLine: 4 }, { startLine: 3, endLine: 4 }),
    true,
  );
});

test("byteSpanToLines: basic conversion", () => {
  const content = new TextEncoder().encode("line1\nline2\nline3");
  // Bytes 0-5 = "line1" → lines {1, 2} (exclusive end)
  const r1 = byteSpanToLines(content, 0, 5);
  assert.equal(r1.startLine, 1);
  assert.equal(r1.endLine, 2);
  // Bytes 6-11 = "line2" → lines {2, 3}
  const r2 = byteSpanToLines(content, 6, 11);
  assert.equal(r2.startLine, 2);
  assert.equal(r2.endLine, 3);
});

// ──────────────────────────────────────────────────────────────────────────
// Risk classification rubric
// ──────────────────────────────────────────────────────────────────────────

test("classifyRisk: depth → risk mapping", () => {
  assert.equal(classifyRisk(0, 0), "direct");
  assert.equal(classifyRisk(1, 0), "near");
  assert.equal(classifyRisk(2, 0), "transitive");
  assert.equal(classifyRisk(3, 0), "transitive");
});

test("classifyRisk: fan-in escalation at exact threshold", () => {
  // Threshold is 5. At threshold-1 (4), no escalation.
  assert.equal(classifyRisk(1, FAN_IN_ESCALATION_THRESHOLD - 1), "near");
  // At threshold (5), near → direct.
  assert.equal(classifyRisk(1, FAN_IN_ESCALATION_THRESHOLD), "direct");
  // At threshold (5), transitive → near.
  assert.equal(classifyRisk(2, FAN_IN_ESCALATION_THRESHOLD), "near");
  // Direct stays direct (capped).
  assert.equal(classifyRisk(0, FAN_IN_ESCALATION_THRESHOLD), "direct");
});

// ──────────────────────────────────────────────────────────────────────────
// Blast-radius fixtures — same change yields direct/near/transitive
// ──────────────────────────────────────────────────────────────────────────

/**
 * Fixture: a → b → c → d (chain). Changing `a` should classify:
 *   a → direct (depth 0)
 *   (nothing calls a, so no near/transitive from a)
 *
 * Changing `d` should classify via reverse BFS:
 *   d → direct (depth 0)
 *   c → near (depth 1, c calls d)
 *   b → transitive (depth 2, b calls c)
 *   a → transitive (depth 3, a calls b)
 */
const CHAIN_FILE: StoreFileIR = {
  path: "src/chain.ts",
  language: "typescript",
  contentHash: "h-chain",
  symbols: [
    sym("chain.a", "a", 0, 10),
    sym("chain.b", "b", 10, 20),
    sym("chain.c", "c", 20, 30),
    sym("chain.d", "d", 30, 40),
  ],
  edges: [
    edge("chain.a", "chain.b"), // a calls b
    edge("chain.b", "chain.c"), // b calls c
    edge("chain.c", "chain.d"), // c calls d
  ],
};

test("computeBlastRadius: chain fixture — d changed yields direct/near/transitive", async () => {
  const { store, dir } = await tempStore();
  try {
    const result = await store.upsertFileBatch([CHAIN_FILE]);
    assert.equal(result.ok, true);

    // Simulate: `d` is directly affected. Reverse BFS finds who depends on d.
    const affected = blast(store, new Set(["chain.d"]), 3);
    assert.equal(affected.length, 4);

    // d is direct (depth 0).
    const d = affected.find((a) => a.qualifiedName === "chain.d");
    assert.ok(d);
    assert.equal(d!.risk, "direct");
    assert.equal(d!.depth, 0);

    // c is near (depth 1 — c calls d).
    const c = affected.find((a) => a.qualifiedName === "chain.c");
    assert.ok(c);
    assert.equal(c!.risk, "near");
    assert.equal(c!.depth, 1);

    // b is transitive (depth 2).
    const b = affected.find((a) => a.qualifiedName === "chain.b");
    assert.ok(b);
    assert.equal(b!.risk, "transitive");
    assert.equal(b!.depth, 2);

    // a is transitive (depth 3).
    const a = affected.find((a) => a.qualifiedName === "chain.a");
    assert.ok(a);
    assert.equal(a!.risk, "transitive");
    assert.equal(a!.depth, 3);
  } finally {
    await dispose(store, dir);
  }
});

test("computeBlastRadius: byte-stable — same input, same output", async () => {
  const { store, dir } = await tempStore();
  try {
    await store.upsertFileBatch([CHAIN_FILE]);

    const affected1 = blast(store, new Set(["chain.d"]), 3);
    const affected2 = blast(store, new Set(["chain.d"]), 3);

    // Deep equal — same order, same classifications.
  assert.deepEqual(
    affected1.map((a) => ({ q: a.qualifiedName, r: a.risk, d: a.depth })),
    affected2.map((a) => ({ q: a.qualifiedName, r: a.risk, d: a.depth })),
  );
  } finally {
    await dispose(store, dir);
  }
});

test("computeBlastRadius: empty affected set → empty result", async () => {
  const { store, dir } = await tempStore();
  try {
    const affected = blast(store, new Set(), 3);
    assert.equal(affected.length, 0);
  } finally {
    await dispose(store, dir);
  }
});

/**
 * Fixture for fan-in escalation:
 *   caller1 → mid → target
 *   caller2 → mid
 *   caller3 → mid
 *   caller4 → mid
 *   caller5 → mid
 *
 * When `target` is directly affected:
 *   target → direct (depth 0)
 *   mid → near (depth 1, mid calls target). mid has fanIn=5 ≥ threshold.
 *        Fan-in escalates near → direct.
 *   caller1-5 → transitive (depth 2).
 */
const FAN_IN_FILE: StoreFileIR = {
  path: "src/fanin.ts",
  language: "typescript",
  contentHash: "h-fanin",
  symbols: [
    sym("fanin.target", "target", 0, 10),
    sym("fanin.mid", "mid", 10, 20),
    sym("fanin.caller1", "caller1", 20, 30),
    sym("fanin.caller2", "caller2", 30, 40),
    sym("fanin.caller3", "caller3", 40, 50),
    sym("fanin.caller4", "caller4", 50, 60),
    sym("fanin.caller5", "caller5", 60, 70),
  ],
  edges: [
    edge("fanin.mid", "fanin.target"),
    edge("fanin.caller1", "fanin.mid"),
    edge("fanin.caller2", "fanin.mid"),
    edge("fanin.caller3", "fanin.mid"),
    edge("fanin.caller4", "fanin.mid"),
    edge("fanin.caller5", "fanin.mid"),
  ],
};

test("computeBlastRadius: fan-in escalation — mid escalates near→direct", async () => {
  const { store, dir } = await tempStore();
  try {
    await store.upsertFileBatch([FAN_IN_FILE]);

    // Change target. mid is 1 hop away (near) but has fanIn=5 ≥ 5.
    const affected = blast(store, new Set(["fanin.target"]), 3);
    const mid = affected.find((a) => a.qualifiedName === "fanin.mid");
    assert.ok(mid, "mid should be in blast radius");
    assert.equal(mid!.depth, 1);
    // Fan-in escalates near → direct.
    assert.equal(mid!.risk, "direct");
    assert.ok(mid!.fanIn >= FAN_IN_ESCALATION_THRESHOLD);
  } finally {
    await dispose(store, dir);
  }
});

// ──────────────────────────────────────────────────────────────────────────
// findDirectlyAffectedSymbols — hunk → span overlap
// ──────────────────────────────────────────────────────────────────────────

test("findDirectlyAffectedSymbols: hunk overlapping a symbol span", () => {
  const content = new TextEncoder().encode("function foo() {\n  return 1;\n}\n");
  // foo spans bytes 0-23 (lines 1-3).
  const ir: FileIR = {
    path: "src/a.ts",
    language: "typescript",
    contentHash: "h",
    symbols: [
      {
        kind: "function",
        name: "foo",
        qualifiedName: "a::foo",
        span: { startByte: 0, endByte: 23 },
      },
    ],
    imports: [],
    exports: [],
    callSites: [],
    routes: [],
  };
  const hunks = new Map<string, readonly DiffHunk[]>([
    ["src/a.ts", [{ path: "src/a.ts", newRange: { startLine: 2, endLine: 3 } }]],
  ]);
  const irs = new Map([["src/a.ts", ir]]);
  const contents = new Map([["src/a.ts", content]]);
  const affected = findDirectlyAffectedSymbols(hunks, irs, contents);
  // Returns node ids (qualifiedName + filePath + label identity).
  assert.ok(
    affected.has(nodeIdFor({ qualifiedName: "a::foo", filePath: "src/a.ts", label: "function" })),
    "foo's node id is affected",
  );
});

test("findDirectlyAffectedSymbols: hunk NOT overlapping → not affected", () => {
  // Content with clear line boundaries:
  // Line 1: "function foo() {"
  // Line 2: "  return 1;"
  // Line 3: "}"
  // Line 4: "function bar() {}"
  // Byte layout: "function foo() {\n  return 1;\n}\nfunction bar() {}\n"
  // foo = bytes [0, 31) = lines [1, 4)
  // bar = bytes [31, 49) = line [4, 5)
  const content = new TextEncoder().encode("function foo() {\n  return 1;\n}\nfunction bar() {}\n");
  const fooEnd = "function foo() {\n  return 1;\n}\n".length; // 31
  const ir: FileIR = {
    path: "src/a.ts",
    language: "typescript",
    contentHash: "h",
    symbols: [
      {
        kind: "function",
        name: "foo",
        qualifiedName: "a::foo",
        span: { startByte: 0, endByte: fooEnd },
      },
      {
        kind: "function",
        name: "bar",
        qualifiedName: "a::bar",
        span: { startByte: fooEnd, endByte: content.length },
      },
    ],
    imports: [],
    exports: [],
    callSites: [],
    routes: [],
  };
  // Hunk on line 4 (bar's line), not foo (lines 1-3).
  const hunks = new Map<string, readonly DiffHunk[]>([
    ["src/a.ts", [{ path: "src/a.ts", newRange: { startLine: 4, endLine: 5 } }]],
  ]);
  const irs = new Map([["src/a.ts", ir]]);
  const contents = new Map([["src/a.ts", content]]);
  const affected = findDirectlyAffectedSymbols(hunks, irs, contents);
  assert.ok(
    !affected.has(nodeIdFor({ qualifiedName: "a::foo", filePath: "src/a.ts", label: "function" })),
    "foo should NOT be affected",
  );
  assert.ok(
    affected.has(nodeIdFor({ qualifiedName: "a::bar", filePath: "src/a.ts", label: "function" })),
    "bar should be affected",
  );
});

// ──────────────────────────────────────────────────────────────────────────
// cursor Bugbot fix — filePath resolution for duplicate simple names
// ──────────────────────────────────────────────────────────────────────────

/**
 * Fixture: two files both declaring a symbol named `foo` (same simple
 * name, different qualified names). a.ts::foo CALLS b.ts::foo. When b's
 * foo changes, the blast radius must report a.ts::foo with filePath
 * `src/a.ts` — NOT the wrong file resolved by a name-only search.
 *
 * Before the fix, computeBlastRadius resolved filePath via searchGraph
 * ({ namePattern: "foo", limit: 1 }), which could attach either file's
 * path to the affected symbol (cursor Bugbot: 'Blast radius wrong file
 * path'). The fix reads filePath straight from the traverse hit.
 */
const DUP_A: StoreFileIR = {
  path: "src/a.ts",
  language: "typescript",
  contentHash: "h-dup-a",
  symbols: [sym("a.foo", "foo", 0, 30)],
  edges: [edge("a.foo", "b.foo")], // a.ts::foo calls b.ts::foo
  exports: [],
};
const DUP_B: StoreFileIR = {
  path: "src/b.ts",
  language: "typescript",
  contentHash: "h-dup-b",
  symbols: [sym("b.foo", "foo", 0, 30)],
  edges: [],
  exports: [],
};

test("computeBlastRadius: duplicate simple names keep the correct filePath (cursor Bugbot: 'Blast radius wrong file path')", async () => {
  const { store, dir } = await tempStore();
  try {
    await store.upsertFileBatch([DUP_A, DUP_B]);

    // b.foo changed directly. Reverse BFS finds a.foo (a calls b).
    const affected = blast(store, new Set(["b.foo"]), 3);
    // Both symbols appear (b.foo direct, a.foo near).
    assert.equal(affected.length, 2);

    const aHit = affected.find((x) => x.qualifiedName === "a.foo");
    assert.ok(aHit, "a.foo is in the blast radius (it calls the changed b.foo)");
    // The load-bearing assertion: a.foo's filePath is src/a.ts, not the
    // wrong src/b.ts that a name-only search could return.
    assert.equal(aHit!.filePath, "src/a.ts");

    const bHit = affected.find((x) => x.qualifiedName === "b.foo");
    assert.ok(bHit);
    assert.equal(bHit!.filePath, "src/b.ts");
    assert.equal(bHit!.risk, "direct");
  } finally {
    await dispose(store, dir);
  }
});


test("findDirectlyAffectedSymbols: duplicate qualifiedName across files yields distinct node ids (chatgpt-codex-connector: 'Preserve node identity')", () => {
  // Two files both declaring a symbol with the SAME qualifiedName "dup::fn".
  // findDirectlyAffectedSymbols must return BOTH node ids (distinct because
  // identity includes filePath), not collapse them into one.
  const content = new TextEncoder().encode("function fn() {}\n");
  const irA: FileIR = {
    path: "src/a.ts", language: "typescript", contentHash: "h",
    symbols: [{ kind: "function", name: "fn", qualifiedName: "dup::fn", span: { startByte: 0, endByte: 16 } }],
    imports: [], exports: [], callSites: [], routes: [],
  };
  const irB: FileIR = {
    path: "src/b.ts", language: "typescript", contentHash: "h",
    symbols: [{ kind: "function", name: "fn", qualifiedName: "dup::fn", span: { startByte: 0, endByte: 16 } }],
    imports: [], exports: [], callSites: [], routes: [],
  };
  const hunks = new Map<string, readonly DiffHunk[]>([
    ["src/a.ts", [{ path: "src/a.ts", newRange: { startLine: 1, endLine: 2 } }]],
    ["src/b.ts", [{ path: "src/b.ts", newRange: { startLine: 1, endLine: 2 } }]],
  ]);
  const irs = new Map([["src/a.ts", irA], ["src/b.ts", irB]]);
  const contents = new Map([["src/a.ts", content], ["src/b.ts", content]]);
  const affected = findDirectlyAffectedSymbols(hunks, irs, contents);
  const idA = nodeIdFor({ qualifiedName: "dup::fn", filePath: "src/a.ts", label: "function" });
  const idB = nodeIdFor({ qualifiedName: "dup::fn", filePath: "src/b.ts", label: "function" });
  assert.notEqual(idA, idB, "node ids must differ by filePath");
  assert.ok(affected.has(idA), "a.ts node affected");
  assert.ok(affected.has(idB), "b.ts node affected");
});

test("computeBlastRadius: byte-stable sort with duplicate qualifiedNames (cursor Bugbot: 'Blast radius sort not byte-stable')", async () => {
  const { store, dir } = await tempStore();
  try {
    await store.upsertFileBatch([DUP_A, DUP_B]);
    const r1 = blast(store, new Set(["b.foo"]), 3);
    const r2 = blast(store, new Set(["b.foo"]), 3);
    // Same risk/qualifiedName for a.foo across runs — order must be
    // identical (filePath + nodeId tiebreaker makes it total).
    assert.deepEqual(
      r1.map((a) => ({ q: a.qualifiedName, f: a.filePath, r: a.risk })),
      r2.map((a) => ({ q: a.qualifiedName, f: a.filePath, r: a.risk })),
    );
    // Both files' foo are present and distinguishable.
    assert.equal(r1.length, 2);
    assert.equal(new Set(r1.map((a) => a.filePath)).size, 2);
  } finally {
    await dispose(store, dir);
  }
});


// ──────────────────────────────────────────────────────────────────────────
// Rule 22 — computeBlastRadius surfaces store failures instead of masking
// them as an empty affected set (cursor Bugbot: 'computeBlastRadius masks
// traverse failures'). Before the fix, `if (!result.ok) continue` conflated
// a backend failure with "no inbound edges".
// ──────────────────────────────────────────────────────────────────────────

test("computeBlastRadius: store failure surfaces as store_error, not masked as empty (rule 22)", async () => {
  const { store, dir } = await tempStore();
  try {
    await store.upsertFileBatch([CHAIN_FILE]);
    const ids = new Set(["chain.d"]);
    // Sanity: a healthy store resolves the blast radius.
    const ok = computeBlastRadius(store, ids, 3);
    assert.equal(ok.ok, true);

    // Close the store — traverse now returns store_closed. Before the fix
    // this was silently skipped (continue), yielding an empty affected set
    // indistinguishable from "no dependents". Now it surfaces a tagged
    // store_error so the caller knows the computation is unreliable.
    await store.close();
    const failed = computeBlastRadius(store, ids, 3);
    assert.equal(failed.ok, false);
    if (failed.ok) throw new Error("expected store_error");
    assert.equal(failed.code, "store_error");
  } finally {
    await rm(dir, { recursive: true, force: true });
  }
});
