import { getFirestore, type Firestore } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { onDocumentWritten } from 'firebase-functions/v2/firestore';
import { avisarCorrecao } from './avisos';
import { classificar, type Agregado, type CriterioDesempate } from './regra/classificacao';
import { faixa, pontos, type TabelaDePontos } from './regra/pontuacao';

/**
 * Apura os palpites de um jogo e atualiza a classificação dos bolões afetados.
 *
 * Dispara na escrita do jogo — a mesma porta por onde entram a sincronização
 * (`MG-6`) e a correção manual do admin. Isso é de propósito: **placar corrigido
 * depois da apuração acontece**, e o recálculo tem de sair do mesmo caminho, não de
 * um fluxo paralelo que alguém esquece de manter.
 *
 * Duas garantias que vêm do backend antigo e não podem ser perdidas:
 *
 * - **A tabela de pontos vem do bolão**, não de constante. Dois bolões com o mesmo
 *   jogo pontuam diferente se assim foram configurados (`CC-2 BE-3`).
 * - **Apurar duas vezes não dobra ponto.** A apuração recalcula do zero a partir do
 *   placar, em vez de somar — então repetir é inofensivo, e é o que salva quando o
 *   gatilho roda duas vezes (o Firestore garante *at-least-once*, não *exactly-once*).
 */
export const apurarJogo = onDocumentWritten(
  { document: 'jogos/{matchId}', region: 'southamerica-east1' },
  async (evento) => {
    const depois = evento.data?.after.data();
    if (!depois) return;
    const antes = evento.data?.before.data();

    // **Só reage a mudança de resultado.** O gatilho dispara em toda escrita do
    // jogo, e a sincronização escreve `sourceUpdatedAt` a cada volta mesmo quando
    // nada mudou. Sem esta guarda, cada uma dessas escritas reapuraria o bolão
    // inteiro e regravaria a classificação de todos — leitura e escrita jogadas
    // fora, no serviço em que custo é cobrado por operação.
    //
    // É também a proteção de *at-least-once*: gatilho repetido com o mesmo
    // resultado não faz nada.
    const mesmoResultado =
      antes !== undefined &&
      antes.status === depois.status &&
      antes.homeScore === depois.homeScore &&
      antes.awayScore === depois.awayScore;
    if (mesmoResultado) return;

    const matchId = evento.params.matchId;
    const db = getFirestore();

    const terminado =
      depois.status === 'FINISHED' &&
      depois.homeScore !== null &&
      depois.homeScore !== undefined &&
      depois.awayScore !== null &&
      depois.awayScore !== undefined;

    // Jogo que **deixou** de ter placar (adiado depois de apurado) precisa
    // desapurar: manter o ponto favoreceria quem já sabia o resultado antigo. É o
    // defeito `PP-6-07`, corrigido no backend antigo em 06/08.
    const palpites = await db
      .collectionGroup('palpites')
      .where('matchId', '==', matchId)
      .get();
    if (palpites.empty) return;

    const porBolao = new Map<string, typeof palpites.docs>();
    for (const doc of palpites.docs) {
      // `boloes/{poolId}/palpites/{id}` — o pai do pai é o bolão.
      const poolId = doc.ref.parent.parent!.id;
      porBolao.set(poolId, [...(porBolao.get(poolId) ?? []), doc]);
    }

    // **Correção** é placar que muda depois de já ter existido. Só nesse caso há
    // quem avisar: na primeira apuração ninguém perdeu ponto que já tinha
    // (`PR-6`/`PR-7`). A comparação tem de ser feita **antes** de recalcular.
    const ehCorrecao =
      antes !== undefined &&
      antes.homeScore != null &&
      (antes.homeScore !== depois.homeScore ||
        antes.awayScore !== depois.awayScore);

    let apurados = 0;
    for (const [poolId, docs] of porBolao) {
      const bolao = (await db.doc(`boloes/${poolId}`).get()).data();
      if (!bolao) continue;

      const posicoesAntes = ehCorrecao
        ? await posicoes(db, poolId)
        : new Map<string, Posicao>();

      const tabela: TabelaDePontos = {
        pointsExact: bolao.pointsExact,
        pointsWinnerDiff: bolao.pointsWinnerDiff,
        pointsWinner: bolao.pointsWinner,
      };

      // Lote em vez de transação: bolão grande passa do limite de 500 operações
      // por transação, e a apuração escreve palpite + classificação de todo mundo.
      const lote = db.bulkWriter();
      for (const doc of docs) {
        const p = doc.data();
        if (!terminado) {
          lote.update(doc.ref, { points: null, band: null, scoredAt: null });
          continue;
        }
        const banda = faixa(
          p.homeScore,
          p.awayScore,
          depois.homeScore,
          depois.awayScore,
        );
        lote.update(doc.ref, {
          points: pontos(banda, tabela),
          band: banda,
          scoredAt: new Date(),
        });
        apurados++;
      }
      await lote.close();

      await recalcularClassificacao(poolId);

      if (ehCorrecao) {
        await avisarAfetados(db, poolId, posicoesAntes, {
          jogo: `${depois.homeTeam} x ${depois.awayTeam}`,
          placarDe:
            antes!.homeScore == null
              ? 'sem placar'
              : `${antes!.homeScore}x${antes!.awayScore}`,
          placarPara: `${depois.homeScore}x${depois.awayScore}`,
          manual: depois.lastCorrectionSource === 'MANUAL',
        });
      }
    }

    logger.info(
      `jogo ${matchId}: ${apurados} palpite(s) apurado(s) em ${porBolao.size} bolão(ões)` +
        (terminado ? '' : ' — placar retirado, apuração desfeita'),
    );
  },
);

interface Posicao {
  points: number;
  position: number;
}

async function posicoes(
  db: Firestore,
  poolId: string,
): Promise<Map<string, Posicao>> {
  const docs = await db.collection(`boloes/${poolId}/classificacao`).get();
  return new Map(
    docs.docs.map((d) => [
      d.id,
      { points: d.data().points as number, position: d.data().position as number },
    ]),
  );
}

/**
 * Avisa quem **de fato** mudou de ponto ou de posição.
 *
 * Quem não mudou nada não recebe nada: aviso de correção que não mexeu com a
 * pessoa é ruído, e ruído é o que faz desligar a notificação inteira.
 */
async function avisarAfetados(
  db: Firestore,
  poolId: string,
  antes: Map<string, Posicao>,
  jogo: { jogo: string; placarDe: string; placarPara: string; manual: boolean },
): Promise<void> {
  const depois = await posicoes(db, poolId);

  for (const [uid, agora] of depois) {
    const antigo = antes.get(uid);
    if (!antigo) continue;
    if (antigo.points === agora.points && antigo.position === agora.position) {
      continue;
    }
    await avisarCorrecao(db, uid, {
      poolId,
      manual: jogo.manual,
      jogo: {
        jogo: jogo.jogo,
        placarDe: jogo.placarDe,
        placarPara: jogo.placarPara,
      },
      posicaoDe: antigo.position !== agora.position ? antigo.position : null,
      posicaoPara: antigo.position !== agora.position ? agora.position : null,
    });
  }
}

/**
 * Recalcula a classificação materializada do bolão inteiro.
 *
 * Recalcula tudo em vez de aplicar deltas. É mais escrita, e é o que mantém o
 * agregado **derivável**: correção de placar, saída de participante e recriação de
 * temporada mexem na base, e delta acumulado erra silenciosamente até alguém
 * comparar com o extrato. Aqui, o pior caso é gastar escrita.
 */
export async function recalcularClassificacao(poolId: string): Promise<void> {
  const db = getFirestore();

  const [bolao, membros, palpites] = await Promise.all([
    db.doc(`boloes/${poolId}`).get(),
    db.collection(`boloes/${poolId}/membros`).where('leftAt', '==', null).get(),
    db.collection(`boloes/${poolId}/palpites`).get(),
  ]);
  if (!bolao.exists) return;

  const soma = new Map<string, Agregado>();
  for (const m of membros.docs) {
    const dados = m.data();
    soma.set(dados.userId, {
      userId: dados.userId,
      displayName: dados.displayName,
      points: 0,
      exactScores: 0,
      winners: 0,
    });
  }

  for (const doc of palpites.docs) {
    const p = doc.data();
    const agregado = soma.get(p.userId);
    // Quem saiu não entra no ranking ativo, mas o palpite dele **permanece** no
    // bolão: o que se encerra é o vínculo, não o passado (CV-4).
    if (!agregado || p.points === null || p.points === undefined) continue;
    agregado.points += p.points;
    if (p.band === 'PLACAR_EXATO') agregado.exactScores++;
    // "Acerto de vencedor" é qualquer faixa que pontuou — inclui o placar exato.
    if (p.band && p.band !== 'ERROU') agregado.winners++;
  }

  const ordem = (bolao.data()?.tiebreakOrder ?? []) as CriterioDesempate[];
  const linhas = classificar([...soma.values()], ordem);

  const lote = db.bulkWriter();
  for (const linha of linhas) {
    lote.set(db.doc(`boloes/${poolId}/classificacao/${linha.userId}`), {
      ...linha,
      updatedAt: new Date(),
    });
  }

  // **Linha de quem saiu tem de sumir.** Recalcular só reescrevendo deixava o
  // agregado com sobra: no Postgres o ranking era somado na leitura a partir de
  // quem estava ativo, então sair tirava a pessoa na hora. Aqui ele é
  // materializado, e o que não é reescrito **fica** — o participante continuava
  // vendo no ranking alguém que já tinha saído do bolão (`CV-4-01`, encontrado
  // em 08/08).
  const atuais = await db.collection(`boloes/${poolId}/classificacao`).get();
  const ativos = new Set(linhas.map((l) => l.userId));
  for (const doc of atuais.docs) {
    if (!ativos.has(doc.id)) lote.delete(doc.ref);
  }
  await lote.close();
}
