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 Linha = {
  displayName: string;
  points: number;
  exactScores: number;
  decidedBy: string;
};
type Ranking = { rows: Linha[] };

/**
 * `CC-2` BE-3 — a configuração do bolão manda no cálculo.
 *
 * O risco está escrito na própria story: se o cálculo usar constantes fixas, a
 * configuração por bolão é **decorativa**. Aconteceu com `tiebreakOrder`
 * (achado em `PR-5`: persistido, editável, herdado na recriação e nunca lido).
 * Esta suíte fecha o outro lado: a **tabela de pontos** e a **ordem de
 * desempate** deste bolão, de ponta a ponta — criação, apuração, ranking.
 */
describe('CC-2 tabela e desempate customizados (e2e)', () => {
  let app: INestApplication<App>;
  let prisma: PrismaService;
  let scoring: ScoringService;
  let admin: { id: string; token: string };
  let ana: { id: string; token: string };
  let bruno: { id: string; token: string };
  let poolId: string;
  let jogos: string[];

  // Rodada exclusiva desta suíte: `Match` é global entre bolões, então duas
  // suítes mexendo no placar do mesmo jogo interferem uma na outra.
  const RODADA = 28;
  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,
    };
  };

  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();
    await app.listen(0);
    prisma = app.get(PrismaService);
    scoring = app.get(ScoringService);

    admin = await criarConta('cc2-admin@e2e.teste', 'Admin');
    ana = await criarConta('cc2-ana@e2e.teste', 'Ana');
    bruno = await criarConta('cc2-bruno@e2e.teste', 'Bruno');

    const pool = await request(app.getHttpServer())
      .post('/pools')
      .set('Authorization', `Bearer ${admin.token}`)
      .send({
        name: 'Bolão de regra própria',
        competitionCode: 'BSA',
        matchdayFrom: RODADA,
        matchdayTo: RODADA,
        // Tabela **e** ordem diferentes do padrão: se o cálculo usar constantes,
        // os números abaixo saem errados e o teste acusa.
        rules: {
          pointsExact: 20,
          pointsWinnerDiff: 10,
          pointsWinner: 5,
          tiebreakOrder: ['WINNERS', 'EXACT_SCORES'],
        },
      })
      .expect(201);
    poolId = (pool.body as { id: string }).id;

    for (const u of [ana, bruno]) {
      await prisma.poolMember.create({
        data: { poolId, userId: u.id, role: 'MEMBER' },
      });
    }

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

    // Horário original guardado: esta suíte abre o prazo de jogos passados, e
    // `Match` é global entre bolões.
    for (const m of await prisma.match.findMany({
      where: { competitionCode: 'BSA', matchday: RODADA },
      select: { id: true, kickoffAt: true },
    })) {
      kickoffsOriginais.set(m.id, m.kickoffAt);
    }

    // Rodada 28 está no passado no calendário falso: sem abrir o prazo, o
    // palpite é recusado. Abrir aqui é o que torna o cenário montável.
    await prisma.match.updateMany({
      where: { id: { in: jogos.slice(0, 3) } },
      data: { kickoffAt: new Date(Date.now() + 60 * 60 * 1000) },
    });

    // Oficial: 2x1 nos três jogos.
    // Ana:   2x1 (exato, 20), 3x2 (vencedor+saldo, 10), 4x0 (só vencedor, 5)
    //        → 35 pontos, 1 exato, 3 acertos de vencedor
    // Bruno: 2x1 (exato, 20), 1x0 (vencedor+saldo... 1-0 tem saldo 1, exato é
    //        2x1 → saldo igual, 10), 0x2 (errou, 0)
    //        → 30 pontos, 1 exato, 2 acertos de vencedor
    const palpites: Record<string, [number, number][]> = {
      [ana.token]: [
        [2, 1],
        [3, 2],
        [4, 0],
      ],
      [bruno.token]: [
        [2, 1],
        [1, 0],
        [0, 2],
      ],
    };

    for (const [token, lista] of Object.entries(palpites)) {
      await request(app.getHttpServer())
        .post(`/pools/${poolId}/predictions`)
        .set('Authorization', `Bearer ${token}`)
        .send({
          predictions: lista.map(([h, a], i) => ({
            matchId: jogos[i],
            homeScore: h,
            awayScore: a,
          })),
        })
        .expect(201);
    }

    for (const id of jogos.slice(0, 3)) {
      await prisma.match.update({
        where: { id },
        data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
      });
    }
    await scoring.apurarPendentes();
  });

  afterAll(async () => {
    // Devolve os jogos ao estado original: outras suítes leem os mesmos `Match`.
    for (const [id, kickoffAt] of kickoffsOriginais) {
      await prisma.match.update({ where: { id }, data: { kickoffAt } });
    }
    await prisma.match.updateMany({
      where: { competitionCode: 'BSA', matchday: RODADA },
      data: { status: 'TIMED', homeScore: null, awayScore: null },
    });
    await prisma.pool.deleteMany({ where: { createdById: admin.id } });
    await prisma.user.deleteMany({ where: { email: { contains: 'cc2-' } } });
    await app.close();
  });

  const ranking = () =>
    request(app.getHttpServer())
      .get(`/pools/${poolId}/ranking`)
      .set('Authorization', `Bearer ${ana.token}`);

  it('CC-2-06 o cálculo usa a tabela do bolão, não 10/5/3', async () => {
    const rows = ((await ranking().expect(200)).body as Ranking).rows;
    const ana_ = rows.find((r) => r.displayName === 'Ana')!;
    const bruno_ = rows.find((r) => r.displayName === 'Bruno')!;

    // Com 10/5/3 seriam 18 e 15. Com a tabela do bolão, 35 e 30.
    expect(ana_.points).toBe(35);
    expect(bruno_.points).toBe(30);
  });

  it('CC-2-06b o extrato explica o ponto pela tabela do bolão', async () => {
    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/matchdays/${RODADA}/statement`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(200);

    const linhas = (
      res.body as { rows: { points: number | null; band: string | null }[] }
    ).rows.filter((l) => l.points !== null);

    // A faixa é a mesma regra; o **valor** é o do bolão. Sem isso o extrato
    // explicaria o ponto com um número que não é o do ranking.
    const exato = linhas.find((l) => l.band === 'PLACAR_EXATO');
    expect(exato?.points).toBe(20);
    const saldo = linhas.find((l) => l.band === 'VENCEDOR_E_SALDO');
    expect(saldo?.points).toBe(10);
    const soVencedor = linhas.find((l) => l.band === 'SO_VENCEDOR');
    expect(soVencedor?.points).toBe(5);
  });

  it('CC-2-07 com empate em pontos, a ordem do bolão decide e é explicada', async () => {
    // Caso **discriminante**: montado para dar um vencedor diferente conforme a
    // ordem de desempate. Empate em 20 pontos, Ana com 4 acertos de vencedor e
    // 0 exatos, Bruno com 1 exato e 1 acerto de vencedor.
    //   ordem do bolão (WINNERS primeiro) → Ana na frente
    //   ordem padrão   (EXACT_SCORES)     → Bruno na frente
    // Se o serviço voltar a ignorar `tiebreakOrder`, este teste fica vermelho —
    // era o defeito de PR-5, invisível porque a ordem padrão "parecia certa".
    const res = await request(app.getHttpServer())
      .post('/pools')
      .set('Authorization', `Bearer ${admin.token}`)
      .send({
        name: 'Bolão do desempate por vencedor',
        competitionCode: 'BSA',
        matchdayFrom: RODADA,
        matchdayTo: RODADA,
        rules: {
          pointsExact: 20,
          pointsWinnerDiff: 10,
          pointsWinner: 5,
          tiebreakOrder: ['WINNERS', 'EXACT_SCORES'],
        },
      })
      .expect(201);
    const pool2 = (res.body as { id: string }).id;

    for (const u of [ana, bruno]) {
      await prisma.poolMember.create({
        data: { poolId: pool2, userId: u.id, role: 'MEMBER' },
      });
    }

    // Quatro jogos ainda intocados desta rodada.
    const usados = jogos.slice(4, 8);
    await prisma.match.updateMany({
      where: { id: { in: usados } },
      data: { kickoffAt: new Date(Date.now() + 60 * 60 * 1000) },
    });

    const palpites: Record<string, [number, number][]> = {
      // Ana: acerta o vencedor nos quatro, sempre com saldo errado → 4 × 5 = 20
      [ana.token]: [
        [3, 0],
        [4, 0],
        [5, 0],
        [6, 0],
      ],
      // Bruno: um exato (20) e três derrotas palpitadas → 20
      [bruno.token]: [
        [2, 1],
        [0, 2],
        [0, 3],
        [0, 4],
      ],
    };
    for (const [token, lista] of Object.entries(palpites)) {
      await request(app.getHttpServer())
        .post(`/pools/${pool2}/predictions`)
        .set('Authorization', `Bearer ${token}`)
        .send({
          predictions: lista.map(([h, a], k) => ({
            matchId: usados[k],
            homeScore: h,
            awayScore: a,
          })),
        })
        .expect(201);
    }

    await prisma.match.updateMany({
      where: { id: { in: usados } },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();

    const rows = (
      (
        await request(app.getHttpServer())
          .get(`/pools/${pool2}/ranking`)
          .set('Authorization', `Bearer ${ana.token}`)
          .expect(200)
      ).body as Ranking
    ).rows;

    const ana_ = rows.find((r) => r.displayName === 'Ana')!;
    const bruno_ = rows.find((r) => r.displayName === 'Bruno')!;
    expect(ana_.points).toBe(20);
    expect(bruno_.points).toBe(20);
    expect(bruno_.exactScores).toBe(1);
    expect(ana_.exactScores).toBe(0);

    // A ordem do bolão manda: mais acertos de vencedor vence o empate.
    expect(rows[0].displayName).toBe('Ana');
    // E a tela precisa **dizer** qual critério decidiu — sem isso o segundo
    // colocado só vê que perdeu, e vai reclamar no grupo.
    expect(ana_.decidedBy).toBe('WINNERS');
    expect(bruno_.decidedBy).toBe('WINNERS');
  });
});
