import { ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CRITERIOS_DESEMPATE } from '../pools/dto/regras.dto';
import type { CriterioDesempate } from '../pools/dto/regras.dto';
import { paginacao } from '../common/paginacao';

/** Ordem usada quando o bolão não define uma válida. */
const ORDEM_PADRAO: CriterioDesempate[] = [...CRITERIOS_DESEMPATE];

/**
 * Comparadores por critério, em "quem vem primeiro". Manter num mapa é o que
 * permite aplicar a ordem **configurada** em vez de uma sequência fixa.
 */
const COMPARADOR: Record<
  CriterioDesempate,
  (a: Agregado, b: Agregado) => number
> = {
  EXACT_SCORES: (a, b) => b.exactScores - a.exactScores,
  WINNERS: (a, b) => b.winners - a.winners,
};

/** Critérios de desempate, na ordem em que são aplicados. */
export type CriterioAplicado =
  'PONTOS' | 'EXACT_SCORES' | 'WINNERS' | 'EMPATE_PERSISTE';

/** Números de um participante, antes de ordenar. */
export interface Agregado {
  userId: string;
  displayName: string;
  points: number;
  exactScores: number;
  winners: number;
}

export interface LinhaRanking {
  userId: string;
  displayName: string;
  position: number;
  points: number;
  exactScores: number;
  winners: number;
  /** Critério que decidiu a posição em relação a quem está imediatamente acima. */
  decidedBy: CriterioAplicado;
  /** True quando divide a posição com outro participante (co-campeões). */
  sharedPosition: boolean;
}

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

  async geral(poolId: string, userId: string, limit = 50, offset = 0) {
    ({ limit, offset } = paginacao(limit, offset));
    const linhas = await this.classificacaoCompleta(poolId, userId);
    return {
      total: linhas.length,
      rows: linhas.slice(offset, offset + limit),
    };
  }

  /**
   * Classificação inteira, **sem paginação**.
   *
   * Existe para o encerramento (CV-1): o ranking final não pode ser cortado no
   * teto de página, ou o bolão de empresa perderia participante da lista final —
   * silenciosamente, que é o pior jeito de perder. Não é rota HTTP: uso interno.
   */
  async classificacaoCompleta(poolId: string, userId: string) {
    await this.exigirParticipante(poolId, userId);
    return RankingService.ordenar(
      await this.agregar(poolId),
      await this.ordemDesempate(poolId),
    );
  }

  /**
   * Ranking da rodada. Traz se a rodada está parcialmente apurada — sem isso o
   * participante lê uma classificação incompleta como se fosse final (PR-4).
   */
  async daRodada(
    poolId: string,
    matchday: number,
    userId: string,
    limit = 50,
    offset = 0,
  ) {
    await this.exigirParticipante(poolId, userId);
    ({ limit, offset } = paginacao(limit, offset));

    const [totalJogos, apurados] = await Promise.all([
      this.prisma.poolMatch.count({ where: { poolId, match: { matchday } } }),
      this.prisma.poolMatch.count({
        where: {
          poolId,
          match: { matchday, status: 'FINISHED', homeScore: { not: null } },
        },
      }),
    ]);

    const linhas = RankingService.ordenar(
      await this.agregar(poolId, matchday),
      await this.ordemDesempate(poolId),
    );
    return {
      matchday,
      totalMatches: totalJogos,
      scoredMatches: apurados,
      partial: apurados < totalJogos,
      total: linhas.length,
      rows: linhas.slice(offset, offset + limit),
    };
  }

  /**
   * Agrega pontos e contadores de desempate em **uma** consulta.
   *
   * Um `findMany` por participante seria N+1 — com 50 pessoas no bolão isso são
   * 50 idas ao banco por abertura de tela de ranking (PR-3 BE-4).
   */
  private async agregar(
    poolId: string,
    matchday?: number,
  ): Promise<Agregado[]> {
    const membros = await this.prisma.poolMember.findMany({
      // Quem saiu fica fora do ranking ativo, mas o histórico dele permanece.
      where: { poolId, leftAt: null },
      select: { userId: true, user: { select: { displayName: true } } },
    });

    const grupos = await this.prisma.prediction.groupBy({
      by: ['userId', 'band'],
      where: {
        poolId,
        band: { not: null },
        ...(matchday !== undefined ? { match: { matchday } } : {}),
      },
      _sum: { points: true },
      _count: { _all: true },
    });

    const porUsuario = new Map<
      string,
      { points: number; exact: number; winners: number }
    >();
    for (const g of grupos) {
      const atual = porUsuario.get(g.userId) ?? {
        points: 0,
        exact: 0,
        winners: 0,
      };
      atual.points += g._sum.points ?? 0;
      if (g.band === 'PLACAR_EXATO') atual.exact += g._count._all;
      // "Acerto de vencedor" é qualquer faixa que pontuou — inclui o placar exato.
      if (g.band !== 'ERROU') atual.winners += g._count._all;
      porUsuario.set(g.userId, atual);
    }

    return membros.map((m) => {
      const a = porUsuario.get(m.userId) ?? { points: 0, exact: 0, winners: 0 };
      return {
        userId: m.userId,
        displayName: m.user.displayName,
        points: a.points,
        exactScores: a.exact,
        winners: a.winners,
      };
    });
  }

  /**
   * Ordem de desempate **daquele bolão**.
   *
   * Existe porque `tiebreakOrder` era persistido, editável e herdado na
   * recriação — e o cálculo ignorava, usando exatos → vencedores fixo no código.
   * Bolão configurado com a ordem invertida classificava errado em silêncio.
   */
  private async ordemDesempate(poolId: string): Promise<CriterioDesempate[]> {
    const pool = await this.prisma.pool.findUnique({
      where: { id: poolId },
      select: { tiebreakOrder: true },
    });
    return (pool?.tiebreakOrder as CriterioDesempate[] | undefined) ??
      ORDEM_PADRAO;
  }

  /**
   * Ordena e atribui posição, aplicando os critérios **na ordem do bolão**.
   *
   * Empate que resiste a todos os critérios **divide a posição** — e a posição
   * seguinte pula pela quantidade de empatados. Errar isso produz duas pessoas
   * em "2º" e ninguém em "3º".
   */
  static ordenar(
    agregados: Agregado[],
    ordem: CriterioDesempate[] = ORDEM_PADRAO,
  ): LinhaRanking[] {
    // Critérios aplicados na ordem configurada. Vazio ou desconhecido cai no
    // padrão: bolão sem ordem válida não pode ficar sem desempate nenhum.
    const criterios = ordem.filter((c) => c in COMPARADOR);
    const usados = criterios.length > 0 ? criterios : ORDEM_PADRAO;

    const ordenado = [...agregados].sort((a, b) => {
      if (b.points !== a.points) return b.points - a.points;
      for (const criterio of usados) {
        const diff = COMPARADOR[criterio](a, b);
        if (diff !== 0) return diff;
      }
      return a.displayName.localeCompare(b.displayName);
    });

    const empatadoCom = (a: Agregado, b: Agregado) =>
      a.points === b.points &&
      usados.every((criterio) => COMPARADOR[criterio](a, b) === 0);

    const linhas: LinhaRanking[] = [];
    let posicao = 0;

    ordenado.forEach((atual, i) => {
      const anterior = i > 0 ? ordenado[i - 1] : null;
      const dividindo = anterior !== null && empatadoCom(anterior, atual);

      // Posição só avança quando não está empatado com o anterior. O índice+1
      // faz a próxima posição pular pela quantidade de empatados.
      if (!dividindo) posicao = i + 1;

      // O critério é olhado para os dois lados. Comparar só com o anterior
      // deixava justamente quem está **na frente** do empate sem explicação:
      // Ana lidera por placares exatos e via "PONTOS", enquanto Bruno via o
      // desempate. É ela quem é acusada de estar na frente sem motivo.
      const proximo = i + 1 < ordenado.length ? ordenado[i + 1] : null;
      const vizinho =
        anterior && anterior.points === atual.points
          ? anterior
          : proximo && proximo.points === atual.points
            ? proximo
            : anterior;

      let decidedBy: CriterioAplicado = 'PONTOS';
      if (vizinho) {
        if (vizinho.points !== atual.points) {
          decidedBy = 'PONTOS';
        } else {
          // O critério exibido tem que ser o **primeiro da ordem do bolão** que
          // separou os dois. Com a ordem invertida, dizer "placares exatos"
          // seria explicar a classificação pela regra errada.
          const decisivo = usados.find(
            (criterio) => COMPARADOR[criterio](atual, vizinho) !== 0,
          );
          decidedBy = decisivo ?? 'EMPATE_PERSISTE';
        }
      }

      linhas.push({
        ...atual,
        position: posicao,
        decidedBy,
        sharedPosition: false,
      });
    });

    // Marca posição compartilhada nos dois lados do empate.
    const contagem = new Map<number, number>();
    linhas.forEach((l) =>
      contagem.set(l.position, (contagem.get(l.position) ?? 0) + 1),
    );
    linhas.forEach((l) => {
      l.sharedPosition = (contagem.get(l.position) ?? 0) > 1;
    });

    return linhas;
  }

  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');
    }
  }
}
