import { initializeApp } from 'firebase-admin/app';
import { getFirestore, type Firestore } from 'firebase-admin/firestore';
import { encerrarAntes } from './ciclo-vida';
import { corrigir } from './correcao';
import { extrato } from './extrato';
import { classificar, campeoes } from './regra/classificacao';

/**
 * `MG-8` — paridade de **regra**, com os IDs de origem.
 *
 * Este arquivo fecha as duas stories que o mapa de cobertura acusou em **zero**:
 * `CV-2` (co-campeões) e `PR-6` (recálculo por placar corrigido), mais o que
 * `PR-3` tem de regra. Elas não eram difíceis — eram as que ninguém tinha olhado,
 * que é exatamente o que o mapa existe para revelar.
 *
 * Onde a regra é pura, o caso roda sem emulador: pontuação e desempate são
 * função, e função se testa sem banco. Onde a regra depende do gatilho — correção
 * que reapura —, o caso roda contra os emuladores e **espera pelo efeito**.
 */

process.env.FIRESTORE_EMULATOR_HOST ??= '127.0.0.1:8080';
jest.setTimeout(90_000);

const app = initializeApp({ projectId: 'demo-bolao' }, `regras-${Date.now()}`);
const db = getFirestore(app);

const HORA = 3600 * 1000;
const POOL = `p-regras-${Date.now()}`;
const JOGO = `j-regras-${Date.now()}`;
const OUTRO_JOGO = `j-regras2-${Date.now()}`;

const agregado = (
  userId: string,
  points: number,
  exactScores: number,
  winners: number,
) => ({ userId, displayName: userId, points, exactScores, winners });

async function esperar(cond: () => Promise<boolean>, limiteMs = 25000) {
  const fim = Date.now() + limiteMs;
  while (Date.now() < fim) {
    if (await cond()) return true;
    await new Promise((r) => setTimeout(r, 400));
  }
  return false;
}

describe('CV-2 — co-campeões', () => {
  it('CV-2-01 dois empatados em tudo são co-campeões', () => {
    const linhas = classificar([
      agregado('ana', 40, 3, 8),
      agregado('bruno', 40, 3, 8),
      agregado('carla', 30, 2, 6),
    ]);

    const vencedores = campeoes(linhas);
    expect(vencedores.map((l) => l.userId).sort()).toEqual(['ana', 'bruno']);
    expect(vencedores.every((l) => l.position === 1)).toBe(true);
    expect(vencedores.every((l) => l.sharedPosition)).toBe(true);
    // O critério exibido tem de dizer que o empate **resistiu** a tudo: sem isso
    // a tela sugere que houve um desempate que não houve.
    expect(vencedores.every((l) => l.decidedBy === 'EMPATE_PERSISTE')).toBe(true);
  });

  it('CV-2-02 três ou mais empatados são todos campeões', () => {
    const linhas = classificar([
      agregado('ana', 40, 3, 8),
      agregado('bruno', 40, 3, 8),
      agregado('carla', 40, 3, 8),
      agregado('davi', 10, 0, 2),
    ]);
    expect(campeoes(linhas)).toHaveLength(3);
  });

  it('CV-2-03 a posição seguinte ao empate é ajustada', () => {
    const linhas = classificar([
      agregado('ana', 40, 3, 8),
      agregado('bruno', 40, 3, 8),
      agregado('carla', 30, 2, 6),
    ]);
    // 1º, 1º, 3º. Quem ficou atrás de dois empatados está em terceiro — dizer
    // "2º" seria inventar uma posição que ninguém ocupou.
    expect(linhas.map((l) => l.position)).toEqual([1, 1, 3]);
  });

  it('CV-2-04 empate resolvido por critério dá campeão único', () => {
    const linhas = classificar([
      agregado('ana', 40, 5, 8),
      agregado('bruno', 40, 2, 9),
    ]);
    const vencedores = campeoes(linhas);
    expect(vencedores).toHaveLength(1);
    expect(vencedores[0]!.userId).toBe('ana');
    // E o critério que decidiu aparece **para os dois lados** — quem está na
    // frente é justamente quem é acusado de estar lá sem motivo.
    expect(linhas.every((l) => l.decidedBy === 'EXACT_SCORES')).toBe(true);
  });

  it('CV-2-07 ninguém pontuou: não há campeão', () => {
    const linhas = classificar([agregado('ana', 0, 0, 0), agregado('bruno', 0, 0, 0)]);
    // Declarar todo mundo co-campeão de zero ponto é pior que não ter campeão:
    // inventa um título para a galera inteira.
    expect(campeoes(linhas)).toEqual([]);
    expect(linhas.every((l) => l.sharedPosition)).toBe(true);
  });

  it('CV-2-05/CV-2-06 correção desfaz o empate, antes e depois de encerrar', async () => {
    await montarBolao();

    // Ana e Bruno empatados em 5 pontos cada: os dois acertaram vencedor e saldo.
    await palpitar('ana', JOGO, 3, 2);
    await palpitar('bruno', JOGO, 4, 3);
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
    });

    const empataram = await esperar(async () => {
      const c = await db.collection(`boloes/${POOL}/classificacao`).get();
      return c.size === 2 && c.docs.every((d) => d.data().points === 5);
    });
    expect(empataram).toBe(true);

    // CV-2-05: correção **antes** do encerramento — 3x2 vira placar exato da Ana.
    await corrigir('ana', POOL, JOGO, 3, 2, db);
    const desempatou = await esperar(
      async () =>
        (await db.doc(`boloes/${POOL}/classificacao/ana`).get()).data()?.points === 10,
    );
    expect(desempatou).toBe(true);

    await encerrarAntes('ana', POOL, db);

    // CV-2-06: e **depois** do encerramento a correção continua valendo. O campeão
    // é derivado na leitura, nunca congelado — foi a decisão de 03/08.
    await corrigir('ana', POOL, JOGO, 4, 3, db);
    const trocou = await esperar(async () => {
      const bruno = (await db.doc(`boloes/${POOL}/classificacao/bruno`).get()).data();
      const ana = (await db.doc(`boloes/${POOL}/classificacao/ana`).get()).data();
      return bruno?.points === 10 && ana?.points === 5;
    });
    expect(trocou).toBe(true);
  });
});

describe('PR-3 — ranking geral (o que é regra)', () => {
  it('PR-3-01 ordena pela soma dos pontos, com posição', () => {
    const linhas = classificar([
      agregado('carla', 12, 1, 3),
      agregado('ana', 40, 3, 8),
      agregado('bruno', 25, 2, 5),
    ]);
    expect(linhas.map((l) => l.userId)).toEqual(['ana', 'bruno', 'carla']);
    expect(linhas.map((l) => l.position)).toEqual([1, 2, 3]);
  });

  it('PR-3-03 empate compartilha posição e ajusta a seguinte', () => {
    const linhas = classificar([
      agregado('ana', 40, 3, 8),
      agregado('bruno', 40, 3, 8),
      agregado('carla', 12, 1, 3),
    ]);
    expect(linhas.map((l) => l.position)).toEqual([1, 1, 3]);
    expect(linhas[2]!.sharedPosition).toBe(false);
  });

  it('PR-3-05 quem não palpitou aparece com zero, não some', async () => {
    await montarBolao();
    await palpitar('ana', JOGO, 2, 1);
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
    });

    const apareceu = await esperar(async () => {
      const bruno = await db.doc(`boloes/${POOL}/classificacao/bruno`).get();
      return bruno.exists && bruno.data()!.points === 0;
    });
    // Sumir com quem não palpitou faria a lista parecer menor do que o bolão é —
    // e quem não palpitou é justamente quem precisa se ver lá embaixo.
    expect(apareceu).toBe(true);
  });

  it('PR-3-08 quem saiu fica fora do ranking ativo', async () => {
    await montarBolao();
    await palpitar('ana', JOGO, 2, 1);
    await db.doc(`boloes/${POOL}/membros/bruno`).update({ leftAt: new Date() });
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
    });

    const saiu = await esperar(async () => {
      const c = await db.collection(`boloes/${POOL}/classificacao`).get();
      return c.docs.every((d) => d.id !== 'bruno');
    });
    expect(saiu).toBe(true);
  });
});

describe('PR-6 — recálculo por placar corrigido', () => {
  beforeEach(montarBolao);

  it('PR-6-01 a correção recalcula todos os palpites do jogo', async () => {
    await palpitar('ana', JOGO, 2, 1);
    await palpitar('bruno', JOGO, 1, 0);
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
    });
    await esperar(async () => (await pontos('ana')) === 10);

    await corrigir('ana', POOL, JOGO, 1, 0, db);

    // Os dois mudam: quem estava certo passa a errar a faixa, quem errava acerta.
    const recalculou = await esperar(
      async () => (await pontos('ana')) === 5 && (await pontos('bruno')) === 10,
    );
    expect(recalculou).toBe(true);
  });

  it('PR-6-02 correção que não muda ponto de ninguém não mexe na classificação', async () => {
    await palpitar('ana', JOGO, 0, 3);
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
    });
    await esperar(async () => (await pontos('ana')) === 0);
    const antes = (await db.doc(`boloes/${POOL}/classificacao/ana`).get()).data();

    // 3x1 continua sendo vitória da casa: o palpite 0x3 erra igual.
    await corrigir('ana', POOL, JOGO, 3, 1, db);
    await new Promise((r) => setTimeout(r, 4000));

    const depois = (await db.doc(`boloes/${POOL}/classificacao/ana`).get()).data();
    expect(depois!.points).toBe(antes!.points);
    expect(depois!.position).toBe(antes!.position);
  });

  it('PR-6-03 correção que altera o desempate', async () => {
    // Empate em pontos, decidido por placares exatos. A correção tira o exato de
    // um e o dá ao outro — a posição vira sem nenhum ponto mudar.
    await palpitar('ana', JOGO, 2, 1);
    await palpitar('bruno', JOGO, 3, 2);
    await palpitar('ana', OUTRO_JOGO, 1, 0);
    await palpitar('bruno', OUTRO_JOGO, 1, 0);
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
    });
    await db.doc(`jogos/${OUTRO_JOGO}`).update({
      status: 'FINISHED',
      homeScore: 1,
      awayScore: 0,
    });

    const primeiraApuracao = await esperar(
      async () => (await pontos('ana')) === 20 && (await pontos('bruno')) === 15,
    );
    expect(primeiraApuracao).toBe(true);
    expect(
      (await db.doc(`boloes/${POOL}/classificacao/ana`).get()).data()!.position,
    ).toBe(1);

    await corrigir('ana', POOL, JOGO, 3, 2, db);

    const virou = await esperar(async () => {
      const bruno = (await db.doc(`boloes/${POOL}/classificacao/bruno`).get()).data();
      return bruno?.position === 1 && bruno?.points === 20;
    });
    expect(virou).toBe(true);
  });

  it('PR-6-04/PR-6-05 o extrato mostra o placar anterior e diz quem corrigiu', async () => {
    await palpitar('ana', JOGO, 2, 1);
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
    });
    await esperar(async () => (await pontos('ana')) === 10);

    await corrigir('ana', POOL, JOGO, 1, 0, db);
    const e = await extrato('ana', POOL, 20, db);
    const linha = e.rows.find((l) => l.matchId === JOGO)!;

    // Ponto que cai sem explicação é indistinguível de fraude (`PR-6`).
    expect(linha.correction).toMatchObject({
      previousHome: 2,
      previousAway: 1,
      manual: true,
    });
  });

  it('PR-6-06 jogo sem correção não ganha marca', async () => {
    await palpitar('ana', OUTRO_JOGO, 1, 0);
    await db.doc(`jogos/${OUTRO_JOGO}`).update({
      status: 'FINISHED',
      homeScore: 1,
      awayScore: 0,
    });
    await esperar(async () => (await pontos('ana')) === 10);

    const e = await extrato('ana', POOL, 20, db);
    const linha = e.rows.find((l) => l.matchId === OUTRO_JOGO)!;
    expect(linha.correction).toBeNull();
  });

  it('PR-6-07 correção de jogo que não tinha placar', async () => {
    await palpitar('ana', JOGO, 2, 1);
    // Jogo adiado que nunca foi reagendado: o admin informa o placar do W.O., e
    // não há placar anterior para mostrar.
    await corrigir('ana', POOL, JOGO, 2, 1, db);
    await esperar(async () => (await pontos('ana')) === 10);

    const e = await extrato('ana', POOL, 20, db);
    const linha = e.rows.find((l) => l.matchId === JOGO)!;
    expect(linha.correction).toMatchObject({
      previousHome: null,
      previousAway: null,
      manual: true,
    });
    expect(linha.points).toBe(10);
  });

  it('PR-6-08 duas correções seguidas mostram a última', async () => {
    await palpitar('ana', JOGO, 2, 1);
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
    });
    await esperar(async () => (await pontos('ana')) === 10);

    await corrigir('ana', POOL, JOGO, 1, 0, db);
    await corrigir('ana', POOL, JOGO, 4, 2, db);

    const e = await extrato('ana', POOL, 20, db);
    const linha = e.rows.find((l) => l.matchId === JOGO)!;
    // A trilha guarda as duas; o extrato mostra a mais recente — o que interessa
    // ao participante é "de onde para onde meu ponto foi agora".
    expect(linha.correction).toMatchObject({ previousHome: 1, previousAway: 0 });
    const trilha = await db.collection(`jogos/${JOGO}/correcoes`).get();
    expect(trilha.size).toBe(2);
  });

  it('PR-6-09 a soma do extrato continua batendo com o ranking', async () => {
    await palpitar('ana', JOGO, 2, 1);
    await palpitar('ana', OUTRO_JOGO, 0, 0);
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
    });
    await db.doc(`jogos/${OUTRO_JOGO}`).update({
      status: 'FINISHED',
      homeScore: 1,
      awayScore: 0,
    });
    await esperar(async () => (await pontos('ana')) === 10);

    await corrigir('ana', POOL, JOGO, 3, 1, db);
    await esperar(async () => (await pontos('ana')) === 5);

    const e = await extrato('ana', POOL, 20, db);
    // Extrato que não bate com o ranking é a desconfiança que `PR-2` existe para
    // eliminar — e correção é justamente quando ela aparece.
    expect(e.total).toBe(await pontos('ana'));
  });

  it('PR-6-10 o recálculo não duplica revisão de palpite', async () => {
    await palpitar('ana', JOGO, 2, 1);
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
    });
    await esperar(async () => (await pontos('ana')) === 10);

    const antes = (
      await db.collection(`boloes/${POOL}/palpites/${JOGO}_ana/revisoes`).get()
    ).size;

    await corrigir('ana', POOL, JOGO, 1, 0, db);
    await esperar(async () => (await pontos('ana')) === 5);

    // Revisão é histórico do **palpite**, não do placar. Reapuração não muda o
    // que a pessoa escreveu, então não pode inventar linha na auditoria dela.
    const depois = (
      await db.collection(`boloes/${POOL}/palpites/${JOGO}_ana/revisoes`).get()
    ).size;
    expect(depois).toBe(antes);
  });
});

// ---- cenário compartilhado -------------------------------------------------

async function montarBolao(): Promise<void> {
  await db.doc(`boloes/${POOL}`).set({
    name: 'Bolão da paridade de regra',
    competitionCode: 'REG',
    matchdayFrom: 20,
    matchdayTo: 20,
    createdBy: 'ana',
    createdAt: new Date(),
    pointsExact: 10,
    pointsWinnerDiff: 5,
    pointsWinner: 3,
    tiebreakOrder: ['EXACT_SCORES', 'WINNERS'],
    rulesLockedAt: null,
    closedAt: null,
    closedEarly: false,
    archivedAt: null,
    activeMembers: 2,
  });
  for (const uid of ['ana', 'bruno']) {
    await db.doc(`boloes/${POOL}/membros/${uid}`).set({
      userId: uid,
      displayName: uid,
      role: uid === 'ana' ? 'ADMIN' : 'MEMBER',
      joinedAt: new Date(),
      leftAt: null,
    });
  }
  for (const id of [JOGO, OUTRO_JOGO]) {
    await db.doc(`jogos/${id}`).set({
      externalId: id,
      competitionCode: 'REG',
      matchday: 20,
      homeTeam: 'Casa',
      awayTeam: 'Fora',
      kickoffAt: new Date(Date.now() - 3 * HORA),
      status: 'SCHEDULED',
      homeScore: null,
      awayScore: null,
      sourceUpdatedAt: null,
    });
  }
  // Limpa a trilha de correção junto: ela vive no **jogo**, que é global e
  // sobrevive ao bolão. Sem isto, o caso que conta correções conta as do vizinho.
  for (const id of [JOGO, OUTRO_JOGO]) {
    const trilha = await db.collection(`jogos/${id}/correcoes`).get();
    await Promise.all(trilha.docs.map((d) => d.ref.delete()));
  }

  // Limpa palpites de um caso anterior: o bolão é o mesmo dentro do arquivo.
  const antigos = await db.collection(`boloes/${POOL}/palpites`).get();
  await Promise.all(antigos.docs.map((d) => d.ref.delete()));
  const classificacao = await db.collection(`boloes/${POOL}/classificacao`).get();
  await Promise.all(classificacao.docs.map((d) => d.ref.delete()));
}

async function palpitar(
  uid: string,
  matchId: string,
  casa: number,
  fora: number,
): Promise<void> {
  await db.doc(`boloes/${POOL}/palpites/${matchId}_${uid}`).set({
    userId: uid,
    matchId,
    homeScore: casa,
    awayScore: fora,
    createdAt: new Date(),
    updatedAt: new Date(),
    matchFinished: false,
    points: null,
    band: null,
    scoredAt: null,
  });
}

const pontos = async (uid: string): Promise<number | undefined> =>
  (await db.doc(`boloes/${POOL}/classificacao/${uid}`).get()).data()?.points as
    | number
    | undefined;
