// Resolver hardening — ESPN team-roster index is the PRIMARY name→id source
// for NBA/WNBA (the v2 search is a backstop). Hermetic: fetch is injected, no
// network. Verifies the index is COMPLETE (accented + suffix names fold), that
// a roster-only player resolves (search never consulted needlessly), that the
// roster hit WINS over a conflicting search result, that an unknown name never
// guesses, and that a failing team fetch is skipped (partial index, no throw).

// Redis no-op so cache read/write paths don't touch a live server.
jest.mock('../../src/utils/redis', () => ({
  cacheGet: async () => null,
  cacheSet: async () => {},
  cacheDel: async () => {},
}));

const espn = require('../../src/services/adapters/espnStatsAdapter');

// ── Fixtures ────────────────────────────────────────────────────────────────
const TEAMS = {
  sports: [{ leagues: [{ teams: [
    { team: { id: '1', displayName: 'Minnesota Lynx' } },
    { team: { id: '2', displayName: 'Atlanta Dream' } },
    { team: { id: 'FAIL', displayName: 'Broken Team' } },
  ] }] }],
};

// Team 1 roster: the dual-league athlete the search missed + an accented name +
// a suffix name (the two forms nameKey must fold to canonical keys).
const ROSTER_1 = {
  athletes: [
    { id: '3917450', displayName: 'Napheesa Collier', fullName: 'Napheesa Collier' },
    { id: '999', displayName: 'José Álvarez', fullName: 'José Álvarez' },
    { id: '555', displayName: 'Ronald Acuña Jr.', fullName: 'Ronald Acuña Jr.' },
    { id: null, displayName: 'No Id Player' },           // skipped — no id
    { id: 'guid-xyz', displayName: 'Guid Only' },        // skipped — non-numeric id
  ],
};
// Team 2 roster uses the GROUPED shape ([{ position, items:[…] }]).
const ROSTER_2 = {
  athletes: [
    { position: 'Guard', items: [{ id: '4066533', displayName: 'Sabrina Ionescu' }] },
    { position: 'Forward', items: [{ id: '2998928', displayName: 'Breanna Stewart' }] },
  ],
};

const GAMELOG = {
  names: ['points', 'totalRebounds', 'assists'],
  seasonTypes: [{ categories: [{ events: [{ eventId: 'G1', stats: ['20', '9', '5'] }] }] }],
  events: { G1: { gameDate: '2026-07-13T01:00:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'IND' } } },
};

// A search payload that would resolve "Napheesa Collier" to a DIFFERENT
// (wrong) id — proves the roster index is consulted first and wins.
const SEARCH_CONFLICT = {
  results: [{ type: 'player', contents: [
    { uid: 's:40~l:59~a:9999', displayName: 'Napheesa Collier', defaultLeagueSlug: 'wnba' },
  ] }],
};
const SEARCH_EMPTY = { results: [] };

// Route an injected fetch by URL. `/roster` matched BEFORE `/teams` (the roster
// URL contains "/teams/"). Records whether search was ever hit.
function makeFetch({ search = SEARCH_EMPTY, failTeam = 'FAIL' } = {}) {
  const calls = { search: 0, teams: 0, rosters: [] };
  const impl = async (url) => {
    if (url.includes('/search/v2')) { calls.search++; return search; }
    const rosterMatch = /\/teams\/([^/]+)\/roster/.exec(url);
    if (rosterMatch) {
      const tid = rosterMatch[1];
      calls.rosters.push(tid);
      if (tid === failTeam) throw new Error('roster fetch boom');
      if (tid === '1') return ROSTER_1;
      if (tid === '2') return ROSTER_2;
      return { athletes: [] };
    }
    if (url.endsWith('/teams')) { calls.teams++; return TEAMS; }
    if (url.includes('/gamelog')) return GAMELOG;
    return null;
  };
  impl.calls = calls;
  return impl;
}

beforeEach(() => {
  espn.__internals.gameLogMem.clear();
  espn.__internals.rosterMem.clear();
});

describe('buildAthleteRosterIndex', () => {
  it('aggregates all team rosters into a complete nameKey→{id,displayName,teamId} map', async () => {
    const fetchImpl = makeFetch();
    const index = await espn.buildAthleteRosterIndex('wnba', { fetchImpl });

    // Dual-league athlete present.
    expect(index['napheesa collier']).toMatchObject({ id: '3917450', teamId: '1' });
    // Accented name folds to an accent-stripped key.
    expect(index['jose alvarez']).toMatchObject({ id: '999', teamId: '1' });
    // Suffix name folds (Jr. stripped) — display keeps the accent/original.
    expect(index['ronald acuna']).toMatchObject({ id: '555', displayName: 'Ronald Acuña Jr.' });
    // Grouped-shape roster (team 2) flattened.
    expect(index['sabrina ionescu']).toMatchObject({ id: '4066533', teamId: '2' });
    expect(index['breanna stewart']).toMatchObject({ id: '2998928', teamId: '2' });
  });

  it('skips athletes with no id or a non-numeric id (absent beats wrong)', async () => {
    const index = await espn.buildAthleteRosterIndex('wnba', { fetchImpl: makeFetch() });
    expect(index['no id player']).toBeUndefined();
    expect(index['guid only']).toBeUndefined();
  });

  it('skips a team whose roster fetch fails — partial index, never throws', async () => {
    const fetchImpl = makeFetch(); // team 'FAIL' throws
    let index;
    await expect((async () => { index = await espn.buildAthleteRosterIndex('wnba', { fetchImpl }); })()).resolves.toBeUndefined();
    // The two healthy teams still populated the index.
    expect(index['napheesa collier']).toBeDefined();
    expect(index['sabrina ionescu']).toBeDefined();
    expect(fetchImpl.calls.rosters).toContain('FAIL');
  });

  it('returns {} for an unsupported sport without fetching', async () => {
    const fetchImpl = makeFetch();
    expect(await espn.buildAthleteRosterIndex('nfl', { fetchImpl })).toEqual({});
    expect(fetchImpl.calls.teams).toBe(0);
  });
});

describe('getPlayerGameLog — roster index is the primary resolver', () => {
  it('resolves a player found ONLY in the roster index (search returns nothing) → returns its gamelog', async () => {
    const fetchImpl = makeFetch({ search: SEARCH_EMPTY });
    const res = await espn.getPlayerGameLog('Napheesa Collier', 'wnba', { fetchImpl });
    expect(res.found).toBe(true);
    expect(res.id).toBe('3917450');
    expect(res.last10[0].stat.points).toBe(20);
  });

  it('tries the roster index BEFORE search — a roster hit wins over a conflicting search id', async () => {
    const fetchImpl = makeFetch({ search: SEARCH_CONFLICT });
    const res = await espn.getPlayerGameLog('Napheesa Collier', 'wnba', { fetchImpl });
    // Roster id (3917450), NOT the search's wrong 9999.
    expect(res.id).toBe('3917450');
    // Search was never consulted — the roster resolved it.
    expect(fetchImpl.calls.search).toBe(0);
  });

  it('a name in NEITHER roster nor search → { found:false } (never guesses another player)', async () => {
    const fetchImpl = makeFetch({ search: SEARCH_EMPTY });
    const res = await espn.getPlayerGameLog('Ghost Player', 'wnba', { fetchImpl });
    expect(res).toEqual({ found: false });
    // Search WAS consulted as the backstop after the roster miss.
    expect(fetchImpl.calls.search).toBe(1);
  });

  it('falls back to search when the roster index has no hit', async () => {
    // Sabrina is on team 2's roster, so instead query a name only search knows.
    const searchOnly = {
      results: [{ type: 'player', contents: [
        { uid: 's:40~l:59~a:3149391', displayName: "A'ja Wilson", defaultLeagueSlug: 'wnba' },
      ] }],
    };
    const fetchImpl = makeFetch({ search: searchOnly });
    const res = await espn.getPlayerGameLog("A'ja Wilson", 'wnba', { fetchImpl });
    expect(res.found).toBe(true);
    expect(res.id).toBe('3149391');
    expect(fetchImpl.calls.search).toBe(1);
  });
});

describe('resolveAthleteId ordering', () => {
  it('returns the roster id without ever calling search when the roster has the name', async () => {
    const fetchImpl = makeFetch({ search: SEARCH_CONFLICT });
    const id = await espn.resolveAthleteId('Napheesa Collier', 'wnba', { fetchImpl });
    expect(id).toBe('3917450');
    expect(fetchImpl.calls.search).toBe(0);
  });
});
