import { Injectable, Logger } from '@nestjs/common';
import {
  CompeticaoExterna,
  FonteDeDados,
  FonteIndisponivelError,
  JogoExterno,
} from './fonte-dados.port';

/** Statuses que a v4 devolve. Iguais aos da porta, de propósito. */
const STATUS_VALIDOS = new Set<JogoExterno['status']>([
  'SCHEDULED',
  'TIMED',
  'IN_PLAY',
  'PAUSED',
  'FINISHED',
  'POSTPONED',
  'SUSPENDED',
  'CANCELLED',
]);

type MatchDaApi = {
  id: number;
  utcDate: string;
  status: string;
  matchday: number | null;
  lastUpdated?: string;
  homeTeam?: { shortName?: string; name?: string };
  awayTeam?: { shortName?: string; name?: string };
  score?: { fullTime?: { home: number | null; away: number | null } };
};

type CompeticaoDaApi = {
  code: string;
  name: string;
  type: string;
  currentSeason?: { currentMatchday: number | null } | null;
};

/**
 * Fonte real: `football-data.org` v4.
 *
 * Três decisões que moldam este adaptador, todas vindas do **limite de 10
 * requisições por minuto** do plano gratuito:
 *
 * 1. **Uma requisição por temporada, não por rodada.** `?matchday=N` existe, mas
 *    um bolão de 16 rodadas gastaria 16 chamadas — o minuto inteiro. O adaptador
 *    busca a competição toda (380 jogos, ~410 KB) e filtra o intervalo aqui.
 * 2. **Cache curto por competição.** Criação de bolão é rajada (todo mundo cria
 *    no mesmo dia) e a sincronização é horária. Sem cache, cinco pessoas criando
 *    bolão ao mesmo tempo estouram a cota e ninguém consegue criar.
 * 3. **A cota é respeitada antes de falhar, não depois.** O adaptador lê
 *    `X-Requests-Available-Minute` e para sozinho; 429 é tratado como
 *    indisponibilidade, que o domínio já sabe absorver sem sobrescrever dado bom
 *    (`CC-1 BE-5`, `PP-10 BE-5`).
 */
@Injectable()
export class FootballDataAdapter implements FonteDeDados {
  private readonly log = new Logger(FootballDataAdapter.name);
  private readonly base = 'https://api.football-data.org/v4';
  private readonly token: string;

  /** Teto do plano gratuito. Deixa uma de folga para não fechar na trave. */
  private readonly porMinuto = 9;
  private readonly janelaMs = 60_000;
  private chamadas: number[] = [];

  /** Momento em que a fonte pediu para esperar (429 ou cota zerada). */
  private bloqueadoAte = 0;

  private readonly cacheMs = 5 * 60_000;
  private readonly cache = new Map<
    string,
    { em: number; jogos: JogoExterno[] }
  >();
  private competicoes: { em: number; lista: CompeticaoExterna[] } | null = null;

  constructor(token: string) {
    this.token = token;
    this.log.log(
      'usando a fonte REAL football-data.org — cota de 10 req/min, cache de 5 min',
    );
  }

  async listarCompeticoes(): Promise<CompeticaoExterna[]> {
    if (this.competicoes && Date.now() - this.competicoes.em < this.cacheMs) {
      return this.competicoes.lista;
    }

    const dados = await this.buscar<{ competitions?: CompeticaoDaApi[] }>(
      '/competitions',
    );
    const brutas = dados.competitions;
    if (!Array.isArray(brutas)) {
      throw new FonteIndisponivelError('resposta de competições malformada');
    }

    const lista = brutas
      // Só liga: o produto trabalha com "rodadas 1..N" e prazo por jogo. Copa
      // tem chave e ida/volta, que o modelo de rodadas não representa — oferecer
      // no app levaria a bolão impossível de montar.
      .filter((c) => c.type === 'LEAGUE' && c.code && c.name)
      .map((c) => ({
        code: c.code,
        nome: c.name,
        rodadaAtual: c.currentSeason?.currentMatchday ?? null,
        // A API não informa o total de rodadas. Quando a temporada já está em
        // cache, sai do próprio calendário; senão fica no total do returno
        // completo de 20 clubes, que é o caso do Brasileirão.
        totalRodadas: this.totalRodadasConhecido(c.code) ?? 38,
      }));

    this.competicoes = { em: Date.now(), lista };
    return lista;
  }

  async listarJogos(
    competitionCode: string,
    rodadaInicial: number,
    rodadaFinal: number,
  ): Promise<JogoExterno[]> {
    const temporada = await this.temporada(competitionCode);
    return temporada.filter(
      (j) => j.rodada >= rodadaInicial && j.rodada <= rodadaFinal,
    );
  }

  /** Calendário completo da competição, do cache ou da rede. */
  private async temporada(code: string): Promise<JogoExterno[]> {
    const guardado = this.cache.get(code);
    if (guardado && Date.now() - guardado.em < this.cacheMs) {
      return guardado.jogos;
    }

    const dados = await this.buscar<{ matches?: MatchDaApi[] }>(
      `/competitions/${encodeURIComponent(code)}/matches`,
    );
    if (!Array.isArray(dados.matches)) {
      throw new FonteIndisponivelError('resposta de jogos malformada');
    }

    const jogos = dados.matches
      .map((m) => this.mapear(m, code))
      .filter((j): j is JogoExterno => j !== null);

    if (jogos.length === 0 && dados.matches.length > 0) {
      // A fonte respondeu, mas nada aproveitável: tratar como indisponibilidade
      // é melhor que devolver lista vazia, que o domínio leria como "não há
      // jogos nessas rodadas" e viraria bolão vazio.
      throw new FonteIndisponivelError(
        'nenhum jogo utilizável na resposta da fonte',
      );
    }

    this.cache.set(code, { em: Date.now(), jogos });
    return jogos;
  }

  /**
   * Converte um jogo da API. Devolve `null` para registro que não dá para usar —
   * um jogo sem rodada ou sem data não pode entrar num bolão que é organizado
   * por rodada e cujo prazo é o apito.
   */
  private mapear(m: MatchDaApi, code: string): JogoExterno | null {
    if (typeof m.id !== 'number' || m.matchday == null || !m.utcDate) {
      return null;
    }
    const inicioEm = new Date(m.utcDate);
    if (Number.isNaN(inicioEm.getTime())) return null;

    const casa = m.homeTeam?.shortName ?? m.homeTeam?.name;
    const fora = m.awayTeam?.shortName ?? m.awayTeam?.name;
    if (!casa || !fora) return null;

    const status = STATUS_VALIDOS.has(m.status as JogoExterno['status'])
      ? (m.status as JogoExterno['status'])
      : 'SCHEDULED';

    const atualizadoEm = m.lastUpdated ? new Date(m.lastUpdated) : null;

    return {
      externalId: String(m.id),
      competitionCode: code,
      rodada: m.matchday,
      timeCasa: casa,
      timeFora: fora,
      inicioEm,
      status,
      golsCasa: m.score?.fullTime?.home ?? null,
      golsFora: m.score?.fullTime?.away ?? null,
      atualizadoEm:
        atualizadoEm && !Number.isNaN(atualizadoEm.getTime())
          ? atualizadoEm
          : null,
    };
  }

  private totalRodadasConhecido(code: string): number | null {
    const guardado = this.cache.get(code);
    if (!guardado || guardado.jogos.length === 0) return null;
    return Math.max(...guardado.jogos.map((j) => j.rodada));
  }

  /**
   * Uma chamada HTTP, respeitando a cota e traduzindo **toda** falha em
   * `FonteIndisponivelError`. O domínio decide o que fazer: a criação de bolão
   * aborta inteira, a sincronização preserva o que já existe.
   */
  private async buscar<T>(caminho: string): Promise<T> {
    this.exigirCota();

    const controle = new AbortController();
    const timeout = setTimeout(() => controle.abort(), 10_000);
    try {
      this.chamadas.push(Date.now());
      const r = await fetch(`${this.base}${caminho}`, {
        headers: { 'X-Auth-Token': this.token },
        signal: controle.signal,
      });

      this.registrarCota(r.headers);

      if (r.status === 429) {
        throw new FonteIndisponivelError('cota de requisições excedida (429)');
      }
      if (r.status === 403) {
        // Recurso fora do plano gratuito. Não é falha transitória, mas o domínio
        // trata igual: nunca sobrescrever dado bom.
        throw new FonteIndisponivelError('recurso não disponível no plano (403)');
      }
      if (!r.ok) {
        throw new FonteIndisponivelError(`resposta ${r.status} da fonte`);
      }

      return (await r.json()) as T;
    } catch (erro) {
      if (erro instanceof FonteIndisponivelError) throw erro;
      if (erro instanceof Error && erro.name === 'AbortError') {
        throw new FonteIndisponivelError('tempo esgotado na fonte (10s)');
      }
      throw new FonteIndisponivelError(
        erro instanceof Error ? erro.message : 'falha desconhecida',
      );
    } finally {
      clearTimeout(timeout);
    }
  }

  /** Recusa a chamada **antes** de gastá-la, quando a cota já está no fim. */
  private exigirCota(): void {
    const agora = Date.now();
    if (agora < this.bloqueadoAte) {
      const s = Math.ceil((this.bloqueadoAte - agora) / 1000);
      throw new FonteIndisponivelError(
        `cota da fonte esgotada — liberando em ~${s}s`,
      );
    }
    this.chamadas = this.chamadas.filter((t) => agora - t < this.janelaMs);
    if (this.chamadas.length >= this.porMinuto) {
      const maisAntiga = this.chamadas[0];
      this.bloqueadoAte = maisAntiga + this.janelaMs;
      throw new FonteIndisponivelError(
        'cota de 10 requisições por minuto atingida',
      );
    }
  }

  /** A própria fonte diz quanto sobrou; é mais confiável que a nossa contagem. */
  private registrarCota(headers: Headers): void {
    const restantes = Number(
      headers.get('x-requests-available-minute') ?? 'NaN',
    );
    const reset = Number(headers.get('x-requestcounter-reset') ?? 'NaN');
    if (!Number.isNaN(restantes) && restantes <= 0) {
      const segundos = Number.isNaN(reset) ? 60 : reset;
      this.bloqueadoAte = Date.now() + segundos * 1000;
      this.log.warn(
        `cota da fonte zerada — pausando por ${segundos}s (informado pela fonte)`,
      );
    }
  }
}
