/**
 * Phase A heuristic edge resolution tests (issue #1891).
 *
 * deriveHeuristicEdges: pure function FileIR[] → per-file EdgeIR[] + stats.
 * The store's pass-2 (batch map + full-DB dst fallback, conservative
 * ambiguity drops) does final id resolution; this module only decides
 * WHICH (srcQualifiedName → dstQualifiedName, CALLS) assertions a fresh
 * parse supports, so every emitted edge must be derivable from the IR
 * alone.
 */
import assert from "node:assert/strict";
import test from "node:test";

import {
  deriveHeuristicEdges,
  HEURISTIC_CONFIDENCE_SAME_FILE,
  HEURISTIC_CONFIDENCE_IMPORT_BOUND,
} from "./heuristic-resolution.js";
import type { EdgeIR, FileIR } from "./graph-store.js";

function firstFile(result: { files: readonly { edges: readonly EdgeIR[] }[] }): { edges: readonly EdgeIR[] } {
  const file = result.files[0];
  assert.ok(file, "expected at least one file in the result");
  return file;
}

function fileIR(overrides: Partial<FileIR> & { path: string }): FileIR {
  return {
    language: "typescript",
    contentHash: `h-${overrides.path}`,
    symbols: [],
    imports: [],
    exports: [],
    callSites: [],
    routes: [],
    ...overrides,
  } as FileIR;
}

const span = (startByte: number, endByte: number) => ({ startByte, endByte });

test("same-file call resolves to a CALLS edge with heuristic provenance", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 70) },
      { kind: "function", name: "format", qualifiedName: "format", span: span(71, 132) },
    ],
    callSites: [{ calleeNameCandidates: ["format"], span: span(40, 46) }],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.equal(result.files.length, 1);
  assert.deepEqual(firstFile(result).edges, [
    {
      srcQualifiedName: "greet",
      dstQualifiedName: "format",
      type: "CALLS",
      confidence: HEURISTIC_CONFIDENCE_SAME_FILE,
      provenance: "heuristic",
    },
  ]);
  assert.equal(stats.callSites, 1);
  assert.equal(stats.resolved, 1);
});

test("import-bound cross-file call emits an edge for the store to resolve", () => {
  const ir = fileIR({
    path: "util.ts",
    symbols: [
      { kind: "function", name: "shout", qualifiedName: "shout", span: span(30, 110) },
    ],
    imports: [{ module: "./main", importedNames: ["greet"], span: span(0, 29) }],
    callSites: [{ calleeNameCandidates: ["greet"], span: span(60, 67) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.deepEqual(firstFile(result).edges, [
    {
      srcQualifiedName: "shout",
      dstQualifiedName: "greet",
      type: "CALLS",
      confidence: HEURISTIC_CONFIDENCE_IMPORT_BOUND,
      provenance: "heuristic",
      dstPathHint: "main",
      dstImporterLanguage: "typescript",
    },
  ]);
});

test("unresolvable bare names are skipped and counted, never emitted", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 70) },
    ],
    callSites: [{ calleeNameCandidates: ["log"], span: span(20, 25) }],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.deepEqual(firstFile(result).edges, []);
  assert.equal(stats.skippedUnresolved, 1);
  assert.equal(stats.resolved, 0);
});

test("call site outside any symbol span is skipped and counted", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "format", qualifiedName: "format", span: span(50, 100) },
    ],
    // Top-level call before the first symbol.
    callSites: [{ calleeNameCandidates: ["format"], span: span(0, 8) }],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.deepEqual(firstFile(result).edges, []);
  assert.equal(stats.skippedNoEnclosingSymbol, 1);
});

test("innermost enclosing symbol wins as src (nested spans)", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "class", name: "Outer", qualifiedName: "Outer", span: span(0, 200) },
      { kind: "method", name: "run", qualifiedName: "Outer.run", span: span(20, 120) },
      { kind: "function", name: "helper", qualifiedName: "helper", span: span(210, 260) },
    ],
    callSites: [{ calleeNameCandidates: ["helper"], span: span(60, 68) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.equal(firstFile(result).edges.length, 1);
  assert.equal(firstFile(result).edges[0]?.srcQualifiedName, "Outer.run");
});

test("ambiguous same-file name is skipped conservatively and counted", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "run", qualifiedName: "run", span: span(0, 50) },
      // Same bare name at a different span (e.g. overload-style duplicate).
      { kind: "function", name: "format", qualifiedName: "a.format", span: span(60, 100) },
      { kind: "function", name: "format", qualifiedName: "b.format", span: span(110, 150) },
    ],
    callSites: [{ calleeNameCandidates: ["format"], span: span(20, 28) }],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.deepEqual(firstFile(result).edges, []);
  assert.equal(stats.skippedAmbiguous, 1);
});

test("second callee candidate is tried when the first cannot resolve", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "run", qualifiedName: "run", span: span(0, 50) },
      { kind: "function", name: "fallback", qualifiedName: "fallback", span: span(60, 100) },
    ],
    callSites: [{ calleeNameCandidates: ["missing", "fallback"], span: span(10, 20) }],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.equal(firstFile(result).edges.length, 1);
  assert.equal(firstFile(result).edges[0]?.dstQualifiedName, "fallback");
  assert.equal(stats.resolved, 1);
});

test("recursive self-call emits a self edge", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "walk", qualifiedName: "walk", span: span(0, 90) },
    ],
    callSites: [{ calleeNameCandidates: ["walk"], span: span(40, 46) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.deepEqual(firstFile(result).edges.map((e: EdgeIR) => [e.srcQualifiedName, e.dstQualifiedName]), [
    ["walk", "walk"],
  ]);
});

test("duplicate src→dst call sites are deduped to one edge", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 90) },
      { kind: "function", name: "format", qualifiedName: "format", span: span(100, 150) },
    ],
    callSites: [
      { calleeNameCandidates: ["format"], span: span(20, 26) },
      { calleeNameCandidates: ["format"], span: span(50, 56) },
    ],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.equal(firstFile(result).edges.length, 1);
  assert.equal(stats.callSites, 2);
  assert.equal(stats.resolved, 2);
});

test("files with no call sites assert an explicit empty edge set", () => {
  const ir = fileIR({
    path: "empty.ts",
    symbols: [
      { kind: "function", name: "solo", qualifiedName: "solo", span: span(0, 20) },
    ],
  });
  const result = deriveHeuristicEdges([ir]);
  // Empty ARRAY (not undefined): the fresh parse asserts "no heuristic
  // edges", so stale heuristic edges from a prior version are cleaned up.
  assert.deepEqual(firstFile(result).edges, []);
});

test("output is deterministic and byte-stable across runs (rule 38)", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "a", qualifiedName: "a", span: span(0, 40) },
      { kind: "function", name: "b", qualifiedName: "b", span: span(50, 90) },
      { kind: "function", name: "c", qualifiedName: "c", span: span(100, 140) },
    ],
    callSites: [
      { calleeNameCandidates: ["c"], span: span(10, 12) },
      { calleeNameCandidates: ["b"], span: span(20, 22) },
    ],
  });
  const one = JSON.stringify(deriveHeuristicEdges([ir]));
  const two = JSON.stringify(deriveHeuristicEdges([ir]));
  assert.equal(one, two);
});

test("a nested symbol under a different parent is NOT visible to a sibling caller", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "outer", qualifiedName: "outer", span: span(0, 100) },
      { kind: "function", name: "helper", qualifiedName: "outer.helper", span: span(10, 60) },
      { kind: "function", name: "run", qualifiedName: "run", span: span(110, 180) },
    ],
    callSites: [{ calleeNameCandidates: ["helper"], span: span(130, 138) }],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.deepEqual(firstFile(result).edges, [], "outer.helper is not in run's lexical scope");
  assert.equal(stats.skippedUnresolved, 1);
});

test("shadowing: the innermost visible declaration wins over a top-level one", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "greet", qualifiedName: "greet", span: span(0, 100) },
      { kind: "function", name: "format", qualifiedName: "greet.format", span: span(10, 50) },
      { kind: "function", name: "format", qualifiedName: "format", span: span(110, 160) },
    ],
    callSites: [{ calleeNameCandidates: ["format"], span: span(60, 68) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.equal(firstFile(result).edges.length, 1);
  assert.equal(
    firstFile(result).edges[0]?.dstQualifiedName,
    "greet.format",
    "the nested declaration shadows the top-level one for calls inside greet",
  );
});

test("a caller's own nested declaration is visible to it", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "outer", qualifiedName: "outer", span: span(0, 100) },
      { kind: "function", name: "helper", qualifiedName: "outer.helper", span: span(10, 40) },
    ],
    // Call inside outer but outside helper.
    callSites: [{ calleeNameCandidates: ["helper"], span: span(60, 68) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.deepEqual(
    firstFile(result).edges.map((e: EdgeIR) => [e.srcQualifiedName, e.dstQualifiedName]),
    [["outer", "outer.helper"]],
  );
});

test("external-package imports never bind bare names (no false in-repo edges)", () => {
  const ir = fileIR({
    path: "util.ts",
    symbols: [
      { kind: "function", name: "shout", qualifiedName: "shout", span: span(30, 110) },
    ],
    imports: [{ module: "lodash", importedNames: ["map"], span: span(0, 29) }],
    callSites: [{ calleeNameCandidates: ["map"], span: span(60, 65) }],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.deepEqual(firstFile(result).edges, [], "lodash's map must not bind an in-repo symbol");
  assert.equal(stats.skippedUnresolved, 1);
});

test("relative-module imports bind with a normalized in-repo hint", () => {
  const ir = fileIR({
    path: "src/util.ts",
    symbols: [
      { kind: "function", name: "shout", qualifiedName: "shout", span: span(30, 110) },
    ],
    imports: [{ module: "../lib/main.js", importedNames: ["greet"], span: span(0, 29) }],
    callSites: [{ calleeNameCandidates: ["greet"], span: span(60, 67) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.equal(firstFile(result).edges.length, 1);
  assert.equal(firstFile(result).edges[0]?.confidence, HEURISTIC_CONFIDENCE_IMPORT_BOUND);
  assert.equal(firstFile(result).edges[0]?.dstPathHint, "lib/main");
});

test("an import escaping the repo root never binds (cursor review)", () => {
  const ir = fileIR({
    path: "util.ts",
    symbols: [
      { kind: "function", name: "shout", qualifiedName: "shout", span: span(30, 110) },
    ],
    // dirname("util.ts") = "." so ../lib escapes the repo root — files.path
    // is canonical and can never match a "../" hint.
    imports: [{ module: "../lib/main.js", importedNames: ["greet"], span: span(0, 29) }],
    callSites: [{ calleeNameCandidates: ["greet"], span: span(60, 67) }],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.deepEqual(firstFile(result).edges, []);
  assert.equal(stats.skippedUnresolved, 1);
});

test("member-access call sites never bind bare names (codex review)", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "function", name: "run", qualifiedName: "run", span: span(0, 90) },
      { kind: "function", name: "connect", qualifiedName: "connect", span: span(100, 150) },
    ],
    // client.connect() — the property name matches a visible bare symbol
    // but the receiver decides the target; Phase A must skip it.
    callSites: [{ calleeNameCandidates: ["connect"], memberAccess: true, span: span(30, 40) }],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.deepEqual(firstFile(result).edges, []);
  assert.equal(stats.skippedMemberAccess, 1);
});

test("aliased imports bind by local name and emit the exported dst (codex review)", () => {
  const ir = fileIR({
    path: "util.ts",
    symbols: [
      { kind: "function", name: "shout", qualifiedName: "shout", span: span(30, 110) },
    ],
    imports: [
      {
        module: "./main",
        importedNames: ["greet"],
        bindings: [{ exported: "greet", local: "salute" }],
        span: span(0, 29),
      },
    ],
    // Call site uses the LOCAL alias...
    callSites: [{ calleeNameCandidates: ["salute"], span: span(60, 68) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.equal(firstFile(result).edges.length, 1);
  // ...but the edge's dst is the EXPORTED name in the target file.
  assert.equal(firstFile(result).edges[0]?.dstQualifiedName, "greet");
  assert.equal(firstFile(result).edges[0]?.dstPathHint, "main");
});

test("an ambiguous first candidate does not block a later candidate's import binding (cursor review)", () => {
  const ir = fileIR({
    path: "util.ts",
    symbols: [
      { kind: "function", name: "shout", qualifiedName: "shout", span: span(40, 120) },
      { kind: "function", name: "dup", qualifiedName: "a.dup", span: span(130, 160) },
      { kind: "function", name: "dup", qualifiedName: "b.dup", span: span(170, 200) },
    ],
    imports: [{ module: "./main", importedNames: ["greet"], span: span(0, 29) }],
    callSites: [{ calleeNameCandidates: ["dup", "greet"], span: span(60, 70) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.equal(firstFile(result).edges.length, 1);
  assert.equal(firstFile(result).edges[0]?.dstQualifiedName, "greet");
  assert.equal(firstFile(result).edges[0]?.dstPathHint, "main");
});

test("python dot-style relative imports bind with package-path hints (cursor review)", () => {
  const ir = fileIR({
    path: "pkg/app/views.py",
    language: "python",
    symbols: [
      { kind: "function", name: "render", qualifiedName: "render", span: span(30, 110) },
    ],
    imports: [
      { module: ".models", importedNames: ["User"], span: span(0, 25) },
      { module: "..lib.utils", importedNames: ["helper"], span: span(26, 55) },
    ],
    callSites: [
      { calleeNameCandidates: ["User"], span: span(60, 64) },
      { calleeNameCandidates: ["helper"], span: span(70, 76) },
    ],
  });
  const result = deriveHeuristicEdges([ir]);
  const hints = firstFile(result).edges.map((e: EdgeIR) => [e.dstQualifiedName, e.dstPathHint]);
  assert.deepEqual(hints, [
    ["User", "pkg/app/models"],
    ["helper", "pkg/lib/utils"],
  ]);
});

test("python dot-style import escaping the root never binds", () => {
  const ir = fileIR({
    path: "views.py",
    language: "python",
    symbols: [
      { kind: "function", name: "render", qualifiedName: "render", span: span(30, 110) },
    ],
    imports: [{ module: "..outside", importedNames: ["thing"], span: span(0, 25) }],
    callSites: [{ calleeNameCandidates: ["thing"], span: span(60, 65) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.deepEqual(firstFile(result).edges, []);
});

test("same exported name from two modules yields two hinted edges (codex review)", () => {
  const ir = fileIR({
    path: "util.ts",
    symbols: [
      { kind: "function", name: "run", qualifiedName: "run", span: span(60, 160) },
    ],
    imports: [
      { module: "./a", importedNames: ["foo"], bindings: [{ exported: "foo", local: "fooA" }], span: span(0, 28) },
      { module: "./b", importedNames: ["foo"], bindings: [{ exported: "foo", local: "fooB" }], span: span(29, 57) },
    ],
    callSites: [
      { calleeNameCandidates: ["fooA"], span: span(80, 86) },
      { calleeNameCandidates: ["fooB"], span: span(100, 106) },
    ],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.deepEqual(
    firstFile(result).edges.map((e: EdgeIR) => [e.dstQualifiedName, e.dstPathHint]),
    [
      ["foo", "a"],
      ["foo", "b"],
    ],
  );
});

test("a bare call inside a method never binds a sibling method (codex review round 8)", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "class", name: "C", qualifiedName: "C", span: span(0, 200) },
      { kind: "method", name: "run", qualifiedName: "C.run", span: span(10, 100) },
      { kind: "method", name: "helper", qualifiedName: "C.helper", span: span(110, 190) },
      { kind: "function", name: "helper", qualifiedName: "helper", span: span(210, 260) },
    ],
    // bare helper() inside C.run — this.helper() would be memberAccess.
    callSites: [{ calleeNameCandidates: ["helper"], span: span(40, 48) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.deepEqual(
    firstFile(result).edges.map((e: EdgeIR) => [e.srcQualifiedName, e.dstQualifiedName]),
    [["C.run", "helper"]],
    "binds the file-level function, never the sibling method C.helper",
  );
});

test("with only a sibling method available, a bare call stays unresolved", () => {
  const ir = fileIR({
    path: "main.ts",
    symbols: [
      { kind: "class", name: "C", qualifiedName: "C", span: span(0, 200) },
      { kind: "method", name: "run", qualifiedName: "C.run", span: span(10, 100) },
      { kind: "method", name: "helper", qualifiedName: "C.helper", span: span(110, 190) },
    ],
    callSites: [{ calleeNameCandidates: ["helper"], span: span(40, 48) }],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.deepEqual(firstFile(result).edges, []);
  assert.equal(stats.skippedUnresolved, 1);
});

test("absent bindings falls back to importedNames; explicit [] binds nothing (rounds 9 + 12)", () => {
  const base = {
    path: "util.ts",
    symbols: [
      { kind: "function" as const, name: "shout", qualifiedName: "shout", span: span(30, 110) },
    ],
    callSites: [{ calleeNameCandidates: ["greet"], span: span(60, 67) }],
  };
  // Absent field (pre-bindings emitter / hand-built IR) -> fallback binds.
  const absent = deriveHeuristicEdges([
    fileIR({ ...base, imports: [{ module: "./main", importedNames: ["greet"], span: span(0, 29) }] }),
  ]);
  assert.equal(firstFile(absent).edges.length, 1);
  // Explicit [] (default/namespace import: exported symbol unknowable) ->
  // never re-bound through the fallback.
  const explicit = deriveHeuristicEdges([
    fileIR({
      ...base,
      imports: [{ module: "./main", importedNames: ["greet"], bindings: [], span: span(0, 29) }],
    }),
  ]);
  assert.deepEqual(firstFile(explicit).edges, []);
});

test("pure parent-package python imports hint the package directory (codex review round 10)", () => {
  const ir = fileIR({
    path: "pkg/app/views.py",
    language: "python",
    symbols: [
      { kind: "function", name: "render", qualifiedName: "render", span: span(30, 110) },
    ],
    imports: [
      { module: ".", importedNames: ["sibling"], span: span(0, 20) },
      { module: "..", importedNames: ["helper"], span: span(21, 45) },
    ],
    callSites: [
      { calleeNameCandidates: ["sibling"], span: span(60, 67) },
      { calleeNameCandidates: ["helper"], span: span(70, 76) },
    ],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.deepEqual(
    firstFile(result).edges.map((e: EdgeIR) => [e.dstQualifiedName, e.dstPathHint]),
    [
      ["sibling", "pkg/app"],
      ["helper", "pkg"],
    ],
  );
});

test("implicit-this languages bind sibling methods on bare calls (codex review round 11)", () => {
  const ir = fileIR({
    path: "C.java",
    language: "java",
    symbols: [
      { kind: "class", name: "C", qualifiedName: "C", span: span(0, 200) },
      { kind: "method", name: "run", qualifiedName: "C.run", span: span(10, 100) },
      { kind: "method", name: "helper", qualifiedName: "C.helper", span: span(110, 190) },
    ],
    // Java: helper() inside run() legally targets the sibling method.
    callSites: [{ calleeNameCandidates: ["helper"], span: span(40, 48) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.deepEqual(
    firstFile(result).edges.map((e: EdgeIR) => [e.srcQualifiedName, e.dstQualifiedName]),
    [["C.run", "C.helper"]],
  );
});

test("php bare calls inside methods never bind sibling methods (codex review round 12)", () => {
  const ir = fileIR({
    path: "C.php",
    language: "php",
    symbols: [
      { kind: "class", name: "C", qualifiedName: "C", span: span(0, 200) },
      { kind: "method", name: "run", qualifiedName: "C.run", span: span(10, 100) },
      { kind: "method", name: "helper", qualifiedName: "C.helper", span: span(110, 190) },
    ],
    callSites: [{ calleeNameCandidates: ["helper"], span: span(40, 48) }],
  });
  const result = deriveHeuristicEdges([ir]);
  assert.deepEqual(firstFile(result).edges, []);
});

test("function-local imports bind only within their enclosing symbol (codex review round 12)", () => {
  const ir = fileIR({
    path: "app.py",
    language: "python",
    symbols: [
      { kind: "function", name: "setup", qualifiedName: "setup", span: span(0, 100) },
      { kind: "function", name: "run", qualifiedName: "run", span: span(110, 200) },
    ],
    // from .dep import helper INSIDE setup's span.
    imports: [{ module: ".dep", importedNames: ["helper"], span: span(10, 40) }],
    callSites: [
      // Inside setup: binds.
      { calleeNameCandidates: ["helper"], span: span(60, 66) },
      // Inside sibling run: must NOT bind.
      { calleeNameCandidates: ["helper"], span: span(140, 146) },
    ],
  });
  const result = deriveHeuristicEdges([ir]);
  const { stats } = result;
  assert.deepEqual(
    firstFile(result).edges.map((e: EdgeIR) => e.srcQualifiedName),
    ["setup"],
  );
  assert.equal(stats.skippedUnresolved, 1);
});
