import {
  ConflictException,
  ForbiddenException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RankingService } from '../ranking/ranking.service';

export interface EncerramentoResponse {
  poolId: string;
  closedAt: Date;
  closedEarly: boolean;
  /** Mais de um quando o empate persiste após todos os critérios (CV-2). */
  champions: { userId: string; displayName: string; points: number }[];
  finalRanking: { position: number; displayName: string; points: number }[];
}

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

  constructor(
    private readonly prisma: PrismaService,
    private readonly ranking: RankingService,
  ) {}

  /**
   * Encerra o bolão quando a última rodada foi apurada.
   *
   * Só encerra se **não houver jogo pendente nem correção pendente** — anunciar
   * campeão e trocar depois é pior que anunciar um dia mais tarde.
   */
  async encerrarSePronto(
    poolId: string,
    userId: string,
  ): Promise<EncerramentoResponse> {
    await this.exigirParticipante(poolId, userId);
    const pool = await this.poolOuFalha(poolId);

    if (pool.closedAt)
      return this.resultado(poolId, userId, pool.closedAt, pool.closedEarly);

    const pendentes = await this.prisma.poolMatch.count({
      where: {
        poolId,
        OR: [
          { match: { status: { not: 'FINISHED' } } },
          { match: { homeScore: null } },
        ],
      },
    });
    if (pendentes > 0) {
      throw new ConflictException(
        `ainda há ${pendentes} jogo(s) sem resultado apurado — o bolão não encerra`,
      );
    }

    const closedAt = new Date();
    await this.prisma.pool.update({
      where: { id: poolId },
      data: { closedAt, closedEarly: false },
    });
    this.log.log(`bolão ${poolId} encerrado`);

    return this.resultado(poolId, userId, closedAt, false);
  }

  /**
   * Leitura do encerramento — campeão e ranking final.
   *
   * Existe porque, sem ela, o campeão só era visto **no instante** em que o
   * admin encerrava: depois disso a tela não era mais alcançável, e o
   * participante comum nunca a via. O critério de CV-1 é explícito — "todos os
   * participantes veem o ranking final e quem foi campeão".
   *
   * Nada aqui é congelado: o campeão é derivado na leitura, então correção de
   * placar posterior muda o que esta rota devolve. É a decisão de 03/08.
   */
  async lerEncerramento(
    poolId: string,
    userId: string,
  ): Promise<EncerramentoResponse> {
    await this.exigirParticipante(poolId, userId);
    const pool = await this.poolOuFalha(poolId);
    if (!pool.closedAt) {
      throw new ConflictException('este bolão ainda não foi encerrado');
    }
    return this.resultado(poolId, userId, pool.closedAt, pool.closedEarly);
  }

  /**
   * Encerramento antecipado pelo admin (CV-3). É a **única** saída para bolão
   * travado — jogo adiado sem data nunca encerra sozinho.
   */
  async encerrarAntecipadamente(
    poolId: string,
    userId: string,
  ): Promise<EncerramentoResponse> {
    await this.exigirAdmin(poolId, userId);
    const pool = await this.poolOuFalha(poolId);
    if (pool.closedAt)
      throw new ConflictException('este bolão já está encerrado');

    const closedAt = new Date();
    await this.prisma.$transaction([
      this.prisma.pool.update({
        where: { id: poolId },
        data: { closedAt, closedEarly: true },
      }),
      // Fecha o prazo dos jogos que ainda estavam abertos: nada de palpite novo
      // depois do encerramento.
      this.prisma.match.updateMany({
        where: {
          pools: { some: { poolId } },
          kickoffAt: { gt: closedAt },
        },
        data: { kickoffAt: closedAt },
      }),
    ]);
    this.log.warn(`bolão ${poolId} encerrado ANTECIPADAMENTE por ${userId}`);

    return this.resultado(poolId, userId, closedAt, true);
  }

  /** Arquiva o bolão encerrado. Reversível (CV-6). */
  async arquivar(poolId: string, userId: string) {
    await this.exigirAdmin(poolId, userId);
    const pool = await this.poolOuFalha(poolId);

    if (!pool.closedAt) {
      throw new ConflictException(
        'encerre o bolão antes de arquivar — bolão em andamento não é arquivável',
      );
    }
    await this.prisma.pool.update({
      where: { id: poolId },
      data: { archivedAt: new Date() },
    });
    return { archived: true };
  }

  async desarquivar(poolId: string, userId: string) {
    await this.exigirAdmin(poolId, userId);
    await this.poolOuFalha(poolId);
    await this.prisma.pool.update({
      where: { id: poolId },
      data: { archivedAt: null },
    });
    return { archived: false };
  }

  /**
   * Recria o bolão para a temporada seguinte (CV-7).
   *
   * Herda regras e participantes, **zera a pontuação** e deixa as regras
   * destraváveis até o primeiro palpite. Herdar palpite ou ponto por engano
   * corromperia a competição nova — é o critério mais importante desta story.
   */
  async recriar(
    poolId: string,
    userId: string,
    matchdayFrom: number,
    matchdayTo: number,
    nome?: string,
  ) {
    await this.exigirAdmin(poolId, userId);
    const anterior = await this.prisma.pool.findUnique({
      where: { id: poolId },
      select: {
        name: true,
        competitionCode: true,
        pointsExact: true,
        pointsWinnerDiff: true,
        pointsWinner: true,
        tiebreakOrder: true,
        closedAt: true,
        members: {
          where: { leftAt: null },
          select: { userId: true, role: true },
        },
      },
    });
    if (!anterior) throw new NotFoundException('bolão não encontrado');
    if (!anterior.closedAt) {
      throw new ConflictException('encerre a temporada atual antes de recriar');
    }

    return this.prisma.$transaction(async (tx) => {
      const novo = await tx.pool.create({
        data: {
          name: nome?.trim() || `${anterior.name} — nova temporada`,
          competitionCode: anterior.competitionCode,
          matchdayFrom,
          matchdayTo,
          // Herda as regras...
          pointsExact: anterior.pointsExact,
          pointsWinnerDiff: anterior.pointsWinnerDiff,
          pointsWinner: anterior.pointsWinner,
          tiebreakOrder: anterior.tiebreakOrder,
          // ...mas nasce destravado e sem pontuação: `rulesLockedAt` fica nulo e
          // nenhum palpite é copiado.
          rulesLockedAt: null,
          createdById: userId,
          recreatedFromId: poolId,
          // Reconvida todos automaticamente, conforme decidido em 03/08.
          members: {
            create: anterior.members.map((m) => ({
              userId: m.userId,
              role: m.userId === userId ? 'ADMIN' : m.role,
            })),
          },
        },
        select: { id: true, name: true, _count: { select: { members: true } } },
      });

      const jogos = await tx.match.findMany({
        where: {
          competitionCode: anterior.competitionCode,
          matchday: { gte: matchdayFrom, lte: matchdayTo },
        },
        select: { id: true },
      });
      if (jogos.length > 0) {
        await tx.poolMatch.createMany({
          data: jogos.map((j) => ({ poolId: novo.id, matchId: j.id })),
        });
      }

      // Avisa quem foi reconvidado. Ser incluído num bolão sem saber é o mesmo
      // problema de descobrir por acaso que perdeu ponto: a pessoa só descobre
      // quando o prazo da primeira rodada já passou, e aí perde a rodada
      // achando que o app falhou (CV-7 FE-3).
      const reconvidados = anterior.members
        .filter((m) => m.userId !== userId)
        .map((m) => ({
          userId: m.userId,
          poolId: novo.id,
          kind: 'BOLAO_RECRIADO',
          title: 'Você está num bolão de temporada nova',
          body:
            `"${novo.name}" começou, herdando a galera de "${anterior.name}". ` +
            'A pontuação de todos começa em zero. Confira o primeiro prazo para ' +
            'não perder a rodada de abertura.',
        }));
      if (reconvidados.length > 0) {
        await tx.notification.createMany({ data: reconvidados });
      }

      return {
        id: novo.id,
        name: novo.name,
        inheritedMembers: novo._count.members,
        totalMatches: jogos.length,
        recreatedFrom: poolId,
      };
    });
  }

  /** Histórico de bolões encerrados do participante (CV-5). */
  async historico(userId: string) {
    const participacoes = await this.prisma.poolMember.findMany({
      where: { userId, pool: { closedAt: { not: null } } },
      select: {
        leftAt: true,
        // Arquivar é ação de admin. Sem este campo o app não tem como esconder
        // o botão, e o participante comum descobre pelo 403.
        role: true,
        pool: {
          select: {
            id: true,
            name: true,
            closedAt: true,
            closedEarly: true,
            archivedAt: true,
          },
        },
      },
      orderBy: { pool: { closedAt: 'desc' } },
    });

    const saida = [];
    for (const p of participacoes) {
      if (p.leftAt) {
        // Abandonado: sem posição final, e isso precisa ficar explícito.
        saida.push({
          poolId: p.pool.id,
          name: p.pool.name,
          closedAt: p.pool.closedAt,
          closedEarly: p.pool.closedEarly,
          archived: p.pool.archivedAt !== null,
          outcome: 'ABANDONADO' as const,
          position: null,
          points: null,
          isAdmin: p.role === 'ADMIN',
        });
        continue;
      }

      const { rows } = await this.ranking.geral(p.pool.id, userId, 1000, 0);
      const minha = rows.find((r) => r.userId === userId);
      const campeoes = rows.filter((r) => r.position === 1);

      let outcome: 'CAMPEAO' | 'CO_CAMPEAO' | 'PARTICIPOU' = 'PARTICIPOU';
      if (minha?.position === 1) {
        outcome = campeoes.length > 1 ? 'CO_CAMPEAO' : 'CAMPEAO';
      }

      saida.push({
        poolId: p.pool.id,
        name: p.pool.name,
        closedAt: p.pool.closedAt,
        closedEarly: p.pool.closedEarly,
        archived: p.pool.archivedAt !== null,
        outcome,
        position: minha?.position ?? null,
        points: minha?.points ?? null,
        isAdmin: p.role === 'ADMIN',
      });
    }
    return saida;
  }

  /**
   * Campeão e ranking final. **Calculado na leitura, não congelado** — a decisão
   * de 03/08 é que correção de placar troca o campeão mesmo após o encerramento.
   */
  private async resultado(
    poolId: string,
    userId: string,
    closedAt: Date,
    closedEarly: boolean,
  ): Promise<EncerramentoResponse> {
    // Sem paginação: o ranking final cortado no teto de página perderia
    // participante da lista sem avisar ninguém.
    const rows = await this.ranking.classificacaoCompleta(poolId, userId);
    return {
      poolId,
      closedAt,
      closedEarly,
      // Campeão precisa ter **pontuado**. Sem o `points > 0`, um bolão em que
      // ninguém acertou nada declarava todo mundo co-campeão — pior que não ter
      // campeão, porque inventa um título de zero ponto para a galera inteira.
      champions: rows
        .filter((r) => r.position === 1 && r.points > 0)
        .map((r) => ({
          userId: r.userId,
          displayName: r.displayName,
          points: r.points,
        })),
      finalRanking: rows.map((r) => ({
        position: r.position,
        displayName: r.displayName,
        points: r.points,
      })),
    };
  }

  private async poolOuFalha(poolId: string) {
    const pool = await this.prisma.pool.findUnique({
      where: { id: poolId },
      select: { closedAt: true, closedEarly: true, archivedAt: true },
    });
    if (!pool) throw new NotFoundException('bolão não encontrado');
    return pool;
  }

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

  private async exigirAdmin(poolId: string, userId: string): Promise<void> {
    const m = await this.prisma.poolMember.findUnique({
      where: { poolId_userId: { poolId, userId } },
      select: { role: true, leftAt: true },
    });
    if (!m || m.leftAt || m.role !== 'ADMIN') {
      throw new ForbiddenException(
        'apenas o administrador do bolão pode fazer isso',
      );
    }
  }
}
