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';
import { SyncService } from '../src/sync/sync.service';

type PalpitesDoJogo = {
  revealed: boolean;
  predictions: {
    homeScore: number;
    awayScore: number;
    points: number | null;
  }[];
};

describe('Sigilo, apuração e sincronização (e2e)', () => {
  let app: INestApplication<App>;
  let prisma: PrismaService;
  let sync: SyncService;
  let scoring: ScoringService;
  let admin: { id: string; token: string };
  let membro: { id: string; token: string };
  let poolId: string;

  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 palpitesDoJogo = (matchId: string, token: string) =>
    request(app.getHttpServer())
      .get(`/pools/${poolId}/matches/${matchId}/predictions`)
      .set('Authorization', `Bearer ${token}`);

  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);
    sync = app.get(SyncService);
    scoring = app.get(ScoringService);

    admin = await criarConta('sigilo-admin@e2e.teste', 'Admin');
    membro = await criarConta('sigilo-membro@e2e.teste', 'Membro');

    const pool = await request(app.getHttpServer())
      .post('/pools')
      .set('Authorization', `Bearer ${admin.token}`)
      .send({
        name: 'Bolão sigilo',
        competitionCode: 'BSA',
        // Rodada exclusiva: esta suíte muta o status dos jogos, e `Match` é
        // compartilhado entre bolões.
        matchdayFrom: 33,
        matchdayTo: 33,
      })
      .expect(201);
    poolId = (pool.body as { id: string }).id;

    await prisma.poolMember.create({
      data: { poolId, userId: membro.id, role: 'MEMBER' },
    });

    // Todos palpitam na rodada aberta.
    const jogos = await prisma.poolMatch.findMany({
      where: { poolId },
      select: { matchId: true },
    });
    for (const token of [admin.token, membro.token]) {
      await request(app.getHttpServer())
        .post(`/pools/${poolId}/predictions`)
        .set('Authorization', `Bearer ${token}`)
        .send({
          predictions: jogos.map((j, i) => ({
            matchId: j.matchId,
            homeScore: (i + (token === admin.token ? 0 : 1)) % 4,
            awayScore: i % 3,
          })),
        })
        .expect(201);
    }
  });

  afterAll(async () => {
    await prisma.match.updateMany({
      where: { competitionCode: 'BSA', matchday: 33 },
      data: { status: 'TIMED', homeScore: null, awayScore: null },
    });
    await prisma.pool.deleteMany({ where: { createdById: admin.id } });
    await prisma.user.deleteMany({ where: { email: { contains: 'sigilo-' } } });
    await app.close();
  });

  const primeiroJogo = async () => {
    const v = await prisma.poolMatch.findFirst({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    return v!.matchId;
  };

  it('jogo não terminado: a API NÃO devolve palpite de terceiro', async () => {
    const matchId = await primeiroJogo();
    const res = await palpitesDoJogo(matchId, membro.token).expect(200);
    const body = res.body as PalpitesDoJogo;

    expect(body.revealed).toBe(false);
    expect(body.predictions).toHaveLength(0);
  });

  it('nem para o admin — o sigilo não tem exceção de papel', async () => {
    const matchId = await primeiroJogo();
    const res = await palpitesDoJogo(matchId, admin.token).expect(200);
    expect((res.body as PalpitesDoJogo).revealed).toBe(false);
  });

  it('jogo em andamento continua oculto — libera no FIM, não no apito', async () => {
    const matchId = await primeiroJogo();
    await prisma.match.update({
      where: { id: matchId },
      data: { status: 'IN_PLAY', kickoffAt: new Date(Date.now() - 1000) },
    });

    const res = await palpitesDoJogo(matchId, membro.token).expect(200);
    expect((res.body as PalpitesDoJogo).revealed).toBe(false);
  });

  it('jogo terminado libera os palpites de todos', async () => {
    const matchId = await primeiroJogo();
    await prisma.match.update({
      where: { id: matchId },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });

    const res = await palpitesDoJogo(matchId, membro.token).expect(200);
    const body = res.body as PalpitesDoJogo;

    expect(body.revealed).toBe(true);
    expect(body.predictions).toHaveLength(2);
  });

  it('apura os pontos pela tabela do bolão quando o jogo termina', async () => {
    const matchId = await primeiroJogo();
    // Palpites conhecidos: admin 0x0, membro 1x0. Oficial 2x1.
    await prisma.prediction.updateMany({
      where: { matchId, userId: admin.id },
      data: { homeScore: 2, awayScore: 1, points: null, scoredAt: null },
    });
    await prisma.prediction.updateMany({
      where: { matchId, userId: membro.id },
      data: { homeScore: 3, awayScore: 2, points: null, scoredAt: null },
    });

    // Apuração direta, não via sincronização: `sincronizar` releria da fonte e
    // sobrescreveria o placar montado aqui — comportamento correto dela (a
    // fonte é autoritativa), mas que apagaria o cenário deste teste.
    const apurados = await scoring.apurarPendentes();
    expect(apurados).toBeGreaterThanOrEqual(2);

    const doAdmin = await prisma.prediction.findFirst({
      where: { matchId, userId: admin.id },
      select: { points: true, scoredHomeScore: true, scoredAwayScore: true },
    });
    const doMembro = await prisma.prediction.findFirst({
      where: { matchId, userId: membro.id },
      select: { points: true },
    });

    // 2x1 exato = 10 · 3x2 (mesmo vencedor e saldo) = 5
    expect(doAdmin?.points).toBe(10);
    expect(doMembro?.points).toBe(5);

    // O placar que gerou o ponto fica guardado, para o extrato mostrar a conta.
    expect(doAdmin?.scoredHomeScore).toBe(2);
    expect(doAdmin?.scoredAwayScore).toBe(1);
  });

  it('endpoint agregado não vaza palpite de jogo não terminado', async () => {
    // A rodada traz `myPrediction`, mas nunca o dos outros.
    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/matchdays/33`)
      .set('Authorization', `Bearer ${membro.token}`)
      .expect(200);

    const texto = JSON.stringify(res.body);
    expect(texto).toContain('myPrediction');
    expect(texto).not.toContain('displayName');
    expect(texto).not.toContain('Admin');
  });

  it('sincronização detecta adiamento e reabre o palpite', async () => {
    const matchId = await primeiroJogo();
    const futuro = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000);

    // Simula o que a fonte informaria: horário novo, jogo voltando a TIMED.
    await prisma.match.update({
      where: { id: matchId },
      data: { kickoffAt: new Date(Date.now() - 60_000), status: 'FINISHED' },
    });

    const antes = await prisma.match.findUnique({
      where: { id: matchId },
      select: { kickoffAt: true },
    });

    await prisma.match.update({
      where: { id: matchId },
      data: {
        kickoffAt: futuro,
        status: 'TIMED',
        homeScore: null,
        awayScore: null,
      },
    });

    const depois = await prisma.match.findUnique({
      where: { id: matchId },
      select: { kickoffAt: true },
    });
    expect(depois!.kickoffAt.getTime()).not.toBe(antes!.kickoffAt.getTime());

    // Com o prazo no futuro, o palpite volta a aceitar edição.
    const res = await request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${membro.token}`)
      .send({ predictions: [{ matchId, homeScore: 4, awayScore: 0 }] })
      .expect(201);

    expect((res.body as { savedCount: number }).savedCount).toBe(1);
  });

  it('fonte indisponível não corrompe o que já está armazenado', async () => {
    const antes = await prisma.match.count({
      where: { competitionCode: 'BSA' },
    });

    process.env.FONTE_FAKE_INDISPONIVEL = 'true';
    const r = await sync.sincronizar('BSA');
    process.env.FONTE_FAKE_INDISPONIVEL = 'false';

    expect(r.falhou).toBe(true);
    expect(
      await prisma.match.count({ where: { competitionCode: 'BSA' } }),
    ).toBe(antes);
  });

  it('PR-1-12 jogo terminado sem placar não pontua ninguém', async () => {
    // Plano docs/testes/PR-1-plano.md, caso PR-1-12.
    const jogo = await prisma.poolMatch.findFirstOrThrow({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'desc' } },
      select: { matchId: true },
    });

    await prisma.match.update({
      where: { id: jogo.matchId },
      // A fonte gratuita atrasa: terminado sem placar é estado real.
      data: { status: 'FINISHED', homeScore: null, awayScore: null },
    });
    // A suíte já tem palpite deste participante em jogos do bolão: `upsert`
    // para o caso não depender de qual jogo caiu no `findFirst`.
    await prisma.prediction.upsert({
      where: {
        poolId_matchId_userId: {
          poolId,
          matchId: jogo.matchId,
          userId: membro.id,
        },
      },
      create: {
        poolId,
        matchId: jogo.matchId,
        userId: membro.id,
        homeScore: 1,
        awayScore: 0,
      },
      update: {
        homeScore: 1,
        awayScore: 0,
        points: null,
        band: null,
        scoredAt: null,
      },
    });

    await scoring.apurarPendentes();

    const palpite = await prisma.prediction.findFirstOrThrow({
      where: { matchId: jogo.matchId, userId: membro.id },
      select: { points: true, band: true, scoredAt: true },
    });
    // Zero aqui acusaria o participante de errar um jogo que ninguém apurou.
    expect(palpite.points).toBeNull();
    expect(palpite.band).toBeNull();
    expect(palpite.scoredAt).toBeNull();

    await prisma.prediction.deleteMany({ where: { matchId: jogo.matchId } });
    await prisma.match.update({
      where: { id: jogo.matchId },
      data: { status: 'TIMED' },
    });
  });

  it('PR-1-13 apurar duas vezes não dobra ponto nem duplica registro', async () => {
    // Plano docs/testes/PR-1-plano.md, caso PR-1-13.
    const jogo = await prisma.poolMatch.findFirstOrThrow({
      where: { poolId },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });

    await prisma.match.update({
      where: { id: jogo.matchId },
      data: { status: 'FINISHED', homeScore: 2, awayScore: 1 },
    });
    await prisma.prediction.updateMany({
      where: { matchId: jogo.matchId },
      data: { points: null, band: null, scoredAt: null },
    });

    await scoring.apurarPendentes();
    const depoisDeUma = await prisma.prediction.findMany({
      where: { matchId: jogo.matchId },
      select: { id: true, points: true },
      orderBy: { id: 'asc' },
    });

    // Job rodando duas vezes no mesmo intervalo é cenário real.
    await scoring.apurarPendentes();
    await scoring.apurarJogo(jogo.matchId);
    const depoisDeTres = await prisma.prediction.findMany({
      where: { matchId: jogo.matchId },
      select: { id: true, points: true },
      orderBy: { id: 'asc' },
    });

    expect(depoisDeTres).toEqual(depoisDeUma);
  });

  it('PP-7-06 quem não participa do bolão não lê palpite de ninguém', async () => {
    // Plano docs/testes/PP-7-plano.md, caso PP-7-06. Nem sigilo nem revelação:
    // quem não está no bolão não tem o que ver, mesmo em jogo terminado.
    const deFora = await criarConta('sigilo-fora@e2e.teste', 'DeFora');
    const jogo = await prisma.poolMatch.findFirstOrThrow({
      where: { poolId },
      select: { matchId: true },
    });

    await request(app.getHttpServer())
      .get(`/pools/${poolId}/matches/${jogo.matchId}/predictions`)
      .set('Authorization', `Bearer ${deFora.token}`)
      .expect(403);

    // E também não alcança a visão agregada do admin.
    await request(app.getHttpServer())
      .get(`/pools/${poolId}/members/prediction-status/33`)
      .set('Authorization', `Bearer ${deFora.token}`)
      .expect(403);
  });
});
