import { Logger } from '@nestjs/common';
import { FootballDataAdapter } from './football-data.adapter';
import { FonteIndisponivelError } from './fonte-dados.port';

/**
 * Casos da fonte real que **não** dependem de rede: mapeamento, resposta
 * malformada, timeout e a cota de 10 req/min (`PP-10 BE-6`).
 *
 * A verificação contra a API de verdade fica no plano de `PP-10`, executada à
 * mão: suíte que depende de rede, de cota e do calendário real mudando debaixo
 * dela deixa de ser gate.
 */
describe('FootballDataAdapter', () => {
  const jogo = (extra: Record<string, unknown> = {}) => ({
    id: 554964,
    utcDate: '2026-08-15T19:30:00Z',
    status: 'TIMED',
    matchday: 23,
    lastUpdated: '2026-08-07T05:20:12Z',
    homeTeam: { shortName: 'Fluminense', name: 'Fluminense FC' },
    awayTeam: { shortName: 'Palmeiras', name: 'SE Palmeiras' },
    score: { fullTime: { home: null, away: null } },
    ...extra,
  });

  const respostaOk = (corpo: unknown, headers: Record<string, string> = {}) =>
    ({
      ok: true,
      status: 200,
      headers: new Headers({ 'x-requests-available-minute': '8', ...headers }),
      json: () => Promise.resolve(corpo),
    }) as unknown as Response;

  let chamadas: string[];

  const comFetch = (
    resposta: (url: string) => Promise<Response> | Response,
  ) => {
    chamadas = [];
    globalThis.fetch = ((url: string) => {
      chamadas.push(url);
      return Promise.resolve(resposta(url));
    }) as unknown as typeof fetch;
    return new FootballDataAdapter('token-de-teste');
  };

  beforeAll(() => {
    jest.spyOn(Logger.prototype, 'log').mockImplementation(() => {});
    jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
  });

  afterAll(() => jest.restoreAllMocks());

  it('mapeia o jogo da v4 para o formato da porta', async () => {
    const fonte = comFetch(() => respostaOk({ matches: [jogo()] }));

    const [j] = await fonte.listarJogos('BSA', 23, 23);

    expect(j.externalId).toBe('554964');
    expect(j.rodada).toBe(23);
    // `shortName` é o que cabe na tela do celular: "Fluminense", não
    // "Fluminense FC".
    expect(j.timeCasa).toBe('Fluminense');
    expect(j.inicioEm.toISOString()).toBe('2026-08-15T19:30:00.000Z');
    expect(j.status).toBe('TIMED');
    expect(j.golsCasa).toBeNull();
    expect(j.atualizadoEm?.toISOString()).toBe('2026-08-07T05:20:12.000Z');
  });

  it('lê o placar final de jogo terminado', async () => {
    const fonte = comFetch(() =>
      respostaOk({
        matches: [
          jogo({ status: 'FINISHED', score: { fullTime: { home: 2, away: 1 } } }),
        ],
      }),
    );

    const [j] = await fonte.listarJogos('BSA', 23, 23);
    expect([j.golsCasa, j.golsFora]).toEqual([2, 1]);
    expect(j.status).toBe('FINISHED');
  });

  it('filtra o intervalo de rodadas em uma única requisição', async () => {
    // O ponto de PP-10 BE-6: `?matchday=N` gastaria uma chamada por rodada e um
    // bolão de 16 rodadas consumiria o minuto inteiro.
    const fonte = comFetch(() =>
      respostaOk({
        matches: [
          jogo({ id: 1, matchday: 22 }),
          jogo({ id: 2, matchday: 23 }),
          jogo({ id: 3, matchday: 24 }),
          jogo({ id: 4, matchday: 25 }),
        ],
      }),
    );

    const jogos = await fonte.listarJogos('BSA', 23, 24);

    expect(jogos.map((j) => j.rodada)).toEqual([23, 24]);
    expect(chamadas).toHaveLength(1);
    expect(chamadas[0]).toContain('/competitions/BSA/matches');
    expect(chamadas[0]).not.toContain('matchday=');
  });

  it('serve do cache a segunda leitura da mesma competição', async () => {
    const fonte = comFetch(() =>
      respostaOk({ matches: [jogo(), jogo({ id: 2, matchday: 24 })] }),
    );

    await fonte.listarJogos('BSA', 23, 23);
    await fonte.listarJogos('BSA', 24, 24);

    // Criação de bolão é rajada: sem cache, cinco pessoas criando ao mesmo tempo
    // estouram a cota e ninguém consegue criar.
    expect(chamadas).toHaveLength(1);
  });

  it('descarta registro sem rodada ou sem data, sem derrubar o resto', async () => {
    const fonte = comFetch(() =>
      respostaOk({
        matches: [
          jogo({ id: 1, matchday: null }),
          jogo({ id: 2, utcDate: 'data-invalida' }),
          jogo({ id: 3, homeTeam: {} }),
          jogo({ id: 4 }),
        ],
      }),
    );

    const jogos = await fonte.listarJogos('BSA', 1, 38);

    // Jogo sem rodada não entra num bolão organizado por rodada; sem data não
    // tem prazo. Descartar o registro ruim é melhor que abortar a criação toda.
    expect(jogos.map((j) => j.externalId)).toEqual(['4']);
  });

  it('resposta sem a lista de jogos é indisponibilidade, não lista vazia', async () => {
    const fonte = comFetch(() => respostaOk({ erro: 'ops' }));

    // Lista vazia seria lida pelo domínio como "não há jogos nessas rodadas" e
    // o admin veria uma recusa de validação em vez de "tente de novo".
    await expect(fonte.listarJogos('BSA', 1, 5)).rejects.toThrow(
      FonteIndisponivelError,
    );
  });

  it('resposta com jogos, todos inutilizáveis, também é indisponibilidade', async () => {
    const fonte = comFetch(() =>
      respostaOk({ matches: [jogo({ matchday: null }), jogo({ id: 2, matchday: null })] }),
    );

    await expect(fonte.listarJogos('BSA', 1, 5)).rejects.toThrow(
      /nenhum jogo utilizável/,
    );
  });

  it('429 da fonte vira indisponibilidade com o motivo', async () => {
    const fonte = comFetch(
      () =>
        ({
          ok: false,
          status: 429,
          headers: new Headers(),
          json: () => Promise.resolve({}),
        }) as unknown as Response,
    );

    await expect(fonte.listarJogos('BSA', 1, 5)).rejects.toThrow(/429/);
  });

  it('403 (recurso fora do plano) não é confundido com erro nosso', async () => {
    const fonte = comFetch(
      () =>
        ({
          ok: false,
          status: 403,
          headers: new Headers(),
          json: () => Promise.resolve({}),
        }) as unknown as Response,
    );

    await expect(fonte.listarJogos('XXX', 1, 5)).rejects.toThrow(/403/);
  });

  it('timeout vira indisponibilidade, não exceção crua', async () => {
    chamadas = [];
    globalThis.fetch = (() => {
      const e = new Error('abortado');
      e.name = 'AbortError';
      return Promise.reject(e);
    }) as unknown as typeof fetch;
    const fonte = new FootballDataAdapter('token-de-teste');

    await expect(fonte.listarJogos('BSA', 1, 5)).rejects.toThrow(
      /tempo esgotado/,
    );
  });

  it('PP-10 BE-6 recusa a chamada quando a cota do minuto acaba', async () => {
    // Competições diferentes furam o cache de propósito, para gastar cota.
    const fonte = comFetch(() => respostaOk({ matches: [jogo()] }));

    for (let i = 0; i < 9; i++) {
      await fonte.listarJogos(`C${i}`, 1, 38);
    }
    expect(chamadas).toHaveLength(9);

    // A décima é recusada **antes** de sair: gastar e tomar 429 queimaria a cota
    // e ainda deixaria a fonte irritada com a gente.
    await expect(fonte.listarJogos('C99', 1, 38)).rejects.toThrow(
      /10 requisições por minuto/,
    );
    expect(chamadas).toHaveLength(9);
  });

  it('quando a fonte diz que a cota zerou, o adaptador para sozinho', async () => {
    const fonte = comFetch(() =>
      respostaOk(
        { matches: [jogo()] },
        { 'x-requests-available-minute': '0', 'x-requestcounter-reset': '30' },
      ),
    );

    await fonte.listarJogos('BSA', 1, 38);

    // A fonte é mais confiável que a nossa contagem: ela sabe o que outras
    // instâncias já gastaram com o mesmo token.
    await expect(fonte.listarJogos('OUTRA', 1, 38)).rejects.toThrow(
      /liberando em ~30s/,
    );
  });

  it('lista só competições de liga, com a rodada atual', async () => {
    const fonte = comFetch(() =>
      respostaOk({
        competitions: [
          {
            code: 'BSA',
            name: 'Campeonato Brasileiro Série A',
            type: 'LEAGUE',
            currentSeason: { currentMatchday: 22 },
          },
          {
            code: 'CL',
            name: 'UEFA Champions League',
            type: 'CUP',
            currentSeason: { currentMatchday: 8 },
          },
        ],
      }),
    );

    const lista = await fonte.listarCompeticoes();

    // Copa tem chave e ida/volta: o modelo de "rodadas 1..N com prazo por jogo"
    // não representa isso, e oferecer no app levaria a bolão impossível de
    // montar.
    expect(lista.map((c) => c.code)).toEqual(['BSA']);
    expect(lista[0].rodadaAtual).toBe(22);
  });
});
