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 { CorrecaoService } from '../src/scoring/correcao.service';
import { ScoringService } from '../src/scoring/scoring.service';

type Aviso = { kind: string; title: string; body: string };
type Historico = {
  previousHome: number | null;
  newHome: number;
  source: string;
  correctedBy: { displayName: string } | null;
}[];
type Ranking = {
  rows: { displayName: string; points: number; position: number }[];
};

describe('Correção de placar, recálculo e aviso (e2e)', () => {
  let app: INestApplication<App>;
  let prisma: PrismaService;
  let scoring: ScoringService;
  let correcao: CorrecaoService;
  let admin: { id: string; token: string };
  let ana: { id: string; token: string };
  let bruno: { id: string; token: string };
  let poolId: string;
  let jogo: string;
  /** Horário original de cada jogo da rodada, para o teardown devolver. */
  const kickoffsOriginais = new Map<string, Date>();

  // Rodada exclusiva: esta suíte muta status e placar.
  const RODADA = 36;

  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);
    correcao = app.get(CorrecaoService);

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

    const pool = await request(app.getHttpServer())
      .post('/pools')
      .set('Authorization', `Bearer ${admin.token}`)
      .send({
        name: 'Bolão correção',
        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 v = await prisma.poolMatch.findFirst({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    jogo = v!.matchId;

    const daRodada = await prisma.match.findMany({
      where: { competitionCode: 'BSA', matchday: RODADA },
      select: { id: true, kickoffAt: true },
    });
    for (const m of daRodada) kickoffsOriginais.set(m.id, m.kickoffAt);

    // Ana palpita 2x1, Bruno 1x0.
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${ana.token}`)
      .send({ predictions: [{ matchId: jogo, homeScore: 2, awayScore: 1 }] })
      .expect(201);
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${bruno.token}`)
      .send({ predictions: [{ matchId: jogo, homeScore: 1, awayScore: 0 }] })
      .expect(201);

    // Apura com 2x1: Ana exato (10), Bruno vencedor+saldo (5).
    await prisma.match.update({
      where: { id: jogo },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await scoring.apurarPendentes();
  });

  afterAll(async () => {
    // `Match` é global entre bolões e alguns casos mexem no **horário** para
    // exercitar W.O. e jogo sem placar. Sem devolver o kickoff, o palpite do
    // `beforeAll` da próxima execução é recusado por prazo e a suíte inteira
    // cai — foi o que aconteceu em 06/08.
    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: 'corr-' } } });
    await app.close();
  });

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

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

  it('parte de Ana na frente de Bruno', 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(10);
    expect(porNome.get('Bruno')?.points).toBe(5);
    expect(porNome.get('Ana')?.position).toBe(1);
  });

  it('correção oficial recalcula e inverte o ranking', async () => {
    // Placar corrigido para 1x0: agora Bruno acertou exato e Ana só o vencedor.
    const r = await correcao.aplicar(jogo, 1, 0, 'OFFICIAL');
    expect(r.afetados).toBe(2);

    const rank = (await ranking(ana.token).expect(200)).body as Ranking;
    const porNome = new Map(rank.rows.map((l) => [l.displayName, l]));

    expect(porNome.get('Bruno')?.points).toBe(10);
    // Ana palpitou 2x1 e o oficial virou 1x0: mesmo vencedor e mesmo saldo
    // (+1), então cai em "vencedor + saldo" = 5. Não é 3.
    expect(porNome.get('Ana')?.points).toBe(5);
    expect(porNome.get('Bruno')?.position).toBe(1);
    // Ana caiu — é exatamente o "ranking que muda para trás" do PRD.
    expect(porNome.get('Ana')?.position).toBe(2);
  });

  it('avisa os afetados dizendo qual jogo e qual placar mudou', async () => {
    const lista = (await avisos(ana.token).expect(200)).body as Aviso[];
    expect(lista.length).toBeGreaterThan(0);

    const aviso = lista[0];
    expect(aviso.kind).toBe('PLACAR_CORRIGIDO');
    // O critério exige dizer o jogo e os dois placares, não "seus pontos mudaram".
    expect(aviso.body).toMatch(/de 2x1/);
    expect(aviso.body).toMatch(/para 1x0/);
    expect(aviso.body).toMatch(/posição/i);
  });

  it('correção que não muda ponto de ninguém não gera aviso', async () => {
    const antes = await prisma.notification.count({
      where: { userId: ana.id },
    });

    // Reaplica o mesmo placar: nada muda.
    const r = await correcao.aplicar(jogo, 1, 0, 'OFFICIAL');
    expect(r.avisos).toBe(0);

    expect(await prisma.notification.count({ where: { userId: ana.id } })).toBe(
      antes,
    );
  });

  it('registra a trilha de auditoria da correção', async () => {
    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/matches/${jogo}/corrections`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(200);

    const h = res.body as Historico;
    expect(h.length).toBeGreaterThan(0);
    expect(h[0].previousHome).toBe(2);
    expect(h[0].newHome).toBe(1);
    expect(h[0].source).toBe('OFFICIAL');
  });

  it('admin corrige à mão e a alteração fica visível a todos como manual', async () => {
    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/matches/${jogo}/score`)
      .set('Authorization', `Bearer ${admin.token}`)
      .send({ homeScore: 3, awayScore: 0 })
      .expect(200);

    // Qualquer participante vê quem corrigiu — única mitigação do poder do admin.
    const h = (
      await request(app.getHttpServer())
        .get(`/pools/${poolId}/matches/${jogo}/corrections`)
        .set('Authorization', `Bearer ${bruno.token}`)
        .expect(200)
    ).body as Historico;

    expect(h[0].source).toBe('MANUAL');
    expect(h[0].correctedBy?.displayName).toBe('Admin');
  });

  it('o aviso da correção manual diz que foi o administrador', async () => {
    const lista = (await avisos(bruno.token).expect(200)).body as Aviso[];
    const manual = lista.find((a) => a.kind === 'PLACAR_CORRIGIDO_MANUAL');
    expect(manual).toBeDefined();
    expect(manual?.body).toMatch(/corrigido pelo administrador/i);
  });

  it('participante comum não corrige placar', async () => {
    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/matches/${jogo}/score`)
      .set('Authorization', `Bearer ${ana.token}`)
      .send({ homeScore: 9, awayScore: 9 })
      .expect(403);
  });

  it('recusa correção de jogo que ainda não começou', async () => {
    // Exclui o jogo já corrigido: ele está FINISHED e por isso é permitido.
    const futuro = await prisma.poolMatch.findFirst({
      where: {
        poolId,
        matchId: { not: jogo },
        match: { kickoffAt: { gt: new Date() }, status: { not: 'FINISHED' } },
      },
      select: { matchId: true },
    });
    expect(futuro).not.toBeNull();

    if (futuro) {
      await request(app.getHttpServer())
        .patch(`/pools/${poolId}/matches/${futuro.matchId}/score`)
        .set('Authorization', `Bearer ${admin.token}`)
        .send({ homeScore: 1, awayScore: 1 })
        .expect(400);
    }
  });

  it('a fonte oficial prevalece sobre a correção manual', async () => {
    // Admin deixou 3x0; a fonte informa 2x2 depois.
    await correcao.aplicar(jogo, 2, 2, 'OFFICIAL');

    const jogoAtual = await prisma.match.findUnique({
      where: { id: jogo },
      select: { homeScore: true, awayScore: true },
    });
    expect(jogoAtual?.homeScore).toBe(2);
    expect(jogoAtual?.awayScore).toBe(2);

    // E as duas alterações ficam no histórico.
    const h = (
      await request(app.getHttpServer())
        .get(`/pools/${poolId}/matches/${jogo}/corrections`)
        .set('Authorization', `Bearer ${ana.token}`)
        .expect(200)
    ).body as Historico;

    expect(h.some((c) => c.source === 'MANUAL')).toBe(true);
    expect(h.some((c) => c.source === 'OFFICIAL')).toBe(true);
  });

  it('recusa placar inválido na correção', async () => {
    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/matches/${jogo}/score`)
      .set('Authorization', `Bearer ${admin.token}`)
      .send({ homeScore: -1, awayScore: 0 })
      .expect(400);
  });

  it('avisos são do bolão da rota, não de todos os bolões', async () => {
    // Outro bolão do mesmo participante, com aviso próprio gravado à mão.
    const outro = await prisma.pool.create({
      data: {
        name: 'Bolão paralelo',
        competitionCode: 'BSA',
        matchdayFrom: 1,
        matchdayTo: 1,
        createdById: admin.id,
        members: { create: { userId: ana.id, role: 'MEMBER' } },
      },
      select: { id: true },
    });
    await prisma.notification.create({
      data: {
        userId: ana.id,
        poolId: outro.id,
        kind: 'PLACAR_CORRIGIDO',
        title: 'Aviso do outro bolão',
        body: 'não deve aparecer no bolão desta rota',
      },
    });

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

    const titulos = (res.body as { title: string }[]).map((a) => a.title);
    // Aviso do bolão errado manda o participante procurar no ranking errado.
    expect(titulos).not.toContain('Aviso do outro bolão');

    const doOutro = await request(app.getHttpServer())
      .get(`/pools/${outro.id}/notifications`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(200);
    expect((doOutro.body as { title: string }[]).map((a) => a.title)).toContain(
      'Aviso do outro bolão',
    );

    await prisma.pool.delete({ where: { id: outro.id } });
  });

  it('marcar como lido só afeta o aviso do próprio participante', async () => {
    const aviso = await prisma.notification.findFirstOrThrow({
      where: { userId: ana.id, poolId, readAt: null },
    });

    // Bruno tentando marcar o aviso de Ana: responde 204 mas não altera nada.
    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/notifications/${aviso.id}/read`)
      .set('Authorization', `Bearer ${bruno.token}`)
      .expect(204);
    expect(
      (await prisma.notification.findUniqueOrThrow({ where: { id: aviso.id } }))
        .readAt,
    ).toBeNull();

    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/notifications/${aviso.id}/read`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(204);
    expect(
      (await prisma.notification.findUniqueOrThrow({ where: { id: aviso.id } }))
        .readAt,
    ).not.toBeNull();
  });

  it('histórico de correção exige participar do bolão da rota', async () => {
    const deFora = await criarConta('corr-fora@e2e.teste', 'DeFora');

    // Antes, qualquer conta autenticada lia o histórico de qualquer jogo — e
    // com ele o nome do admin que corrigiu num bolão alheio.
    await request(app.getHttpServer())
      .get(`/pools/${poolId}/matches/${jogo}/corrections`)
      .set('Authorization', `Bearer ${deFora.token}`)
      .expect(403);

    await request(app.getHttpServer())
      .get(`/pools/${poolId}/matches/${jogo}/corrections`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(200);
  });

  it('jogo de fora do bolão responde 404 no histórico', async () => {
    const alheio = await prisma.match.findFirstOrThrow({
      where: { competitionCode: 'BSA', matchday: 2 },
      select: { id: true },
    });

    await request(app.getHttpServer())
      .get(`/pools/${poolId}/matches/${alheio.id}/corrections`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(404);
  });

  it('o extrato marca o jogo corrigido e mostra o placar anterior', async () => {
    const antes = await prisma.match.findUniqueOrThrow({
      where: { id: jogo },
      select: { homeScore: true, awayScore: true, matchday: true },
    });

    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/matches/${jogo}/score`)
      .set('Authorization', `Bearer ${admin.token}`)
      .send({ homeScore: 5, awayScore: 5 })
      .expect(200);

    const extrato = (
      await request(app.getHttpServer())
        .get(`/pools/${poolId}/matchdays/${antes.matchday}/statement`)
        .set('Authorization', `Bearer ${ana.token}`)
        .expect(200)
    ).body as {
      rows: {
        matchId: string;
        correction: {
          previousHome: number | null;
          previousAway: number | null;
          manual: boolean;
        } | null;
      }[];
    };

    const linha = extrato.rows.find((l) => l.matchId === jogo);
    // Ponto que cai sem explicação é indistinguível de bug — ou de fraude.
    expect(linha?.correction).not.toBeNull();
    expect(linha?.correction?.previousHome).toBe(antes.homeScore);
    expect(linha?.correction?.previousAway).toBe(antes.awayScore);
    expect(linha?.correction?.manual).toBe(true);

    // Jogo sem correção não ganha marca nenhuma.
    const semCorrecao = extrato.rows.find((l) => l.matchId !== jogo);
    expect(semCorrecao?.correction).toBeNull();
  });

  it('PR-10-03b jogo começado sem placar final é corrigível', async () => {
    // Plano docs/testes/PR-10-plano.md, caso PR-10-03b. Exigir FINISHED
    // deixaria o bolão preso para sempre num W.O., que nunca é reagendado nem
    // recebe placar da fonte. O que barra o abuso é o apito, não o fim.
    const vinculo = await prisma.poolMatch.findFirstOrThrow({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'desc' } },
      select: { matchId: true },
    });
    const alvo = vinculo.matchId;
    await prisma.match.update({
      where: { id: alvo },
      data: {
        status: 'POSTPONED',
        homeScore: null,
        awayScore: null,
        kickoffAt: new Date(Date.now() - 60 * 60 * 1000),
      },
    });

    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/matches/${alvo}/score`)
      .set('Authorization', `Bearer ${admin.token}`)
      .send({ homeScore: 3, awayScore: 0 })
      .expect(200);

    const depois = await prisma.match.findUniqueOrThrow({
      where: { id: alvo },
      select: { status: true, homeScore: true, awayScore: true },
    });
    expect(depois.status).toBe('FINISHED');
    expect(depois.homeScore).toBe(3);
  });

  it('PR-10-09 correção em bolão encerrado troca o campeão', async () => {
    // Plano docs/testes/PR-10-plano.md, caso PR-10-09, e CV-1-07. Encerramento
    // não congela nada: o campeão é calculado na leitura (decisão de 03/08).
    await prisma.pool.update({
      where: { id: poolId },
      data: { closedAt: new Date() },
    });

    const antes = (
      await request(app.getHttpServer())
        .get(`/pools/${poolId}/ranking`)
        .set('Authorization', `Bearer ${ana.token}`)
        .expect(200)
    ).body as { rows: { userId: string; displayName: string }[] };
    const campeaoAntes = antes.rows[0].displayName;

    // Zera tudo e monta uma vantagem clara para quem estava atrás.
    await prisma.prediction.updateMany({
      where: { poolId },
      data: { points: 0, band: 'ERROU' },
    });
    const doSegundo = await prisma.prediction.findFirstOrThrow({
      where: {
        poolId,
        userId: antes.rows[1].userId,
      },
      select: { id: true },
    });
    await prisma.prediction.updateMany({
      where: { id: doSegundo.id },
      data: { points: 50, band: 'PLACAR_EXATO' },
    });

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

    // Bolão encerrado e o campeão mudou: é o comportamento pretendido.
    expect(depois.rows[0].displayName).not.toBe(campeaoAntes);

    await prisma.pool.update({
      where: { id: poolId },
      data: { closedAt: null },
    });
  });

  it('PR-6-08 duas correções seguidas: o extrato mostra a última', async () => {
    // Plano docs/testes/PR-6-plano.md, caso PR-6-08.
    const alvo = await prisma.poolMatch.findFirstOrThrow({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true, match: { select: { matchday: true } } },
    });

    const corrigir = (casa: number, fora: number) =>
      request(app.getHttpServer())
        .patch(`/pools/${poolId}/matches/${alvo.matchId}/score`)
        .set('Authorization', `Bearer ${admin.token}`)
        .send({ homeScore: casa, awayScore: fora })
        .expect(200);

    await corrigir(2, 1);
    await corrigir(3, 1);

    const extrato = (
      await request(app.getHttpServer())
        .get(`/pools/${poolId}/matchdays/${alvo.match.matchday}/statement`)
        .set('Authorization', `Bearer ${ana.token}`)
        .expect(200)
    ).body as {
      rows: {
        matchId: string;
        correction: { previousHome: number | null } | null;
      }[];
    };

    const linha = extrato.rows.find((l) => l.matchId === alvo.matchId);
    // A **última** correção é a que explica o placar de agora.
    expect(linha?.correction?.previousHome).toBe(2);

    const historico = (
      await request(app.getHttpServer())
        .get(`/pools/${poolId}/matches/${alvo.matchId}/corrections`)
        .set('Authorization', `Bearer ${ana.token}`)
        .expect(200)
    ).body as { newHome: number }[];

    // E o histórico guarda as duas, em ordem decrescente.
    expect(historico.length).toBeGreaterThanOrEqual(2);
    expect(historico[0].newHome).toBe(3);
    expect(historico[1].newHome).toBe(2);
  });

  it('PR-6-07 correção de jogo sem placar anterior não inventa 0x0', async () => {
    // Plano docs/testes/PR-6-plano.md, caso PR-6-07. `0x0` seria um empate que
    // nunca existiu — e mudaria a leitura de quem palpitou empate.
    const alvo = await prisma.poolMatch.findFirstOrThrow({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'desc' } },
      select: { matchId: true, match: { select: { matchday: true } } },
    });

    await prisma.match.update({
      where: { id: alvo.matchId },
      data: {
        status: 'FINISHED',
        homeScore: null,
        awayScore: null,
        kickoffAt: new Date(Date.now() - 60 * 60 * 1000),
      },
    });
    await prisma.scoreCorrection.deleteMany({
      where: { matchId: alvo.matchId },
    });

    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/matches/${alvo.matchId}/score`)
      .set('Authorization', `Bearer ${admin.token}`)
      .send({ homeScore: 1, awayScore: 0 })
      .expect(200);

    const extrato = (
      await request(app.getHttpServer())
        .get(`/pools/${poolId}/matchdays/${alvo.match.matchday}/statement`)
        .set('Authorization', `Bearer ${ana.token}`)
        .expect(200)
    ).body as {
      rows: {
        matchId: string;
        correction: {
          previousHome: number | null;
          previousAway: number | null;
        } | null;
      }[];
    };

    const linha = extrato.rows.find((l) => l.matchId === alvo.matchId);
    expect(linha?.correction).not.toBeNull();
    expect(linha?.correction?.previousHome).toBeNull();
    expect(linha?.correction?.previousAway).toBeNull();
  });
});
