/**
 * Unit tests for `narrowOverloadCandidates` — the shared overload-
 * narrowing utility used by `receiver-bound-calls.ts::pickOverload`
 * (explicit receiver member call) and
 * `free-call-fallback.ts::pickImplicitThisOverload` (implicit-`this`
 * free call).
 *
 * The utility is pure (data in / data out), so tests build synthetic
 * `SymbolDefinition` stubs — no fixtures, no pipeline.
 */

import { describe, it, expect } from 'vitest';
import type { SymbolDefinition } from 'gitnexus-shared';
import { narrowOverloadCandidates } from '../../../src/core/ingestion/scope-resolution/passes/overload-narrowing.js';

const mkDef = (overrides: Partial<SymbolDefinition> & { nodeId: string }): SymbolDefinition => ({
  nodeId: overrides.nodeId,
  filePath: overrides.filePath ?? 'x.cs',
  type: overrides.type ?? 'Method',
  ...overrides,
});

describe('narrowOverloadCandidates — empty input', () => {
  it('returns empty output for empty overload list', () => {
    expect(narrowOverloadCandidates([], 1, ['int'])).toEqual([]);
    expect(narrowOverloadCandidates([], undefined, undefined)).toEqual([]);
  });
});

describe('narrowOverloadCandidates — arity filtering', () => {
  const add1 = mkDef({ nodeId: 'add:1', parameterCount: 1, requiredParameterCount: 1 });
  const add2 = mkDef({ nodeId: 'add:2', parameterCount: 2, requiredParameterCount: 2 });
  const add3 = mkDef({ nodeId: 'add:3', parameterCount: 3, requiredParameterCount: 3 });

  it('passes all overloads through when argCount is undefined', () => {
    const result = narrowOverloadCandidates([add1, add2, add3], undefined, undefined);
    expect(result.map((d) => d.nodeId)).toEqual(['add:1', 'add:2', 'add:3']);
  });

  it('filters out overloads whose max is below argCount (non-variadic)', () => {
    const result = narrowOverloadCandidates([add1, add2, add3], 2, undefined);
    expect(result.map((d) => d.nodeId)).toEqual(['add:2']);
  });

  it('filters out overloads whose required-count exceeds argCount', () => {
    const result = narrowOverloadCandidates([add1, add2, add3], 1, undefined);
    expect(result.map((d) => d.nodeId)).toEqual(['add:1']);
  });

  it('accepts argCount above max when `params` variadic marker is present', () => {
    const writeLine = mkDef({
      nodeId: 'wl:1',
      parameterCount: 2,
      requiredParameterCount: 1,
      parameterTypes: ['string', 'params object[]'],
    });
    const result = narrowOverloadCandidates([writeLine], 5, undefined);
    expect(result.map((d) => d.nodeId)).toEqual(['wl:1']);
  });

  it('accepts argCount above max when bare `params` marker is present', () => {
    const variadic = mkDef({
      nodeId: 'v:1',
      parameterCount: 1,
      requiredParameterCount: 0,
      parameterTypes: ['params'],
    });
    const result = narrowOverloadCandidates([variadic], 4, undefined);
    expect(result.map((d) => d.nodeId)).toEqual(['v:1']);
  });

  it('returns empty when arity filter empties the set AND every candidate had definite bounds', () => {
    // argCount=5 doesn't match any overload (none variadic, all have max < 5).
    // Post-commit af9af4a9 (PR #1497 / U1): the empty result is now authoritative
    // because every rejected candidate had defined `parameterCount` /
    // `requiredParameterCount`. The old "always fall back to full list" rescue
    // was deliberately removed so resolvers actually drop calls that are
    // definitively arity-incompatible (e.g., PHP `f(int $req, ...$rest)`
    // called with zero args).
    const result = narrowOverloadCandidates([add1, add2, add3], 5, undefined);
    expect(result.map((d) => d.nodeId)).toEqual([]);
  });

  // Note: the `anyUnknownBounds ? overloads : []` branch in
  // narrowOverloadCandidates is structurally unreachable in this caller's
  // shape — a candidate with both `parameterCount` and `requiredParameterCount`
  // undefined always passes the arity filter (neither `argCount > max` nor
  // `argCount < min` can fire), so `arityMatches.length` is always > 0
  // whenever `anyUnknownBounds` is true. The branch is preserved in the
  // source as a defensive guard for future refactors that might add
  // additional rejection criteria in the filter.
});

describe('narrowOverloadCandidates — type narrowing', () => {
  const byInt = mkDef({
    nodeId: 'm:int',
    parameterCount: 1,
    requiredParameterCount: 1,
    parameterTypes: ['int'],
  });
  const byString = mkDef({
    nodeId: 'm:string',
    parameterCount: 1,
    requiredParameterCount: 1,
    parameterTypes: ['string'],
  });

  it('picks the overload whose parameterTypes[i] equals argTypes[i]', () => {
    const result = narrowOverloadCandidates([byInt, byString], 1, ['string']);
    expect(result.map((d) => d.nodeId)).toEqual(['m:string']);
  });

  it('treats empty-string argTypes slot as "unknown" and matches every candidate', () => {
    const result = narrowOverloadCandidates([byInt, byString], 1, ['']);
    // Both candidates survive because "" is an unknown slot.
    expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']);
  });

  it('falls through to arity-filtered candidates when type filter matches nothing', () => {
    const result = narrowOverloadCandidates([byInt, byString], 1, ['bool']);
    // Type mismatch against both — falls back to arity candidates.
    expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']);
  });

  it('skips the type filter entirely when argTypes is undefined', () => {
    const result = narrowOverloadCandidates([byInt, byString], 1, undefined);
    expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']);
  });

  it('skips the type filter entirely when argTypes is empty', () => {
    const result = narrowOverloadCandidates([byInt, byString], 1, []);
    expect(result.map((d) => d.nodeId).sort()).toEqual(['m:int', 'm:string']);
  });

  it('disqualifies an overload with missing parameterTypes under type filter', () => {
    const noTypes = mkDef({
      nodeId: 'm:notypes',
      parameterCount: 1,
      requiredParameterCount: 1,
    });
    const result = narrowOverloadCandidates([byInt, noTypes], 1, ['int']);
    expect(result.map((d) => d.nodeId)).toEqual(['m:int']);
  });
});

describe('narrowOverloadCandidates — constraint filter monotonicity (issue #1579)', () => {
  // Language-agnostic contract: when `constraintCompatibility` returns
  // 'unknown' for every candidate, the filter must keep every candidate.
  // Adding a predicate to the registry can only narrow correctly, never
  // produce a wrong edge — this guarantees the worst-case behavior is
  // today's "degrade not lie" suppression, not a regression.
  const a = mkDef({
    nodeId: 'a',
    parameterCount: 1,
    requiredParameterCount: 1,
    parameterTypes: ['T'],
    templateConstraints: { dummy: true },
  });
  const b = mkDef({
    nodeId: 'b',
    parameterCount: 1,
    requiredParameterCount: 1,
    parameterTypes: ['T'],
    templateConstraints: { dummy: true },
  });

  it('keeps every candidate when constraintCompatibility returns unknown for all', () => {
    const result = narrowOverloadCandidates([a, b], 1, ['int'], {
      constraintCompatibility: () => 'unknown',
    });
    expect(result.map((d) => d.nodeId).sort()).toEqual(['a', 'b']);
  });

  it('drops only candidates the hook explicitly marks incompatible', () => {
    const result = narrowOverloadCandidates([a, b], 1, ['int'], {
      constraintCompatibility: (_callsite, def) =>
        def.nodeId === 'a' ? 'incompatible' : 'compatible',
    });
    expect(result.map((d) => d.nodeId)).toEqual(['b']);
  });

  it('skips the constraint filter when hookCtx is omitted (pre-#1579 behavior preserved)', () => {
    const result = narrowOverloadCandidates([a, b], 1, ['int']);
    expect(result.map((d) => d.nodeId).sort()).toEqual(['a', 'b']);
  });

  it('skips the constraint filter for candidates without templateConstraints', () => {
    const plain = mkDef({
      nodeId: 'plain',
      parameterCount: 1,
      requiredParameterCount: 1,
      parameterTypes: ['T'],
    });
    // Even though the hook would return 'incompatible' for everything, the
    // candidate has no templateConstraints so the filter doesn't consult it.
    const result = narrowOverloadCandidates([plain], 1, ['int'], {
      constraintCompatibility: () => 'incompatible',
    });
    expect(result.map((d) => d.nodeId)).toEqual(['plain']);
  });
});
