process.env.THROTTLE_LOGIN_LIMIT = '1000';

import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { ScoringService } from '../src/scoring/scoring.service';

type Encerramento = {
  closedEarly: boolean;
  champions: { displayName: string; points: number }[];
  finalRanking: { position: number; displayName: string }[];
};
type Historico = {
  name: string;
  outcome: string;
  position: number | null;
  closedEarly: boolean;
  archived: boolean;
  isAdmin: boolean;
}[];

describe('Ciclo de vida do bolão (e2e)', () => {
  let app: INestApplication<App>;
  let prisma: PrismaService;
  let scoring: ScoringService;
  let criador: { id: string; token: string };
  let ana: { id: string; token: string };
  let poolId: string;

  // Rodada exclusiva: esta suíte apura jogos.
  const RODADA = 37;
  /// Rodadas em que esta suíte mexe (status, placar ou horário).
  const RODADAS_TOCADAS = [21, 22, 23, 24, 25, 26, 27, 32, 33, 38, RODADA];
  const kickoffsOriginais = new Map<string, Date>();

  const criarConta = async (email: string, nome: string) => {
    const conta = await request(app.getHttpServer())
      .post('/auth/register')
      .send({ email, password: 'senhaSegura123', displayName: nome })
      .expect(201);
    const login = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ email, password: 'senhaSegura123' })
      .expect(200);
    return {
      id: (conta.body as { id: string }).id,
      token: (login.body as { accessToken: string }).accessToken,
    };
  };

  const criarBolao = async (nome: string, de = RODADA, ate = RODADA) => {
    const res = await request(app.getHttpServer())
      .post('/pools')
      .set('Authorization', `Bearer ${criador.token}`)
      .send({
        name: nome,
        competitionCode: 'BSA',
        matchdayFrom: de,
        matchdayTo: ate,
      })
      .expect(201);
    return (res.body as { id: string }).id;
  };

  beforeAll(async () => {
    const mod = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = mod.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
      }),
    );
    await app.init();
    // Servidor escutando **uma vez** por suíte: sem isto o supertest faz
    // listen/close por requisição, e o ciclo repetido produz "socket hang up"
    // aleatório na suíte completa — gate que falha sozinho não serve de gate.
    await app.listen(0);
    prisma = app.get(PrismaService);
    scoring = app.get(ScoringService);

    criador = await criarConta('ciclo-criador@e2e.teste', 'Criador');
    ana = await criarConta('ciclo-ana@e2e.teste', 'Ana');

    // `Match` é global entre bolões: sem devolver o horário, o palpite do
    // `beforeAll` de outra suíte é recusado por prazo — foi o que aconteceu em
    // 06/08.
    for (const m of await prisma.match.findMany({
      where: { competitionCode: 'BSA', matchday: { in: RODADAS_TOCADAS } },
      select: { id: true, kickoffAt: true },
    })) {
      kickoffsOriginais.set(m.id, m.kickoffAt);
    }

    poolId = await criarBolao('Bolão ciclo');
    await prisma.poolMember.create({
      data: { poolId, userId: ana.id, role: 'MEMBER' },
    });
  });

  afterAll(async () => {
    // Restaura **todas** as rodadas que esta suíte mexeu, não só a principal:
    // `Match` é global entre bolões, e status/placar deixados para trás quebram
    // a próxima suíte (já aconteceu em 06/08 com `kickoffAt`).
    for (const [id, kickoffAt] of kickoffsOriginais) {
      await prisma.match.update({ where: { id }, data: { kickoffAt } });
    }
    await prisma.match.updateMany({
      where: { competitionCode: 'BSA', matchday: { in: RODADAS_TOCADAS } },
      data: { status: 'TIMED', homeScore: null, awayScore: null },
    });
    await prisma.pool.deleteMany({ where: { createdById: criador.id } });
    await prisma.user.deleteMany({ where: { email: { contains: 'ciclo-' } } });
    await app.close();
  });

  const encerrar = (id: string, token: string) =>
    request(app.getHttpServer())
      .post(`/pools/${id}/close`)
      .set('Authorization', `Bearer ${token}`);

  it('recusa encerrar com jogo sem resultado apurado', async () => {
    const res = await encerrar(poolId, criador.token).expect(409);
    expect(JSON.stringify(res.body)).toMatch(/sem resultado apurado/i);
  });

  it('recusa arquivar bolão em andamento', async () => {
    const res = await request(app.getHttpServer())
      .post(`/pools/${poolId}/archive`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(409);
    expect(JSON.stringify(res.body)).toMatch(/encerre o bolão antes/i);
  });

  it('encerra quando todos os jogos estão apurados e aponta o campeão', async () => {
    const jogos = await prisma.poolMatch.findMany({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });

    // Ana acerta o primeiro na mosca; o criador não palpita nada.
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${ana.token}`)
      .send({
        predictions: [
          { matchId: jogos[0].matchId, homeScore: 2, awayScore: 1 },
        ],
      })
      .expect(201);

    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();

    const res = await encerrar(poolId, criador.token).expect(201);
    const e = res.body as Encerramento;

    expect(e.closedEarly).toBe(false);
    expect(e.champions).toHaveLength(1);
    expect(e.champions[0].displayName).toBe('Ana');
    expect(e.finalRanking[0].position).toBe(1);
  });

  it('bolão encerrado não aceita palpite nem entrada', async () => {
    const jogo = await prisma.poolMatch.findFirst({
      where: { poolId },
      select: { matchId: true },
    });

    await request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${ana.token}`)
      .send({
        predictions: [{ matchId: jogo!.matchId, homeScore: 0, awayScore: 0 }],
      })
      .expect(410);
  });

  it('co-campeões dividem a posição quando o empate persiste', async () => {
    const pool2 = await criarBolao('Bolão empate');
    const bruno = await criarConta('ciclo-bruno@e2e.teste', 'Bruno');
    await prisma.poolMember.create({
      data: { poolId: pool2, userId: bruno.id, role: 'MEMBER' },
    });

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: pool2 },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });

    // Palpites idênticos: empate que resiste a todos os critérios.
    for (const token of [criador.token, bruno.token]) {
      await request(app.getHttpServer())
        .post(`/pools/${pool2}/predictions`)
        .set('Authorization', `Bearer ${token}`)
        .send({
          predictions: [
            { matchId: jogos[0].matchId, homeScore: 2, awayScore: 1 },
          ],
        })
        .expect(201);
    }

    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();

    const e = (await encerrar(pool2, criador.token).expect(201))
      .body as Encerramento;
    expect(e.champions).toHaveLength(2);
    expect(e.champions.map((c) => c.displayName).sort()).toEqual([
      'Bruno',
      'Criador',
    ]);
  });

  it('encerramento antecipado fecha o prazo dos jogos abertos e fica marcado', async () => {
    const pool3 = await criarBolao('Bolão travado', 38, 38);

    const res = await request(app.getHttpServer())
      .post(`/pools/${pool3}/close-early`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(201);

    expect((res.body as Encerramento).closedEarly).toBe(true);

    // Nenhum jogo do bolão continua com prazo aberto.
    const abertos = await prisma.poolMatch.count({
      where: { poolId: pool3, match: { kickoffAt: { gt: new Date() } } },
    });
    expect(abertos).toBe(0);
  });

  it('participante comum não encerra nem arquiva', async () => {
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/close-early`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(403);

    await request(app.getHttpServer())
      .post(`/pools/${poolId}/archive`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(403);
  });

  it('arquiva e desarquiva — o arquivamento é reversível', async () => {
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/archive`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(201);

    let pool = await prisma.pool.findUnique({
      where: { id: poolId },
      select: { archivedAt: true },
    });
    expect(pool?.archivedAt).not.toBeNull();

    await request(app.getHttpServer())
      .delete(`/pools/${poolId}/archive`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(200);

    pool = await prisma.pool.findUnique({
      where: { id: poolId },
      select: { archivedAt: true },
    });
    expect(pool?.archivedAt).toBeNull();
  });

  it('recria a temporada herdando regras e galera, com pontuação zerada', async () => {
    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/rules`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({ pointsExact: 25 })
      .expect(409); // já travado, como esperado

    const res = await request(app.getHttpServer())
      .post(`/pools/${poolId}/recreate`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({ matchdayFrom: 20, matchdayTo: 21, name: 'Bolão 2027' })
      .expect(201);

    const novo = res.body as {
      id: string;
      inheritedMembers: number;
      totalMatches: number;
    };
    expect(novo.inheritedMembers).toBe(2);
    expect(novo.totalMatches).toBe(20);

    // Nenhum palpite herdado — critério mais importante da story.
    expect(await prisma.prediction.count({ where: { poolId: novo.id } })).toBe(
      0,
    );

    // Regras herdadas, mas destravadas.
    const pool = await prisma.pool.findUnique({
      where: { id: novo.id },
      select: { rulesLockedAt: true, pointsExact: true, recreatedFromId: true },
    });
    expect(pool?.rulesLockedAt).toBeNull();
    expect(pool?.pointsExact).toBe(10);
    expect(pool?.recreatedFromId).toBe(poolId);
  });

  it('recusa recriar temporada de bolão ainda em andamento', async () => {
    const emAndamento = await criarBolao('Bolão aberto', 32, 32);
    await request(app.getHttpServer())
      .post(`/pools/${emAndamento}/recreate`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({ matchdayFrom: 20, matchdayTo: 20 })
      .expect(409);
  });

  it('histórico distingue campeão, co-campeão e abandonado', async () => {
    const res = await request(app.getHttpServer())
      .get('/pools/history')
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(200);

    const h = res.body as Historico;
    expect(h.length).toBeGreaterThanOrEqual(2);

    // No "Bolão empate" o criador dividiu o título.
    expect(h.find((p) => p.name === 'Bolão empate')?.outcome).toBe(
      'CO_CAMPEAO',
    );
    // No "Bolão travado" foi encerramento antecipado.
    expect(h.find((p) => p.name === 'Bolão travado')?.closedEarly).toBe(true);
  });

  it('histórico diz quem é admin, para o app não oferecer arquivar a todos', async () => {
    const membro = await criarConta('ciclo-membro-hist@e2e.teste', 'Membro');
    const pool = await criarBolao('Bolão papel', 32, 32);
    await prisma.poolMember.create({
      data: { poolId: pool, userId: membro.id, role: 'MEMBER' },
    });
    await prisma.pool.update({
      where: { id: pool },
      data: { closedAt: new Date() },
    });

    const doCriador = (
      await request(app.getHttpServer())
        .get('/pools/history')
        .set('Authorization', `Bearer ${criador.token}`)
        .expect(200)
    ).body as Historico;
    const doMembro = (
      await request(app.getHttpServer())
        .get('/pools/history')
        .set('Authorization', `Bearer ${membro.token}`)
        .expect(200)
    ).body as Historico;

    // Sem isto o participante comum via o botão de arquivar e levava 403.
    expect(doCriador.find((p) => p.name === 'Bolão papel')?.isAdmin).toBe(true);
    expect(doMembro.find((p) => p.name === 'Bolão papel')?.isAdmin).toBe(false);
  });

  it('histórico de quem abandonou não traz posição final', async () => {
    const carlos = await criarConta('ciclo-carlos@e2e.teste', 'Carlos');
    const pool4 = await criarBolao('Bolão abandonado', 32, 32);
    await prisma.poolMember.create({
      data: { poolId: pool4, userId: carlos.id, role: 'MEMBER' },
    });
    await prisma.poolMember.update({
      where: { poolId_userId: { poolId: pool4, userId: carlos.id } },
      data: { leftAt: new Date() },
    });
    await prisma.pool.update({
      where: { id: pool4 },
      data: { closedAt: new Date(), closedEarly: true },
    });

    const h = (
      await request(app.getHttpServer())
        .get('/pools/history')
        .set('Authorization', `Bearer ${carlos.token}`)
        .expect(200)
    ).body as Historico;

    const abandonado = h.find((p) => p.name === 'Bolão abandonado');
    expect(abandonado?.outcome).toBe('ABANDONADO');
    expect(abandonado?.position).toBeNull();
  });

  it('CV-1-04 encerrar duas vezes é idempotente', async () => {
    // Plano docs/testes/CV-1-plano.md, caso CV-1-04. Dois toques no botão não
    // podem produzir dois encerramentos com horários diferentes.
    const pool = await criarBolao('Bolão idempotência', 32, 32);
    const vinculos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      select: { matchId: true },
    });
    await prisma.match.updateMany({
      where: { id: { in: vinculos.map((v) => v.matchId) } },
      data: { status: 'FINISHED', homeScore: 1, awayScore: 0 },
    });

    const primeira = await request(app.getHttpServer())
      .post(`/pools/${pool}/close`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(201);
    const segunda = await request(app.getHttpServer())
      .post(`/pools/${pool}/close`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(201);

    const a = primeira.body as { closedAt: string; closedEarly: boolean };
    const b = segunda.body as { closedAt: string; closedEarly: boolean };
    expect(b.closedAt).toBe(a.closedAt);
    expect(b.closedEarly).toBe(a.closedEarly);
  });

  it('CV-1-09 bolão em que ninguém pontuou não inventa campeão', async () => {
    // Plano docs/testes/CV-1-plano.md, caso CV-1-09.
    const pool = await criarBolao('Bolão sem ponto', 32, 32);
    const vinculos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      select: { matchId: true },
    });
    await prisma.match.updateMany({
      where: { id: { in: vinculos.map((v) => v.matchId) } },
      data: { status: 'FINISHED', homeScore: 1, awayScore: 0 },
    });

    const res = await request(app.getHttpServer())
      .post(`/pools/${pool}/close`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(201);

    const r = res.body as {
      champions: { displayName: string; points: number }[];
      finalRanking: { points: number }[];
    };

    // Sem palpite de ninguém, todos ficam com zero. Campeão de zero ponto seria
    // título inventado: a resposta correta é **não ter campeão**, e não declarar
    // co-campeão a galera inteira.
    expect(r.champions).toEqual([]);
    expect(r.finalRanking.every((l) => l.points === 0)).toBe(true);
    expect(r.finalRanking.length).toBeGreaterThan(0);
  });

  it('CV-1-03 jogo adiado para depois do bolão só encerra antecipadamente', async () => {
    // Plano docs/testes/CV-1-plano.md, caso CV-1-03. O bolão não pode ficar
    // preso para sempre esperando um jogo que a CBF remarcou.
    const pool = await criarBolao('Bolão remarcado', 32, 32);
    const vinculos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    await prisma.match.updateMany({
      where: { id: { in: vinculos.map((v) => v.matchId) } },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 0 },
    });
    // Um jogo remarcado para muito depois do fim do bolão.
    await prisma.match.update({
      where: { id: vinculos[0].matchId },
      data: {
        status: 'POSTPONED',
        homeScore: null,
        awayScore: null,
        kickoffAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
      },
    });

    // Encerramento normal recusado: ainda há jogo sem resultado.
    await request(app.getHttpServer())
      .post(`/pools/${pool}/close`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(409);

    // O antecipado é a única saída, e fica marcado como tal.
    const res = await request(app.getHttpServer())
      .post(`/pools/${pool}/close-early`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(201);
    expect((res.body as { closedEarly: boolean }).closedEarly).toBe(true);
  });

  it('CV-7 FE-3 quem foi reconvidado na temporada nova é avisado', async () => {
    // Ser incluído num bolão sem saber é o mesmo problema de descobrir por acaso
    // que perdeu ponto: a pessoa só nota quando o prazo da abertura já passou.
    const outro = await criarConta('ciclo-recriado@e2e.teste', 'Recriado');
    const pool = await criarBolao('Bolão temporada 1', 32, 32);
    await prisma.poolMember.create({
      data: { poolId: pool, userId: outro.id, role: 'MEMBER' },
    });
    await prisma.pool.update({
      where: { id: pool },
      data: { closedAt: new Date() },
    });
    await prisma.notification.deleteMany({ where: { userId: outro.id } });

    const res = await request(app.getHttpServer())
      .post(`/pools/${pool}/recreate`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({ matchdayFrom: 33, matchdayTo: 33, name: 'Bolão temporada 2' })
      .expect(201);
    const novo = (res.body as { id: string }).id;

    const avisos = await prisma.notification.findMany({
      where: { userId: outro.id, kind: 'BOLAO_RECRIADO' },
      select: { title: true, body: true, poolId: true },
    });

    expect(avisos.length).toBe(1);
    expect(avisos[0].title).toBe('Você está num bolão de temporada nova');
    expect(avisos[0].body).toContain('Bolão temporada 2');
    // O aviso aponta para o bolão **novo**, que é onde ele precisa palpitar.
    expect(avisos[0].poolId).toBe(novo);

    // Quem recriou não se avisa a si mesmo: ele acabou de fazer a ação.
    expect(
      await prisma.notification.count({
        where: { userId: criador.id, kind: 'BOLAO_RECRIADO' },
      }),
    ).toBe(0);
  });

  it('CV-2-02 três empatados no topo são todos campeões', async () => {
    // Plano docs/testes/CV-2-plano.md. Cortar em dois seria escolher
    // arbitrariamente quem fica de fora — e em bolão pequeno com rodada única é
    // o caso mais provável de todos.
    const pool = await criarBolao('Bolão de três campeões');
    const c1 = await criarConta('ciclo-tri1@e2e.teste', 'Tri1');
    const c2 = await criarConta('ciclo-tri2@e2e.teste', 'Tri2');
    for (const u of [c1, c2]) {
      await prisma.poolMember.create({
        data: { poolId: pool, userId: u.id, role: 'MEMBER' },
      });
    }

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });

    // Palpites idênticos: empate que resiste a todos os critérios.
    for (const token of [criador.token, c1.token, c2.token]) {
      await request(app.getHttpServer())
        .post(`/pools/${pool}/predictions`)
        .set('Authorization', `Bearer ${token}`)
        .send({
          predictions: [
            { matchId: jogos[0].matchId, homeScore: 2, awayScore: 1 },
          ],
        })
        .expect(201);
    }
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();

    const e = (await encerrar(pool, criador.token).expect(201))
      .body as Encerramento;
    expect(e.champions).toHaveLength(3);
    expect(e.champions.map((c) => c.displayName).sort()).toEqual([
      'Criador',
      'Tri1',
      'Tri2',
    ]);
  });

  it('CV-2-03 a posição seguinte a um empate é 3º, não 2º', async () => {
    // Plano docs/testes/CV-2-plano.md. Quem ficou atrás de dois empatados está
    // em terceiro — e o ranking precisa dizer isso, senão o 3º se apresenta como
    // vice.
    const pool = await criarBolao('Bolão de posição ajustada');
    const a = await criarConta('ciclo-pos-a@e2e.teste', 'PosA');
    const b = await criarConta('ciclo-pos-b@e2e.teste', 'PosB');
    for (const u of [a, b]) {
      await prisma.poolMember.create({
        data: { poolId: pool, userId: u.id, role: 'MEMBER' },
      });
    }

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    const jogo = jogos[0].matchId;

    // A e B acertam o placar exato; o criador acerta só o vencedor.
    for (const [token, palpite] of [
      [a.token, [2, 1]],
      [b.token, [2, 1]],
      [criador.token, [4, 0]],
    ] as [string, [number, number]][]) {
      await request(app.getHttpServer())
        .post(`/pools/${pool}/predictions`)
        .set('Authorization', `Bearer ${token}`)
        .send({
          predictions: [
            { matchId: jogo, homeScore: palpite[0], awayScore: palpite[1] },
          ],
        })
        .expect(201);
    }
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();

    const res = await request(app.getHttpServer())
      .get(`/pools/${pool}/ranking`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(200);
    const rows = (
      res.body as {
        rows: { displayName: string; position: number; sharedPosition: boolean }[];
      }
    ).rows;

    const posicoes = new Map(rows.map((r) => [r.displayName, r.position]));
    expect(posicoes.get('PosA')).toBe(1);
    expect(posicoes.get('PosB')).toBe(1);
    expect(posicoes.get('Criador')).toBe(3);
    expect(rows.find((r) => r.displayName === 'PosA')!.sharedPosition).toBe(true);
    expect(rows.find((r) => r.displayName === 'Criador')!.sharedPosition).toBe(
      false,
    );
  });

  it('CV-2-05 correção antes do encerramento desfaz o empate', async () => {
    // Plano docs/testes/CV-2-plano.md. O campeão é derivado na leitura, então
    // corrigir um placar antes de encerrar tem que produzir campeão único.
    const pool = await criarBolao('Bolão do empate desfeito');
    const rival = await criarConta('ciclo-rival@e2e.teste', 'Rival');
    await prisma.poolMember.create({
      data: { poolId: pool, userId: rival.id, role: 'MEMBER' },
    });

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    const jogo = jogos[0].matchId;

    // Palpites diferentes que, contra 2x1, dão a mesma pontuação: ambos acertam
    // vencedor e saldo (3x2 e 1x0 têm saldo 1).
    for (const [token, palpite] of [
      [criador.token, [3, 2]],
      [rival.token, [1, 0]],
    ] as [string, [number, number]][]) {
      await request(app.getHttpServer())
        .post(`/pools/${pool}/predictions`)
        .set('Authorization', `Bearer ${token}`)
        .send({
          predictions: [
            { matchId: jogo, homeScore: palpite[0], awayScore: palpite[1] },
          ],
        })
        .expect(201);
    }
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();

    // Empate confirmado antes da correção.
    const antes = (
      await request(app.getHttpServer())
        .get(`/pools/${pool}/ranking`)
        .set('Authorization', `Bearer ${criador.token}`)
        .expect(200)
    ).body as { rows: { displayName: string; points: number }[] };
    expect(antes.rows[0].points).toBe(antes.rows[1].points);

    // Correção do admin: o placar real era 3x2 → o criador passa a ter exato.
    await request(app.getHttpServer())
      .patch(`/pools/${pool}/matches/${jogo}/score`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({ homeScore: 3, awayScore: 2 })
      .expect(200);

    const e = (await encerrar(pool, criador.token).expect(201))
      .body as Encerramento;
    expect(e.champions).toHaveLength(1);
    expect(e.champions[0].displayName).toBe('Criador');
  });

  it('CV-2-06 correção DEPOIS do encerramento também muda o campeão', async () => {
    // Plano docs/testes/CV-2-plano.md. Decisão de 03/08: o campeão é derivado na
    // leitura e **nunca congelado**. É o que permite consertar placar errado sem
    // perder o histórico — e é contraintuitivo o suficiente para precisar de
    // teste próprio.
    const pool = await criarBolao('Bolão corrigido depois de encerrar');
    const rival = await criarConta('ciclo-depois@e2e.teste', 'Depois');
    await prisma.poolMember.create({
      data: { poolId: pool, userId: rival.id, role: 'MEMBER' },
    });

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    const jogo = jogos[0].matchId;

    for (const [token, palpite] of [
      [criador.token, [3, 2]],
      [rival.token, [1, 0]],
    ] as [string, [number, number]][]) {
      await request(app.getHttpServer())
        .post(`/pools/${pool}/predictions`)
        .set('Authorization', `Bearer ${token}`)
        .send({
          predictions: [
            { matchId: jogo, homeScore: palpite[0], awayScore: palpite[1] },
          ],
        })
        .expect(201);
    }
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();

    const encerrado = (await encerrar(pool, criador.token).expect(201))
      .body as Encerramento;
    expect(encerrado.champions).toHaveLength(2);

    // Correção depois de encerrado.
    await request(app.getHttpServer())
      .patch(`/pools/${pool}/matches/${jogo}/score`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({ homeScore: 3, awayScore: 2 })
      .expect(200);

    const relido = (
      await request(app.getHttpServer())
        .get(`/pools/${pool}/closing`)
        .set('Authorization', `Bearer ${criador.token}`)
        .expect(200)
    ).body as Encerramento;

    expect(relido.champions).toHaveLength(1);
    expect(relido.champions[0].displayName).toBe('Criador');
  });

  it('CV-1-10 participante comum lê o encerramento — campeão e ranking final', async () => {
    // Critério de CV-1: "todos os participantes veem o ranking final e quem foi
    // campeão". Antes desta rota 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.
    const pool = await criarBolao('Bolão de leitura do encerramento');
    const membro = await criarConta('ciclo-leitor@e2e.teste', 'Leitor');
    await prisma.poolMember.create({
      data: { poolId: pool, userId: membro.id, role: 'MEMBER' },
    });

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    await request(app.getHttpServer())
      .post(`/pools/${pool}/predictions`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({
        predictions: [
          { matchId: jogos[0].matchId, homeScore: 2, awayScore: 1 },
        ],
      })
      .expect(201);
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();
    await encerrar(pool, criador.token).expect(201);

    const lido = (
      await request(app.getHttpServer())
        .get(`/pools/${pool}/closing`)
        .set('Authorization', `Bearer ${membro.token}`)
        .expect(200)
    ).body as Encerramento;

    expect(lido.champions).toHaveLength(1);
    expect(lido.champions[0].displayName).toBe('Criador');
    expect(lido.finalRanking.length).toBeGreaterThanOrEqual(2);
  });

  it('CV-1-11 bolão não encerrado responde 409 na leitura do encerramento', async () => {
    // Devolver ranking vazio faria a tela dizer "sem campeão" para um bolão que
    // está apenas em andamento.
    // Bolão novo, garantidamente em andamento — `poolId` da suíte já pode ter
    // sido encerrado por casos anteriores.
    const emAndamento = await criarBolao('Bolão ainda rolando');
    await prisma.poolMember.create({
      data: { poolId: emAndamento, userId: ana.id, role: 'MEMBER' },
    });
    const res = await request(app.getHttpServer())
      .get(`/pools/${emAndamento}/closing`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(409);
    expect((res.body as { message: string }).message).toMatch(/não foi encerrado/i);
  });

  it('CV-1-12 quem não participa não lê o encerramento', async () => {
    const deFora = await criarConta('ciclo-defora@e2e.teste', 'DeFora');
    await request(app.getHttpServer())
      .get(`/pools/${poolId}/closing`)
      .set('Authorization', `Bearer ${deFora.token}`)
      .expect(403);
  });

  it('CV-3-04 depois do encerramento antecipado, palpite é recusado', async () => {
    // Plano docs/testes/CV-3-plano.md. O motivo tem que ser "bolão encerrado",
    // não "prazo fechado" — prazo sugere atraso do participante.
    const pool = await criarBolao('Bolão fechado cedo', 26, 26);
    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    // Prazo aberto antes de encerrar.
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { kickoffAt: new Date(Date.now() + 2 * 60 * 60 * 1000) },
    });

    await request(app.getHttpServer())
      .post(`/pools/${pool}/close-early`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(201);

    const res = await request(app.getHttpServer())
      .post(`/pools/${pool}/predictions`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({
        predictions: [
          { matchId: jogos[0].matchId, homeScore: 1, awayScore: 0 },
        ],
      });
    expect(res.status).toBeGreaterThanOrEqual(400);
    expect((res.body as { message: string }).message).toMatch(/encerrad/i);
  });

  it('CV-3-06 encerrar antecipadamente duas vezes é recusado', async () => {
    // Plano docs/testes/CV-3-plano.md. Diferente do encerramento normal, que é
    // idempotente: um segundo encerramento antecipado reescreveria a data e a
    // marca do título.
    const pool = await criarBolao('Bolão fechado duas vezes', 27, 27);

    const primeiro = (
      await request(app.getHttpServer())
        .post(`/pools/${pool}/close-early`)
        .set('Authorization', `Bearer ${criador.token}`)
        .expect(201)
    ).body as Encerramento;

    await request(app.getHttpServer())
      .post(`/pools/${pool}/close-early`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(409);

    const depois = await prisma.pool.findUniqueOrThrow({
      where: { id: pool },
      select: { closedAt: true, closedEarly: true },
    });
    expect(depois.closedEarly).toBe(true);
    expect(depois.closedAt!.toISOString()).toBe(
      new Date(primeiro.closedAt).toISOString(),
    );
  });

  it('CV-4-03 saída voluntária não recalcula o ponto de quem ficou', async () => {
    // Plano docs/testes/CV-4-plano.md. Mesma garantia da remoção pelo admin
    // (CC-9-03), pelo outro caminho: quem sai não pode mexer no ponto de ninguém.
    const pool = await criarBolao('Bolão de quem sai', 25, 25);
    const fica = await criarConta('ciclo-fica@e2e.teste', 'Fica');
    const sai = await criarConta('ciclo-sai@e2e.teste', 'Sai');
    for (const u of [fica, sai]) {
      await prisma.poolMember.create({
        data: { poolId: pool, userId: u.id, role: 'MEMBER' },
      });
    }

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    const jogo = jogos[0].matchId;
    await prisma.match.update({
      where: { id: jogo },
      data: {
        status: 'TIMED',
        homeScore: null,
        awayScore: null,
        kickoffAt: new Date(Date.now() + 60 * 60 * 1000),
      },
    });

    for (const [token, palpite] of [
      [fica.token, [2, 1]],
      [sai.token, [3, 0]],
    ] as [string, [number, number]][]) {
      await request(app.getHttpServer())
        .post(`/pools/${pool}/predictions`)
        .set('Authorization', `Bearer ${token}`)
        .send({
          predictions: [
            { matchId: jogo, homeScore: palpite[0], awayScore: palpite[1] },
          ],
        })
        .expect(201);
    }
    await prisma.match.update({
      where: { id: jogo },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();

    const antes = (
      await request(app.getHttpServer())
        .get(`/pools/${pool}/ranking`)
        .set('Authorization', `Bearer ${fica.token}`)
        .expect(200)
    ).body as { rows: { displayName: string; points: number }[] };
    const pontosAntes = new Map(antes.rows.map((r) => [r.displayName, r.points]));
    const palpitesDoQueSai = await prisma.prediction.count({
      where: { poolId: pool, userId: sai.id },
    });

    await request(app.getHttpServer())
      .post(`/pools/${pool}/members/leave`)
      .set('Authorization', `Bearer ${sai.token}`)
      .expect(200);

    const depois = (
      await request(app.getHttpServer())
        .get(`/pools/${pool}/ranking`)
        .set('Authorization', `Bearer ${fica.token}`)
        .expect(200)
    ).body as { rows: { displayName: string; points: number }[] };

    expect(depois.rows.some((r) => r.displayName === 'Sai')).toBe(false);
    for (const linha of depois.rows) {
      expect(linha.points).toBe(pontosAntes.get(linha.displayName));
    }
    // E o histórico dele continua: apagar seria reescrever a história do bolão
    // de todos por decisão de um.
    expect(
      await prisma.prediction.count({ where: { poolId: pool, userId: sai.id } }),
    ).toBe(palpitesDoQueSai);
  });

  it('CV-4-07 o criador não sai nem havendo outro admin', async () => {
    // Plano docs/testes/CV-4-plano.md. Consequência aceita da decisão de 03/08:
    // admin permanente. Sem este caso, alguém "consertaria" a recusa achando que
    // ela só valia para o último admin.
    const pool = await criarBolao('Bolão com dois admins', 24, 24);
    const outro = await criarConta('ciclo-2admin@e2e.teste', 'SegundoAdmin');
    await prisma.poolMember.create({
      data: { poolId: pool, userId: outro.id, role: 'ADMIN' },
    });

    const res = await request(app.getHttpServer())
      .post(`/pools/${pool}/members/leave`)
      .set('Authorization', `Bearer ${criador.token}`);
    expect(res.status).toBeGreaterThanOrEqual(400);

    // O outro admin, sim, pode sair — a recusa é sobre o criador, não sobre
    // "ser admin".
    await request(app.getHttpServer())
      .post(`/pools/${pool}/members/leave`)
      .set('Authorization', `Bearer ${outro.token}`)
      .expect(200);
  });

  it('CV-6-06/07/08 correção em bolão ARQUIVADO recalcula, avisa e troca o campeão', async () => {
    // Plano docs/testes/CV-6-plano.md. Bloquear o recálculo em arquivado deixaria
    // um ranking final **errado** para sempre — e arquivar viraria a maneira de
    // congelar um erro a favor de alguém.
    const pool = await criarBolao('Bolão arquivado com correção', 23, 23);
    const rival = await criarConta('ciclo-arq@e2e.teste', 'Arquivista');
    await prisma.poolMember.create({
      data: { poolId: pool, userId: rival.id, role: 'MEMBER' },
    });

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: pool },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    const jogo = jogos[0].matchId;
    await prisma.match.update({
      where: { id: jogo },
      data: {
        status: 'TIMED',
        homeScore: null,
        awayScore: null,
        kickoffAt: new Date(Date.now() + 60 * 60 * 1000),
      },
    });

    // Criador acerta o placar exato de 2x1; rival acerta só o vencedor.
    for (const [token, palpite] of [
      [criador.token, [2, 1]],
      [rival.token, [4, 0]],
    ] as [string, [number, number]][]) {
      await request(app.getHttpServer())
        .post(`/pools/${pool}/predictions`)
        .set('Authorization', `Bearer ${token}`)
        .send({
          predictions: [
            { matchId: jogo, homeScore: palpite[0], awayScore: palpite[1] },
          ],
        })
        .expect(201);
    }
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();

    // Encerra e arquiva.
    const antes = (await encerrar(pool, criador.token).expect(201))
      .body as Encerramento;
    expect(antes.champions[0].displayName).toBe('Criador');
    await request(app.getHttpServer())
      .post(`/pools/${pool}/archive`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(201);

    // CV-6-06: arquivado é somente leitura para palpite.
    const tentativa = await request(app.getHttpServer())
      .post(`/pools/${pool}/predictions`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({ predictions: [{ matchId: jogo, homeScore: 0, awayScore: 0 }] });
    expect(tentativa.status).toBeGreaterThanOrEqual(400);

    await prisma.notification.deleteMany({ where: { poolId: pool } });

    // CV-6-07: a correção chega e recalcula, mesmo arquivado.
    await request(app.getHttpServer())
      .patch(`/pools/${pool}/matches/${jogo}/score`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({ homeScore: 4, awayScore: 0 })
      .expect(200);

    // CV-6-08: o campeão lido agora é o outro — nada fica congelado.
    const depois = (
      await request(app.getHttpServer())
        .get(`/pools/${pool}/closing`)
        .set('Authorization', `Bearer ${rival.token}`)
        .expect(200)
    ).body as Encerramento;
    expect(depois.champions).toHaveLength(1);
    expect(depois.champions[0].displayName).toBe('Arquivista');

    // E quem foi afetado ficou sabendo.
    expect(
      await prisma.notification.count({
        where: { poolId: pool, kind: { startsWith: 'PLACAR_CORRIGIDO' } },
      }),
    ).toBeGreaterThan(0);
  });

  it('CV-7-03 o bolão recriado nasce com as regras destravadas', async () => {
    // Plano docs/testes/CV-7-plano.md. Herdar o travamento impediria o admin de
    // ajustar a pontuação para a temporada nova — que é justamente quando faz
    // sentido ajustar.
    const anterior = await criarBolao('Bolão que vai virar temporada', 22, 22);
    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: anterior },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    await prisma.match.update({
      where: { id: jogos[0].matchId },
      data: {
        status: 'TIMED',
        homeScore: null,
        awayScore: null,
        kickoffAt: new Date(Date.now() + 60 * 60 * 1000),
      },
    });
    // Palpite no anterior: é o que **trava** as regras dele (CC-3).
    await request(app.getHttpServer())
      .post(`/pools/${anterior}/predictions`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({
        predictions: [
          { matchId: jogos[0].matchId, homeScore: 1, awayScore: 0 },
        ],
      })
      .expect(201);
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { status: 'FINISHED', homeScore: 1, awayScore: 0 },
    });
    await scoring.apurarPendentes();
    await encerrar(anterior, criador.token).expect(201);

    // Regras do anterior estão travadas.
    const travadas = await request(app.getHttpServer())
      .patch(`/pools/${anterior}/rules`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({ pointsExact: 15 });
    expect(travadas.status).toBe(409);

    const novo = (
      await request(app.getHttpServer())
        .post(`/pools/${anterior}/recreate`)
        .set('Authorization', `Bearer ${criador.token}`)
        .send({ matchdayFrom: 33, matchdayTo: 33, name: 'Temporada nova' })
        .expect(201)
    ).body as { id: string };

    // No bolão novo, a mesma alteração é aceita.
    await request(app.getHttpServer())
      .patch(`/pools/${novo.id}/rules`)
      .set('Authorization', `Bearer ${criador.token}`)
      .send({ pointsExact: 15 })
      .expect(200);

    const regras = (
      await request(app.getHttpServer())
        .get(`/pools/${novo.id}/rules`)
        .set('Authorization', `Bearer ${criador.token}`)
        .expect(200)
    ).body as { pointsExact: number; rulesLockedAt: string | null };
    expect(regras.pointsExact).toBe(15);
    expect(regras.rulesLockedAt).toBeNull();

    // E nenhum palpite veio junto.
    expect(
      await prisma.prediction.count({ where: { poolId: novo.id } }),
    ).toBe(0);
  });

  it('CV-7-04 quem saiu do bolão anterior não é reconvidado', async () => {
    // Plano docs/testes/CV-7-plano.md. Reconvidar quem saiu contraria a saída
    // definitiva e ressuscita um vínculo que a pessoa encerrou.
    const anterior = await criarBolao('Bolão com desistente', 21, 21);
    const fica = await criarConta('ciclo-fica2@e2e.teste', 'Fica2');
    const saiu = await criarConta('ciclo-saiu2@e2e.teste', 'Saiu2');
    await prisma.poolMember.create({
      data: { poolId: anterior, userId: fica.id, role: 'MEMBER' },
    });
    await prisma.poolMember.create({
      data: {
        poolId: anterior,
        userId: saiu.id,
        role: 'MEMBER',
        leftAt: new Date(),
      },
    });

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: anterior },
      select: { matchId: true },
    });
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { status: 'FINISHED', homeScore: 1, awayScore: 1 },
    });
    await scoring.apurarPendentes();
    await encerrar(anterior, criador.token).expect(201);

    const novo = (
      await request(app.getHttpServer())
        .post(`/pools/${anterior}/recreate`)
        .set('Authorization', `Bearer ${criador.token}`)
        .send({ matchdayFrom: 33, matchdayTo: 33 })
        .expect(201)
    ).body as { id: string; inheritedMembers?: number };

    expect(
      await prisma.poolMember.count({
        where: { poolId: novo.id, userId: saiu.id },
      }),
    ).toBe(0);
    expect(
      await prisma.poolMember.count({
        where: { poolId: novo.id, userId: fica.id },
      }),
    ).toBe(1);
  });
});
