import {
  ForbiddenException,
  GoneException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
  JogoDaRodadaResponse,
  MotivoRecusa,
  PalpitarDto,
  PalpitarResponse,
  ResultadoItemResponse,
  RodadaResponse,
} from './dto/palpite.dto';

@Injectable()
export class PredictionsService {
  constructor(private readonly prisma: PrismaService) {}

  /**
   * Jogos da rodada com meu palpite e o estado do prazo.
   *
   * `serverTime` vai na resposta porque o app **não pode** decidir prazo pelo
   * relógio do device — o usuário pode alterá-lo. O app calcula a contagem a
   * partir do offset entre este valor e o relógio local (PP-3).
   */
  async rodada(
    poolId: string,
    matchday: number,
    userId: string,
  ): Promise<RodadaResponse> {
    await this.exigirParticipante(poolId, userId);
    const agora = new Date();

    const vinculos = await this.prisma.poolMatch.findMany({
      where: { poolId, match: { matchday } },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: {
        match: {
          select: {
            id: true,
            matchday: true,
            homeTeam: true,
            awayTeam: true,
            kickoffAt: true,
            homeScore: true,
            awayScore: true,
          },
        },
      },
    });

    const meus = await this.prisma.prediction.findMany({
      where: { poolId, userId, match: { matchday } },
      select: { matchId: true, homeScore: true, awayScore: true },
    });
    const porJogo = new Map(meus.map((p) => [p.matchId, p]));

    const matches: JogoDaRodadaResponse[] = vinculos.map(({ match }) => {
      const meu = porJogo.get(match.id);
      return {
        matchId: match.id,
        matchday: match.matchday,
        homeTeam: match.homeTeam,
        awayTeam: match.awayTeam,
        kickoffAt: match.kickoffAt,
        // Fechamento por jogo: cada um tem seu prazo (PP-5 BE-2).
        deadlineOpen: match.kickoffAt > agora,
        myPrediction: meu
          ? { homeScore: meu.homeScore, awayScore: meu.awayScore }
          : null,
        officialScore:
          match.homeScore !== null && match.awayScore !== null
            ? { homeScore: match.homeScore, awayScore: match.awayScore }
            : null,
      };
    });

    return { serverTime: agora, matches };
  }

  /**
   * Registra ou substitui palpites. Item a item: um jogo fechado não derruba os
   * outros do lote.
   */
  async palpitar(
    poolId: string,
    userId: string,
    dto: PalpitarDto,
  ): Promise<PalpitarResponse> {
    await this.exigirParticipante(poolId, userId);
    await this.exigirBolaoAberto(poolId);

    const ids = dto.predictions.map((p) => p.matchId);

    // Só jogos que pertencem a ESTE bolão. Palpitar em jogo de fora seria
    // aceitar dado que não existe na competição do participante.
    const doBolao = await this.prisma.poolMatch.findMany({
      where: { poolId, matchId: { in: ids } },
      select: { matchId: true, match: { select: { kickoffAt: true } } },
    });
    const prazoPorJogo = new Map(
      doBolao.map((v) => [v.matchId, v.match.kickoffAt]),
    );

    const results: ResultadoItemResponse[] = [];

    for (const item of dto.predictions) {
      const kickoff = prazoPorJogo.get(item.matchId);
      let motivo: MotivoRecusa | null = null;

      if (!kickoff) {
        motivo = 'JOGO_FORA_DO_BOLAO';
      } else if (kickoff <= new Date()) {
        // Horário do servidor, sempre. Admin não tem exceção (PP-5 BE-3).
        motivo = 'PRAZO_FECHADO';
      }

      if (motivo) {
        results.push({ matchId: item.matchId, saved: false, reason: motivo });
        continue;
      }

      await this.gravarPalpite(
        poolId,
        userId,
        item.matchId,
        item.homeScore,
        item.awayScore,
      );
      results.push({ matchId: item.matchId, saved: true, reason: null });
    }

    const savedCount = results.filter((r) => r.saved).length;
    return { results, savedCount, rejectedCount: results.length - savedCount };
  }

  /**
   * Um palpite, sua revisão e o travamento das regras entram na mesma
   * transação. O travamento é de CC-3: o primeiro palpite do bolão congela
   * pontuação e desempate, e é o que torna justa a participação do admin.
   */
  private gravarPalpite(
    poolId: string,
    userId: string,
    matchId: string,
    homeScore: number,
    awayScore: number,
  ): Promise<void> {
    return this.prisma.$transaction(async (tx) => {
      const anterior = await tx.prediction.findUnique({
        where: { poolId_matchId_userId: { poolId, matchId, userId } },
        select: { homeScore: true, awayScore: true },
      });

      const palpite = await tx.prediction.upsert({
        where: { poolId_matchId_userId: { poolId, matchId, userId } },
        create: { poolId, matchId, userId, homeScore, awayScore },
        update: { homeScore, awayScore },
        select: { id: true },
      });

      // Mudança de valor vira revisão: é o histórico que resolve disputa de
      // prazo. Reenvio do **mesmo** placar não gera revisão nova — a fila
      // offline de PP-4 repete envios, e inflar este histórico com cópias
      // idênticas destruiria justamente a leitura que ele existe para permitir.
      const mudou =
        !anterior ||
        anterior.homeScore !== homeScore ||
        anterior.awayScore !== awayScore;
      if (mudou) {
        await tx.predictionRevision.create({
          data: { predictionId: palpite.id, homeScore, awayScore },
        });
      }

      // `updateMany` com `rulesLockedAt: null` é idempotente e não sobrescreve
      // o momento do travamento original em palpites seguintes (CC-3 BE-3).
      await tx.pool.updateMany({
        where: { id: poolId, rulesLockedAt: null },
        data: { rulesLockedAt: new Date() },
      });
    });
  }

  /**
   * Palpites de **todos** num jogo — liberado só quando o jogo terminou.
   *
   * O filtro é na origem: se o servidor devolvesse e a tela escondesse, o
   * critério de PP-7 não estaria cumprido, porque qualquer um lê a API direto.
   * Vale para todo papel, admin incluído — é o que torna justa a participação
   * do admin no próprio bolão.
   */
  async palpitesDoJogo(poolId: string, matchId: string, userId: string) {
    await this.exigirParticipante(poolId, userId);

    const jogo = await this.prisma.match.findUnique({
      where: { id: matchId },
      select: { status: true },
    });
    if (!jogo) throw new NotFoundException('jogo não encontrado');

    // Libera no FIM do jogo, não no apito: decisão de 03/08.
    if (jogo.status !== 'FINISHED') {
      return { revealed: false as const, predictions: [] };
    }

    const palpites = await this.prisma.prediction.findMany({
      where: { poolId, matchId },
      orderBy: { user: { displayName: 'asc' } },
      select: {
        homeScore: true,
        awayScore: true,
        points: true,
        user: { select: { id: true, displayName: true } },
      },
    });

    return { revealed: true as const, predictions: palpites };
  }

  /** Palpites do próprio participante na rodada (PP-1 BE-4). */
  async meusPalpites(poolId: string, matchday: number, userId: string) {
    await this.exigirParticipante(poolId, userId);
    return this.prisma.prediction.findMany({
      where: { poolId, userId, match: { matchday } },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: {
        matchId: true,
        homeScore: true,
        awayScore: true,
        updatedAt: true,
        match: { select: { homeTeam: true, awayTeam: true, kickoffAt: true } },
      },
    });
  }

  private async exigirParticipante(
    poolId: string,
    userId: string,
  ): Promise<void> {
    const membro = await this.prisma.poolMember.findUnique({
      where: { poolId_userId: { poolId, userId } },
      select: { leftAt: true },
    });
    if (!membro || membro.leftAt) {
      throw new ForbiddenException('você não participa deste bolão');
    }
  }

  private async exigirBolaoAberto(poolId: string): Promise<void> {
    const pool = await this.prisma.pool.findUnique({
      where: { id: poolId },
      select: { closedAt: true },
    });
    if (pool?.closedAt) {
      throw new GoneException('este bolão já foi encerrado');
    }
  }
}
