import { Inject, Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
  FONTE_DADOS,
  FonteIndisponivelError,
} from '../fonte-dados/fonte-dados.port';
import type { FonteDeDados } from '../fonte-dados/fonte-dados.port';
import { CorrecaoService } from '../scoring/correcao.service';
import type { AvisoDeCorrecao } from '../scoring/correcao.service';
import { ScoringService } from '../scoring/scoring.service';

export interface ResultadoSync {
  jogosVerificados: number;
  horariosAlterados: number;
  placaresAlterados: number;
  palpitesApurados: number;
  palpitesReabertos: number;
  correcoesAplicadas: number;
  avisosEnviados: number;
  /** Palpites que perderam a apuração porque a fonte retirou o placar. */
  pontosRevertidos: number;
  falhou: boolean;
  /** Outra sincronização já estava rodando; esta não fez nada. */
  ignorada?: boolean;
}

/** Resultado neutro, para o pedido que não executou nada. */
const VAZIO: ResultadoSync = {
  jogosVerificados: 0,
  horariosAlterados: 0,
  placaresAlterados: 0,
  palpitesApurados: 0,
  palpitesReabertos: 0,
  correcoesAplicadas: 0,
  avisosEnviados: 0,
  pontosRevertidos: 0,
  falhou: false,
};

@Injectable()
export class SyncService {
  private readonly log = new Logger(SyncService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly scoring: ScoringService,
    private readonly correcao: CorrecaoService,
    @Inject(FONTE_DADOS) private readonly fonte: FonteDeDados,
  ) {}

  /**
   * Sincroniza os jogos de um campeonato com a fonte e apura o que der.
   *
   * Não existe webhook: a detecção é por **comparação** com o que já está
   * armazenado. É o único ponto de entrada de dado externo do sistema (PP-10).
   */
  /**
   * Sincronização em curso, por campeonato.
   *
   * Duas sincronizações simultâneas leem o placar **antigo** ao mesmo tempo e as
   * duas gravam correção: o histórico do jogo passa a mostrar a mesma alteração
   * duas vezes e o aviso de recálculo sai duplicado. Acontece de verdade — cron
   * horário que demora mais de uma hora, ou cron pegando junto com uma
   * sincronização disparada à mão.
   *
   * Guarda **em processo**: cobre a instância única que esta versão roda. Com
   * mais de uma instância, o lugar da trava passa a ser o banco
   * (`pg_try_advisory_lock`) — registrado como limite conhecido em `PP-10`.
   */
  private emAndamento = new Map<string, Promise<ResultadoSync>>();

  async sincronizar(competitionCode = 'BSA'): Promise<ResultadoSync> {
    const jaRodando = this.emAndamento.get(competitionCode);
    if (jaRodando) {
      this.log.warn(
        `sincronização de ${competitionCode} já em andamento — pedido ignorado`,
      );
      // Não devolve o resultado da outra: quem chamou não fez esse trabalho, e
      // somar os números duas vezes mentiria no relatório do job.
      return { ...VAZIO, ignorada: true };
    }

    const execucao = this.executar(competitionCode);
    this.emAndamento.set(competitionCode, execucao);
    try {
      return await execucao;
    } finally {
      this.emAndamento.delete(competitionCode);
    }
  }

  private async executar(competitionCode: string): Promise<ResultadoSync> {
    // Avisos de correção desta execução, para virarem **um** aviso por
    // participante no fim (PR-7 BE-3). Uma rodada corrigida inteira mandava um
    // aviso por jogo — spam que faz o participante desligar a notificação e
    // deixar de ver as que importam.
    const avisosDeCorrecao: AvisoDeCorrecao[] = [];
    const base: ResultadoSync = {
      jogosVerificados: 0,
      horariosAlterados: 0,
      placaresAlterados: 0,
      palpitesApurados: 0,
      palpitesReabertos: 0,
      correcoesAplicadas: 0,
      avisosEnviados: 0,
      pontosRevertidos: 0,
      falhou: false,
    };

    const rodadas = await this.prisma.match.aggregate({
      where: { competitionCode },
      _min: { matchday: true },
      _max: { matchday: true },
    });
    if (rodadas._min.matchday === null || rodadas._max.matchday === null)
      return base;

    let externos;
    try {
      externos = await this.fonte.listarJogos(
        competitionCode,
        rodadas._min.matchday,
        rodadas._max.matchday,
      );
    } catch (error) {
      // Falha da fonte **não corrompe** o que já temos: registra e tenta no
      // ciclo seguinte. Sobrescrever com vazio seria pior que não sincronizar.
      if (error instanceof FonteIndisponivelError) {
        this.log.error(`sincronização abortada: ${error.message}`);
        return { ...base, falhou: true };
      }
      throw error;
    }

    const armazenados = await this.prisma.match.findMany({
      where: { externalId: { in: externos.map((e) => e.externalId) } },
      select: {
        id: true,
        externalId: true,
        kickoffAt: true,
        status: true,
        homeScore: true,
        awayScore: true,
      },
    });
    const porExternalId = new Map(armazenados.map((m) => [m.externalId, m]));

    const resultado = { ...base };

    for (const externo of externos) {
      const atual = porExternalId.get(externo.externalId);
      if (!atual) continue;
      resultado.jogosVerificados++;

      const horarioMudou =
        atual.kickoffAt.getTime() !== externo.inicioEm.getTime();
      const placarMudou =
        atual.homeScore !== externo.golsCasa ||
        atual.awayScore !== externo.golsFora;
      const statusMudou = atual.status !== externo.status;

      if (!horarioMudou && !placarMudou && !statusMudou) continue;

      // Placar mudou num jogo que JÁ tinha placar: é correção, não apuração
      // nova. Vai pelo caminho de PR-6, que recalcula e avisa os afetados.
      const eCorrecao =
        placarMudou &&
        atual.homeScore !== null &&
        externo.golsCasa !== null &&
        externo.golsFora !== null;

      if (eCorrecao) {
        const r = await this.correcao.aplicar(
          atual.id,
          externo.golsCasa!,
          externo.golsFora!,
          'OFFICIAL',
          undefined,
          avisosDeCorrecao,
        );
        resultado.correcoesAplicadas++;
        resultado.placaresAlterados++;
        // A correção já atualizou o jogo; só o horário pode faltar.
        if (horarioMudou) {
          await this.prisma.match.update({
            where: { id: atual.id },
            data: {
              kickoffAt: externo.inicioEm,
              sourceUpdatedAt: externo.atualizadoEm,
            },
          });
          resultado.horariosAlterados++;
        }
        continue;
      }

      // A fonte retirou o placar de um jogo que já tinha: o resultado foi
      // retratado (adiamento depois de erro de lançamento, por exemplo). A
      // apuração feita sobre aquele placar tem que sair junto — senão o palpite
      // reabre e o ponto antigo fica, sem relação nenhuma com o palpite que vai
      // valer. Encontrado pelo caso `PP-6-07`.
      const placarRetirado =
        atual.homeScore !== null && externo.golsCasa === null;

      await this.prisma.match.update({
        where: { id: atual.id },
        data: {
          kickoffAt: externo.inicioEm,
          status: externo.status,
          homeScore: externo.golsCasa,
          awayScore: externo.golsFora,
          sourceUpdatedAt: externo.atualizadoEm,
        },
      });

      if (placarRetirado) {
        resultado.pontosRevertidos += await this.reverterApuracao(
          atual.id,
          atual.externalId,
        );
      }

      if (horarioMudou) {
        resultado.horariosAlterados++;
        // Adiamento: o prazo passa a ser o novo horário e o palpite volta a
        // aceitar edição. A reabertura é **automática**, nunca ação do admin.
        if (externo.inicioEm > new Date()) {
          resultado.palpitesReabertos += await this.avisarReabertura(
            atual.id,
            externo.inicioEm,
          );
          this.log.log(
            `jogo ${atual.externalId} adiado para ${externo.inicioEm.toISOString()} — palpite reaberto`,
          );
        }
      }

      if (placarMudou) resultado.placaresAlterados++;
    }

    resultado.palpitesApurados = await this.scoring.apurarPendentes();

    // Um aviso por participante, com todos os jogos corrigidos nesta execução.
    resultado.avisosEnviados += await this.correcao.gravarConsolidado(
      avisosDeCorrecao,
    );
    return resultado;
  }

  /**
   * Conta os palpites reabertos e **avisa** cada participante.
   *
   * Reabertura silenciosa é pior que não reabrir: quem não fica sabendo mantém o
   * palpite antigo sem revisar, e descobre na apuração que jogou um placar que
   * escolheu para outra data (PP-6 FE-2).
   */
  private async avisarReabertura(
    matchId: string,
    novoInicio: Date,
  ): Promise<number> {
    const palpites = await this.prisma.prediction.findMany({
      where: { matchId },
      select: { userId: true, poolId: true, homeScore: true, awayScore: true },
    });
    if (palpites.length === 0) return 0;

    const jogo = await this.prisma.match.findUnique({
      where: { id: matchId },
      select: { homeTeam: true, awayTeam: true },
    });

    await this.prisma.notification.createMany({
      data: palpites.map((p) => ({
        userId: p.userId,
        poolId: p.poolId,
        kind: 'JOGO_REABERTO',
        title: 'Um jogo foi adiado e o palpite reabriu',
        body:
          `${jogo?.homeTeam} x ${jogo?.awayTeam} foi remarcado para ` +
          `${novoInicio.toISOString()}. Seu palpite de ${p.homeScore}x` +
          `${p.awayScore} continua valendo, e dá para mudar até o novo apito.`,
      })),
    });

    return palpites.length;
  }

  /**
   * Desfaz a apuração de um jogo cujo placar a fonte retirou, e **avisa** quem
   * perdeu ponto.
   *
   * O aviso não é opcional: descobrir por acaso que a pontuação caiu é o pior
   * resultado possível (PR-7), e aqui a queda não vem de correção de placar —
   * vem de o resultado ter deixado de existir, que é mais estranho ainda.
   */
  private async reverterApuracao(
    matchId: string,
    externalId: string,
  ): Promise<number> {
    const apurados = await this.prisma.prediction.findMany({
      where: { matchId, scoredAt: { not: null } },
      select: { id: true, userId: true, poolId: true, points: true },
    });
    if (apurados.length === 0) return 0;

    const jogo = await this.prisma.match.findUnique({
      where: { id: matchId },
      select: { homeTeam: true, awayTeam: true },
    });

    await this.prisma.prediction.updateMany({
      where: { id: { in: apurados.map((p) => p.id) } },
      data: { points: null, band: null, scoredAt: null },
    });

    const avisos = apurados
      // Quem tinha zero não perdeu nada: avisar seria ruído.
      .filter((p) => (p.points ?? 0) > 0)
      .map((p) => ({
        userId: p.userId,
        poolId: p.poolId,
        kind: 'PONTO_REVERTIDO',
        title: 'Um jogo voltou a ficar sem resultado',
        body:
          `${jogo?.homeTeam} x ${jogo?.awayTeam}: a fonte oficial retirou o ` +
          `placar, então os ${p.points} ponto(s) deste jogo saíram do seu ` +
          'total. Quando o jogo acontecer, o palpite volta a valer.',
      }));

    if (avisos.length > 0) {
      await this.prisma.notification.createMany({ data: avisos });
    }

    this.log.warn(
      `jogo ${externalId} perdeu o placar na fonte — ${apurados.length} palpite(s) desapurado(s)`,
    );
    return apurados.length;
  }
}
