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;
  position: number;
  points: number;
  exactScores: number;
  decidedBy: string;
  sharedPosition: boolean;
};
type Ranking = { total: number; rows: Linha[] };
type RankingRodada = Ranking & {
  partial: boolean;
  totalMatches: number;
  scoredMatches: number;
};
type Extrato = {
  total: number;
  rows: {
    estado: string;
    points: number | null;
    band: string | null;
    myPrediction: unknown;
    officialScore: unknown;
  }[];
};

describe('Ranking e extrato (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 status do mesmo jogo interferem uma na outra.
  const RODADA = 34;

  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();
    // 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);

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

    const pool = await request(app.getHttpServer())
      .post('/pools')
      .set('Authorization', `Bearer ${admin.token}`)
      .send({
        name: 'Bolão ranking',
        competitionCode: 'BSA',
        matchdayFrom: RODADA,
        matchdayTo: RODADA,
      })
      .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);

    // Cenário montado: 3 jogos oficiais 2x1.
    // Ana: 2x1 (exato, 10), 2x1 (exato, 10), 0x1 (errou, 0)  → 20, 2 exatos
    // Bruno: 3x2 (vencedor+saldo, 5), 3x2 (5), 5x0 (só vencedor, 3) → 13
    const palpites: Record<string, [number, number][]> = {
      [ana.token]: [
        [2, 1],
        [2, 1],
        [0, 1],
      ],
      [bruno.token]: [
        [3, 2],
        [3, 2],
        [5, 0],
      ],
    };

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

    // Apura os 3 primeiros jogos com 2x1.
    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.
    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: 'rank-' } } });
    await app.close();
  });

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

  it('ranking geral ordena pelos pontos apurados', async () => {
    const r = (await ranking(ana.token).expect(200)).body as Ranking;

    const porNome = new Map(r.rows.map((l) => [l.displayName, l]));
    expect(porNome.get('Ana')?.points).toBe(20);
    expect(porNome.get('Bruno')?.points).toBe(13);
    expect(porNome.get('Ana')?.position).toBe(1);
    expect(porNome.get('Bruno')?.position).toBe(2);
  });

  it('conta os placares exatos para o desempate', async () => {
    const r = (await ranking(ana.token).expect(200)).body as Ranking;
    expect(r.rows.find((l) => l.displayName === 'Ana')?.exactScores).toBe(2);
    expect(r.rows.find((l) => l.displayName === 'Bruno')?.exactScores).toBe(0);
  });

  it('participante sem palpite nenhum aparece com zero, não desaparece', async () => {
    const r = (await ranking(ana.token).expect(200)).body as Ranking;
    const oAdmin = r.rows.find((l) => l.displayName === 'Admin');
    expect(oAdmin).toBeDefined();
    expect(oAdmin?.points).toBe(0);
  });

  it('expõe qual critério decidiu a posição', async () => {
    const r = (await ranking(ana.token).expect(200)).body as Ranking;
    expect(r.rows[0].decidedBy).toBe('PONTOS');
    expect(r.rows.every((l) => l.decidedBy !== undefined)).toBe(true);
  });

  it('quem saiu do bolão fica fora do ranking ativo', async () => {
    await prisma.poolMember.update({
      where: { poolId_userId: { poolId, userId: bruno.id } },
      data: { leftAt: new Date() },
    });

    const r = (await ranking(ana.token).expect(200)).body as Ranking;
    expect(r.rows.some((l) => l.displayName === 'Bruno')).toBe(false);

    // Mas o palpite dele permanece no histórico.
    expect(
      await prisma.prediction.count({ where: { poolId, userId: bruno.id } }),
    ).toBe(3);

    await prisma.poolMember.update({
      where: { poolId_userId: { poolId, userId: bruno.id } },
      data: { leftAt: null },
    });
  });

  it('ranking da rodada avisa que a apuração está parcial', async () => {
    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/matchdays/${RODADA}/ranking`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(200);

    const r = res.body as RankingRodada;
    expect(r.totalMatches).toBe(10);
    expect(r.scoredMatches).toBe(3);
    expect(r.partial).toBe(true);
  });

  it('extrato mostra a conta por jogo, com o motivo do ponto', async () => {
    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/matchdays/${RODADA}/statement`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(200);

    const e = res.body as Extrato;
    const apurados = e.rows.filter((l) => l.estado === 'APURADO');

    expect(apurados).toHaveLength(3);
    expect(apurados.filter((l) => l.band === 'PLACAR_EXATO')).toHaveLength(2);
    expect(apurados.find((l) => l.band === 'ERROU')?.points).toBe(0);
  });

  it('jogo terminado sem palpite aparece como SEM_PALPITE, não como espera', async () => {
    // O 4º jogo termina sem que ninguém tenha palpitado nele.
    await prisma.match.update({
      where: { id: jogos[3] },
      data: { status: 'FINISHED', homeScore: 1, awayScore: 0 },
    });

    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/matchdays/${RODADA}/statement`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(200);

    const e = res.body as Extrato;
    const semPalpite = e.rows.filter((l) => l.estado === 'SEM_PALPITE');

    // O resultado chegou; o palpite é que faltou. Chamar isso de "aguardando
    // resultado" esconde do participante que ele esqueceu de palpitar.
    expect(semPalpite.length).toBeGreaterThan(0);
    for (const l of semPalpite) {
      expect(l.myPrediction).toBeNull();
      expect(l.officialScore).not.toBeNull();
      // Sem palpite em jogo apurado vale 0, não "sem informação".
      expect(l.points).toBe(0);
    }

    await prisma.match.update({
      where: { id: jogos[3] },
      data: { status: 'TIMED', homeScore: null, awayScore: null },
    });
  });

  it('a soma do extrato bate com o total do ranking', async () => {
    const extrato = (
      await request(app.getHttpServer())
        .get(`/pools/${poolId}/matchdays/${RODADA}/statement`)
        .set('Authorization', `Bearer ${ana.token}`)
        .expect(200)
    ).body as Extrato;

    const r = (await ranking(ana.token).expect(200)).body as Ranking;
    const noRanking = r.rows.find((l) => l.displayName === 'Ana')?.points;

    // Se estes divergirem, o app perde a credibilidade que o extrato existe
    // para criar.
    expect(extrato.total).toBe(noRanking);
  });

  it('extrato inclui jogos sem palpite, não os omite', async () => {
    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/matchdays/${RODADA}/statement`)
      .set('Authorization', `Bearer ${admin.token}`)
      .expect(200);

    const e = res.body as Extrato;
    expect(e.rows).toHaveLength(10);
    expect(e.rows.every((l) => l.myPrediction === null)).toBe(true);
  });

  it('quem não participa não vê ranking nem extrato', async () => {
    const estranho = await criarConta('rank-estranho@e2e.teste', 'Estranho');

    await ranking(estranho.token).expect(403);
    await request(app.getHttpServer())
      .get(`/pools/${poolId}/matchdays/${RODADA}/statement`)
      .set('Authorization', `Bearer ${estranho.token}`)
      .expect(403);
  });

  it('paginação limita as linhas devolvidas', async () => {
    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/ranking?limit=1&offset=0`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(200);

    const r = res.body as Ranking;
    expect(r.rows).toHaveLength(1);
    expect(r.total).toBeGreaterThan(1);
  });

  it('PR-5-08 o endpoint aplica a ordem de desempate do bolão', async () => {
    // Plano docs/testes/PR-5-plano.md, caso PR-5-08. O defeito vivia aqui, não
    // no `ordenar`: a ordem era persistida e o serviço nunca a lia.
    const antes = (await ranking(ana.token).expect(200)).body as Ranking;
    const ordemPadrao = antes.rows.map((r) => r.displayName);

    // Cenário controlado: Ana com mais exatos, Bruno com mais acertos, mesmo
    // total de pontos.
    await prisma.prediction.updateMany({
      where: { poolId },
      data: { points: 0, band: 'ERROU' },
    });
    const doAna = await prisma.prediction.findMany({
      where: { poolId, userId: ana.id },
      select: { id: true },
      take: 2,
    });
    const doBruno = await prisma.prediction.findMany({
      where: { poolId, userId: bruno.id },
      select: { id: true },
      take: 3,
    });
    // Ana: 2 exatos × 10 = 20 pontos, 2 acertos.
    await prisma.prediction.updateMany({
      where: { id: { in: doAna.map((p) => p.id) } },
      data: { points: 10, band: 'PLACAR_EXATO' },
    });
    // Bruno: 20 pontos em 3 acertos, nenhum exato.
    await prisma.prediction.updateMany({
      where: { id: { in: [doBruno[0].id, doBruno[1].id] } },
      data: { points: 5, band: 'VENCEDOR_E_SALDO' },
    });
    await prisma.prediction.updateMany({
      where: { id: doBruno[2].id },
      data: { points: 10, band: 'VENCEDOR_E_SALDO' },
    });

    const comPadrao = (await ranking(ana.token).expect(200)).body as Ranking;
    expect(comPadrao.rows[0].displayName).toBe('Ana');
    expect(comPadrao.rows[0].decidedBy).toBe('EXACT_SCORES');

    // Inverte a ordem **no bolão** e relê o mesmo endpoint.
    await prisma.pool.update({
      where: { id: poolId },
      data: { tiebreakOrder: ['WINNERS', 'EXACT_SCORES'] },
    });

    const comInvertida = (await ranking(ana.token).expect(200)).body as Ranking;
    expect(comInvertida.rows[0].displayName).toBe('Bruno');
    expect(comInvertida.rows[0].decidedBy).toBe('WINNERS');

    await prisma.pool.update({
      where: { id: poolId },
      data: { tiebreakOrder: ['EXACT_SCORES', 'WINNERS'] },
    });
    expect(ordemPadrao.length).toBeGreaterThan(0);
  });

  it('CC-9-03 remoção pelo admin não recalcula o ponto de quem ficou', async () => {
    // Plano docs/testes/CC-9-plano.md. A posição pode subir por o removido sair
    // da lista; o que **não** pode mudar é o ponto de alguém — senão o admin
    // ganha uma alavanca para alterar a classificação depois do jogo.
    const antes = ((await ranking(ana.token).expect(200)).body as Ranking).rows;
    const pontosAntes = new Map(antes.map((l) => [l.displayName, l.points]));

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

    const depois = ((await ranking(ana.token).expect(200)).body as Ranking).rows;
    expect(depois.some((l) => l.displayName === 'Bruno')).toBe(false);
    for (const linha of depois) {
      expect(linha.points).toBe(pontosAntes.get(linha.displayName));
    }
    // E o histórico do removido continua lá.
    expect(
      await prisma.prediction.count({ where: { poolId, userId: bruno.id } }),
    ).toBe(3);

    await prisma.poolMember.update({
      where: { poolId_userId: { poolId, userId: bruno.id } },
      data: { leftAt: null },
    });
  });

  it('PR-3 paginação do ranking recusa faixa inválida', async () => {
    // Mesma regra da lista de participantes (CC-7-08): ranking vazio por
    // `limit=0` seria lido como "ninguém pontuou".
    const como = (q: string) =>
      request(app.getHttpServer())
        .get(`/pools/${poolId}/ranking?${q}`)
        .set('Authorization', `Bearer ${ana.token}`);

    await como('limit=0').expect(400);
    await como('offset=-1').expect(400);
    await como('limit=100000').expect(200);

    // E o ranking da rodada aplica a mesma regra, para o app não descobrir a
    // diferença na tela.
    await request(app.getHttpServer())
      .get(`/pools/${poolId}/matchdays/${RODADA}/ranking?limit=0`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(400);
  });
});
