/**
 * LSP resolution pass tests (issue #1555 step 4 — prove-fail-before).
 *
 * Covers:
 *   - Planner: basic planning, budget enforcement, deterministic ordering
 *   - Executor: happy path (location maps → upgrade applied)
 *   - Executor: location doesn't map → unresolved
 *   - Executor: mid-batch applyUpgrades failure → caught, counted as unresolved
 *   - Executor: server crash mid-run → degradation, remaining sites unresolved
 *   - Executor: fatal error after upgrade in same batch → NOT committed (rule 25)
 *   - Executor: didOpen sent before definition requests (LSP 3.17)
 *   - Executor: workspaceRoot resolves repo-relative paths to absolute URIs
 *   - mapLocationToNode: same-file, cross-file with resolver, fallback
 *   - Characterization: with lsp.enabled=false, resolution is a no-op
 */
import assert from "node:assert/strict";
import test from "node:test";

import {
  planLspUpgrades,
  executeLspResolution,
  mapLocationToNode,
  type EdgeUpgrade,
  type NodeLocator,
  type UnresolvedCallSite,
} from "./resolution.js";
import type { LspLocation } from "./types.js";
import type { LspClient } from "./client.js";

// ──────────────────────────────────────────────────────────────────────────
// Planner tests (pure — no I/O)
// ──────────────────────────────────────────────────────────────────────────

function makeCallSite(
  filePath: string,
  calleeName: string,
  calleeByteOffset: number,
  srcQualifiedName: string,
  content = "line0\nline1\nline2",
): UnresolvedCallSite {
  return {
    filePath,
    language: "typescript",
    content,
    calleeByteOffset,
    calleeName,
    srcQualifiedName,
  };
}

test("planner: basic — plans one request per call site", () => {
  const sites = [
    makeCallSite("src/a.ts", "foo", 0, "a.caller"),
    makeCallSite("src/b.ts", "bar", 6, "b.caller"),
  ];
  const result = planLspUpgrades(sites, { maxRequests: 100 });
  assert.equal(result.requests.length, 2);
  assert.equal(result.budgetExhausted, 0);
  // Sorted by file path.
  assert.equal(result.requests[0].filePath, "src/a.ts");
  assert.equal(result.requests[1].filePath, "src/b.ts");
});

test("planner: budget enforcement — excess counted as budgetExhausted", () => {
  const sites = [
    makeCallSite("src/a.ts", "foo", 0, "a.x"),
    makeCallSite("src/b.ts", "bar", 0, "b.x"),
    makeCallSite("src/c.ts", "baz", 0, "c.x"),
  ];
  const result = planLspUpgrades(sites, { maxRequests: 2 });
  assert.equal(result.requests.length, 2);
  assert.equal(result.budgetExhausted, 1);
});

test("planner: zero budget → all exhausted", () => {
  const sites = [makeCallSite("a.ts", "x", 0, "a.x")];
  const result = planLspUpgrades(sites, { maxRequests: 0 });
  assert.equal(result.requests.length, 0);
  assert.equal(result.budgetExhausted, 1);
});

test("planner: deterministic ordering — same input always same output", () => {
  const sites = [
    makeCallSite("src/z.ts", "z", 10, "z.x"),
    makeCallSite("src/a.ts", "a", 5, "a.x"),
    makeCallSite("src/a.ts", "b", 3, "a.y"),
  ];
  const r1 = planLspUpgrades(sites, { maxRequests: 100 });
  const r2 = planLspUpgrades([...sites].reverse(), { maxRequests: 100 });
  assert.deepEqual(
    r1.requests.map((r) => `${r.filePath}:${r.position.line}:${r.position.character}`),
    r2.requests.map((r) => `${r.filePath}:${r.position.line}:${r.position.character}`),
  );
  // Sorted: a.ts byte 3, a.ts byte 5, z.ts byte 10.
  assert.equal(r1.requests[0].calleeName, "b");
  assert.equal(r1.requests[1].calleeName, "a");
  assert.equal(r1.requests[2].calleeName, "z");
});

test("planner: position conversion — byte offset → line/character", () => {
  // content = "line0\nline1\nline2"
  //                    ^ byte 6 = start of "line1"
  const site = makeCallSite("a.ts", "foo", 6, "a.x", "line0\nline1\nline2");
  const result = planLspUpgrades([site], { maxRequests: 100 });
  assert.equal(result.requests.length, 1);
  assert.equal(result.requests[0].position.line, 1);
  assert.equal(result.requests[0].position.character, 0);
});

// ──────────────────────────────────────────────────────────────────────────
// Location → node mapping tests (rule 35 — half-open containment)
// ──────────────────────────────────────────────────────────────────────────

test("mapLocationToNode: location in span → returns qualified name", () => {
  const locator: NodeLocator = (_path, byteOffset) => {
    // Node spans bytes [0, 10).
    return byteOffset >= 0 && byteOffset < 10 ? "mod.target" : null;
  };
  const locations: LspLocation[] = [
    {
      uri: "file:///src/target.ts",
      range: { start: { line: 0, character: 5 }, end: { line: 0, character: 8 } },
    },
  ];
  // callerContent is used for position→byte conversion.
  // character 5 on line 0 = byte 5 (ASCII content).
  const result = mapLocationToNode(
    locations,
    { callerFilePath: "src/target.ts", callerContent: "abcdefghij" },
    locator,
  );
  assert.equal(result, "mod.target");
});

test("mapLocationToNode: location outside span → null", () => {
  const locator: NodeLocator = () => null;
  const locations: LspLocation[] = [
    {
      uri: "file:///src/target.ts",
      range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
    },
  ];
  const result = mapLocationToNode(
    locations,
    { callerFilePath: "src/target.ts", callerContent: "abcdef" },
    locator,
  );
  assert.equal(result, null);
});

test("mapLocationToNode: multiple locations — first match wins", () => {
  let callCount = 0;
  const locator: NodeLocator = () => {
    callCount++;
    return callCount === 2 ? "mod.second" : null;
  };
  const locations: LspLocation[] = [
    { uri: "file:///a.ts", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } },
    { uri: "file:///b.ts", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } },
  ];
  const result = mapLocationToNode(
    locations,
    { callerFilePath: "src/a.ts", callerContent: "abc" },
    locator,
  );
  assert.equal(result, "mod.second");
});

test("mapLocationToNode: empty locations → null", () => {
  const locator: NodeLocator = () => "should-not-be-called";
  const result = mapLocationToNode(
    [],
    { callerFilePath: "src/a.ts", callerContent: "abc" },
    locator,
  );
  assert.equal(result, null);
});

test("mapLocationToNode: cross-file definition uses resolveContent", () => {
  // Target file has different content than the caller. The correct byte
  // offset can only be computed from the target file's content.
  // Target: "ab\ncdefgh\n" — line 1 starts at byte 3, character 2 = byte 5.
  const locator: NodeLocator = (filePath, byteOffset) => {
    if (filePath === "src/target.ts" && byteOffset === 5) return "mod.target";
    return null;
  };
  const locations: LspLocation[] = [
    {
      uri: "file:///workspace/src/target.ts",
      range: { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } },
    },
  ];
  const result = mapLocationToNode(
    locations,
    {
      callerFilePath: "src/caller.ts",
      callerContent: "XXXXXXXXXX", // wrong content — must NOT be used
      workspaceRoot: "/workspace",
      resolveContent: (fp) => (fp === "src/target.ts" ? "ab\ncdefgh\n" : null),
    },
    locator,
  );
  assert.equal(result, "mod.target");
});

test("mapLocationToNode: cross-file without resolveContent → callerContent fallback", () => {
  // Without a content resolver, cross-file definitions fall back to the
  // caller's content (best-effort, documented behavior).
  const locator: NodeLocator = () => "mod.fallback";
  const locations: LspLocation[] = [
    { uri: "file:///src/other.ts", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } },
  ];
  const result = mapLocationToNode(
    locations,
    { callerFilePath: "src/caller.ts", callerContent: "abc" },
    locator,
  );
  assert.equal(result, "mod.fallback");
});

// ──────────────────────────────────────────────────────────────────────────
// Executor tests (mock client)
// ──────────────────────────────────────────────────────────────────────────

/**
 * Minimal mock LspClient — only implements the methods the executor calls.
 */
function makeMockClient(
  definitionResults: Map<string, { ok: true; locations: LspLocation[] } | { ok: false; code: string }>,
): LspClient {
  return {
    definition: async (params: { textDocument: { uri: string }; position: { line: number; character: number } }) => {
      // Key by uri+line+character for deterministic lookup.
      const key = `${params.textDocument.uri}:${params.position.line}:${params.position.character}`;
      const result = definitionResults.get(key);
      if (!result) return { ok: true, locations: [] };
      if (result.ok) return result;
      return {
        ok: false,
        degradation: { backend: "lsp" as const, code: result.code as "server_crashed" },
      };
    },
    didOpen: () => {},
    dispose: async () => {},
    supportsDefinition: true,
    pid: undefined,
  } as unknown as LspClient;
}

test("executor: happy path — definition maps to node, upgrade applied", async () => {
  const requests = planLspUpgrades(
    [makeCallSite("src/a.ts", "target", 0, "a.caller")],
    { maxRequests: 100 },
  ).requests;

  // Mock: definition at position {0, 0} returns a location at byte 0
  // in src/target.ts. The nodeLocator finds "mod.target" at byte 0.
  const mockClient = makeMockClient(new Map([
    ["file:///src/a.ts:0:0", {
      ok: true as const,
      locations: [{
        uri: "file:///src/target.ts",
        range: { start: { line: 0, character: 0 }, end: { line: 0, character: 6 } },
      }],
    }],
  ]));

  const locator: NodeLocator = () => "mod.target";
  const applied: EdgeUpgrade[] = [];
  const result = await executeLspResolution(requests, {
    client: mockClient,
    nodeLocator: locator,
    applyUpgrades: async (upgrades) => {
      applied.push(...upgrades);
    },
  });

  assert.equal(result.upgraded, 1);
  assert.equal(result.unresolved, 0);
  assert.equal(applied.length, 1);
  assert.equal(applied[0].provenance, "lsp");
  assert.equal(applied[0].confidence, 0.9);
  assert.equal(applied[0].dstQualifiedName, "mod.target");
});

test("executor: definition returns empty → unresolved", async () => {
  const requests = planLspUpgrades(
    [makeCallSite("src/a.ts", "missing", 0, "a.caller")],
    { maxRequests: 100 },
  ).requests;

  const mockClient = makeMockClient(new Map());
  const locator: NodeLocator = () => null;
  const result = await executeLspResolution(requests, {
    client: mockClient,
    nodeLocator: locator,
    applyUpgrades: async () => {},
    // Warm-up retry (issue #1933) is exercised by its own tests below;
    // this test asserts the definitive-empty path.
    warmupRetryDelayMs: 0,
  });

  assert.equal(result.upgraded, 0);
  assert.equal(result.unresolved, 1);
});

// ──────────────────────────────────────────────────────────────────────────
// Warm-up retry (issue #1933): tsserver answers pre-project-load definition
// requests with [] instead of an error. An empty result before the server
// has proven warm is retried once; the retry's answer is final.
// ──────────────────────────────────────────────────────────────────────────

test("executor: warm-up retry — empty first answer retried once, retry result upgrades", async () => {
  const requests = planLspUpgrades(
    [makeCallSite("src/a.ts", "target", 0, "a.caller")],
    { maxRequests: 100 },
  ).requests;

  // Stateful mock: first definition call returns [], second returns the
  // real location (project finished loading between the two).
  let calls = 0;
  const client = {
    definition: async () => {
      calls += 1;
      if (calls === 1) return { ok: true as const, locations: [] };
      return {
        ok: true as const,
        locations: [{
          uri: "file:///src/target.ts",
          range: { start: { line: 0, character: 0 }, end: { line: 0, character: 6 } },
        }],
      };
    },
    didOpen: () => {},
    dispose: async () => {},
  } as unknown as LspClient;

  const applied: EdgeUpgrade[] = [];
  const result = await executeLspResolution(requests, {
    client,
    nodeLocator: () => "mod.target",
    applyUpgrades: async (upgrades) => {
      applied.push(...upgrades);
    },
    warmupRetryDelayMs: 5,
  });

  assert.equal(calls, 2, "empty pre-warm answer must be retried exactly once");
  assert.equal(result.upgraded, 1, "the retry's location produces the upgrade");
  assert.equal(result.unresolved, 0);
  assert.equal(applied.length, 1);
});

test("executor: warm-up retry — only ONE retry is paid; later empties are definitive", async () => {
  const requests = planLspUpgrades(
    [
      makeCallSite("src/a.ts", "missing1", 0, "a.caller"),
      makeCallSite("src/a.ts", "missing2", 10, "a.caller"),
    ],
    { maxRequests: 100 },
  ).requests;

  let calls = 0;
  const client = {
    definition: async () => {
      calls += 1;
      return { ok: true as const, locations: [] };
    },
    didOpen: () => {},
    dispose: async () => {},
  } as unknown as LspClient;

  const result = await executeLspResolution(requests, {
    client,
    nodeLocator: () => null,
    applyUpgrades: async () => {},
    warmupRetryDelayMs: 5,
  });

  // Site 1: initial + one warm-up retry = 2 calls; the server is then
  // considered warm. Site 2: exactly 1 call, its empty answer is final.
  assert.equal(calls, 3, "one warm-up retry total, not one per site");
  assert.equal(result.upgraded, 0);
  assert.equal(result.unresolved, 2);
});

test("executor: warm-up retry — a non-empty FIRST answer marks the server warm (no retry paid)", async () => {
  const requests = planLspUpgrades(
    [
      makeCallSite("src/a.ts", "target", 0, "a.caller"),
      makeCallSite("src/a.ts", "missing", 10, "a.caller"),
    ],
    { maxRequests: 100 },
  ).requests;

  let calls = 0;
  const client = {
    definition: async () => {
      calls += 1;
      if (calls === 1) {
        return {
          ok: true as const,
          locations: [{
            uri: "file:///src/target.ts",
            range: { start: { line: 0, character: 0 }, end: { line: 0, character: 6 } },
          }],
        };
      }
      return { ok: true as const, locations: [] };
    },
    didOpen: () => {},
    dispose: async () => {},
  } as unknown as LspClient;

  const result = await executeLspResolution(requests, {
    client,
    nodeLocator: () => "mod.target",
    applyUpgrades: async () => {},
    warmupRetryDelayMs: 5,
  });

  assert.equal(calls, 2, "warm server: the second site's empty answer is definitive, no retry");
  assert.equal(result.upgraded, 1);
  assert.equal(result.unresolved, 1);
});

test("executor: mid-batch applyUpgrades failure → caught, counted as unresolved", async () => {
  const requests = planLspUpgrades(
    [makeCallSite("src/a.ts", "target", 0, "a.caller")],
    { maxRequests: 100 },
  ).requests;

  const mockClient = makeMockClient(new Map([
    ["file:///src/a.ts:0:0", {
      ok: true as const,
      locations: [{
        uri: "file:///src/target.ts",
        range: { start: { line: 0, character: 0 }, end: { line: 0, character: 6 } },
      }],
    }],
  ]));

  const locator: NodeLocator = () => "mod.target";
  const result = await executeLspResolution(requests, {
    client: mockClient,
    nodeLocator: locator,
    applyUpgrades: async () => {
      throw new Error("simulated transaction failure");
    },
  });

  // The upgrade was found but the apply failed → counted as unresolved.
  assert.equal(result.upgraded, 0);
  assert.equal(result.unresolved, 1);
});

test("executor: server crash mid-run → degradation, remaining unresolved", async () => {
  const sites = [
    makeCallSite("src/a.ts", "first", 0, "a.x"),
    makeCallSite("src/b.ts", "second", 0, "b.x"),
  ];
  const requests = planLspUpgrades(sites, { maxRequests: 100 }).requests;

  // First file batch: definition request gets server_crashed.
  const mockClient = makeMockClient(new Map([
    ["file:///src/a.ts:0:0", { ok: false as const, code: "server_crashed" }],
  ]));

  const result = await executeLspResolution(requests, {
    client: mockClient,
    nodeLocator: () => null,
    applyUpgrades: async () => {},
  });

  assert.ok(result.degradation, "should have a degradation");
  assert.equal(result.degradation.code, "server_crashed");
  // The first request was interrupted by the crash; subsequent file
  // batches are NOT processed because the degradation short-circuits.
  assert.equal(result.upgraded, 0);
});

test("executor: fatal error after upgrade in same batch → NOT committed (rule 25)", async () => {
  // Two call sites in the SAME file. First succeeds, second crashes.
  // The first upgrade must NOT be committed — per-batch atomicity.
  const sites = [
    makeCallSite("src/a.ts", "first", 0, "a.x"),
    makeCallSite("src/a.ts", "second", 6, "a.y"),
  ];
  const requests = planLspUpgrades(sites, { maxRequests: 100 }).requests;

  const mockClient = makeMockClient(new Map([
    // byte 0 → line 0, char 0 — succeeds.
    ["file:///src/a.ts:0:0", {
      ok: true as const,
      locations: [{
        uri: "file:///src/a.ts",
        range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
      }],
    }],
    // byte 6 → line 1, char 0 — server crashes.
    ["file:///src/a.ts:1:0", { ok: false as const, code: "server_crashed" }],
  ]));

  let applyCalled = false;
  const result = await executeLspResolution(requests, {
    client: mockClient,
    nodeLocator: () => "mod.target",
    applyUpgrades: async () => { applyCalled = true; },
  });

  // Per-batch atomicity: the upgrade from the first request is NOT
  // committed because the batch was poisoned by the fatal error.
  assert.equal(result.upgraded, 0);
  assert.equal(applyCalled, false, "applyUpgrades must NOT be called for a poisoned batch");
  assert.ok(result.degradation);
  assert.equal(result.degradation.code, "server_crashed");
  // The crashed request (1) + the lost upgrade (1) = 2 unresolved.
  assert.equal(result.unresolved, 2);
});

test("executor: request_timeout counts as unresolved, pass continues", async () => {
  const sites = [
    makeCallSite("src/a.ts", "slow", 0, "a.x"),
    makeCallSite("src/b.ts", "fast", 0, "b.x"),
  ];
  const requests = planLspUpgrades(sites, { maxRequests: 100 }).requests;

  // First request times out; second succeeds.
  const mockClient = makeMockClient(new Map([
    ["file:///src/a.ts:0:0", { ok: false as const, code: "request_timeout" }],
    ["file:///src/b.ts:0:0", {
      ok: true as const,
      locations: [{
        uri: "file:///src/b.ts",
        range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
      }],
    }],
  ]));

  const applied: EdgeUpgrade[] = [];
  const result = await executeLspResolution(requests, {
    client: mockClient,
    nodeLocator: () => "mod.resolved",
    applyUpgrades: async (u) => { applied.push(...u); },
  });

  assert.equal(result.upgraded, 1, "second request should succeed");
  assert.equal(result.unresolved, 1, "first request timed out");
  assert.equal(applied.length, 1);
  assert.equal(applied[0].srcQualifiedName, "b.x");
});

test("executor: empty requests → zero upgraded, zero unresolved", async () => {
  const mockClient = makeMockClient(new Map());
  const result = await executeLspResolution([], {
    client: mockClient,
    nodeLocator: () => null,
    applyUpgrades: async () => {},
  });
  assert.equal(result.upgraded, 0);
  assert.equal(result.unresolved, 0);
});

test("executor: didOpen sent before definition request (LSP 3.17)", async () => {
  const requests = planLspUpgrades(
    [makeCallSite("src/a.ts", "target", 0, "a.caller")],
    { maxRequests: 100 },
  ).requests;

  const didOpenCalls: { uri: string; languageId: string; text: string }[] = [];
  const mockClient = {
    definition: async () => ({ ok: true as const, locations: [] }),
    didOpen: (item: { uri: string; languageId: string; text: string }) => {
      didOpenCalls.push({ uri: item.uri, languageId: item.languageId, text: item.text });
    },
    dispose: async () => {},
  } as unknown as LspClient;

  await executeLspResolution(requests, {
    client: mockClient,
    nodeLocator: () => null,
    applyUpgrades: async () => {},
  });

  assert.equal(didOpenCalls.length, 1, "didOpen must be called once per file batch");
  assert.equal(didOpenCalls[0].uri, "file:///src/a.ts");
  assert.equal(didOpenCalls[0].languageId, "typescript");
  assert.equal(didOpenCalls[0].text, "line0\nline1\nline2");
});

test("executor: workspaceRoot resolves repo-relative paths to absolute URIs", async () => {
  const requests = planLspUpgrades(
    [makeCallSite("src/a.ts", "target", 0, "a.caller")],
    { maxRequests: 100 },
  ).requests;

  // With workspaceRoot, the URI sent to the server is absolute.
  const definitionUris: string[] = [];
  const didOpenUris: string[] = [];
  const mockClient = {
    definition: async (params: { textDocument: { uri: string } }) => {
      definitionUris.push(params.textDocument.uri);
      return { ok: true as const, locations: [] };
    },
    didOpen: (item: { uri: string }) => { didOpenUris.push(item.uri); },
    dispose: async () => {},
  } as unknown as LspClient;

  await executeLspResolution(requests, {
    client: mockClient,
    nodeLocator: () => null,
    applyUpgrades: async () => {},
    workspaceRoot: "/workspace",
    // This test asserts URI shapes, not warm-up behavior (issue #1933).
    warmupRetryDelayMs: 0,
  });

  assert.equal(didOpenUris.length, 1);
  assert.equal(didOpenUris[0], "file:///workspace/src/a.ts");
  assert.equal(definitionUris.length, 1);
  assert.equal(definitionUris[0], "file:///workspace/src/a.ts");
});
