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 Jogo = {
  matchId: string;
  matchday: number;
  kickoffAt: string;
  deadlineOpen: boolean;
  myPrediction: { homeScore: number; awayScore: number } | null;
};
type Rodada = { serverTime: string; matches: Jogo[] };
type Resultado = {
  results: { matchId: string; saved: boolean; reason: string | null }[];
  savedCount: number;
  rejectedCount: number;
};

describe('Palpite, prazo e travamento de regras (e2e)', () => {
  let app: INestApplication<App>;
  let prisma: PrismaService;
  let admin: { id: string; token: string };
  let membro: { id: string; token: string };
  let estranho: { id: string; token: string };
  let poolId: string;

  const RODADA_FUTURA = 30;
  const RODADA_PASSADA = 2;

  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 rodada = (n: number, token = membro.token) =>
    request(app.getHttpServer())
      .get(`/pools/${poolId}/matchdays/${n}`)
      .set('Authorization', `Bearer ${token}`);

  const palpitar = (
    body: Record<string, unknown>,
    token = membro.token,
    pool = poolId,
  ) =>
    request(app.getHttpServer())
      .post(`/pools/${pool}/predictions`)
      .set('Authorization', `Bearer ${token}`)
      .send(body);

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

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

    const pool = await request(app.getHttpServer())
      .post('/pools')
      .set('Authorization', `Bearer ${admin.token}`)
      // Inclui rodada passada e futura: prazo fechado e aberto no mesmo bolão.
      .send({
        name: 'Bolão palpite',
        competitionCode: 'BSA',
        matchdayFrom: RODADA_PASSADA,
        matchdayTo: RODADA_FUTURA,
      })
      .expect(201);
    poolId = (pool.body as { id: string }).id;

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

  afterAll(async () => {
    await prisma.pool.deleteMany({ where: { createdById: admin.id } });
    await prisma.user.deleteMany({
      where: { email: { contains: 'palpite-' } },
    });
    await app.close();
  });

  it('a resposta traz o horário do servidor', async () => {
    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    const diff = Math.abs(new Date(r.serverTime).getTime() - Date.now());
    expect(diff).toBeLessThan(60_000);
  });

  it('rodada futura vem com prazo aberto; rodada passada, fechado', async () => {
    const futura = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    const passada = (await rodada(RODADA_PASSADA).expect(200)).body as Rodada;

    expect(futura.matches.length).toBe(10);
    expect(futura.matches.every((m) => m.deadlineOpen)).toBe(true);
    expect(passada.matches.every((m) => !m.deadlineOpen)).toBe(true);
  });

  it('registra a rodada inteira em uma requisição', async () => {
    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;

    const res = await palpitar({
      predictions: r.matches.map((m, i) => ({
        matchId: m.matchId,
        homeScore: i % 4,
        awayScore: (i + 1) % 3,
      })),
    }).expect(201);

    const out = res.body as Resultado;
    expect(out.savedCount).toBe(10);
    expect(out.rejectedCount).toBe(0);
  });

  it('o palpite salvo volta na leitura da rodada', async () => {
    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    expect(r.matches.every((m) => m.myPrediction !== null)).toBe(true);
  });

  it('substituir o palpite mantém um registro e guarda a revisão', async () => {
    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    const jogo = r.matches[0];

    await palpitar({
      predictions: [{ matchId: jogo.matchId, homeScore: 7, awayScore: 1 }],
    }).expect(201);

    const palpite = await prisma.prediction.findFirst({
      where: { poolId, userId: membro.id, matchId: jogo.matchId },
      select: { id: true, homeScore: true, awayScore: true },
    });
    expect(palpite?.homeScore).toBe(7);

    // Histórico: é o que resolve "eu mudei antes do apito".
    const revisoes = await prisma.predictionRevision.count({
      where: { predictionId: palpite!.id },
    });
    expect(revisoes).toBeGreaterThanOrEqual(2);
  });

  it('reenviar o mesmo palpite não cria revisão nova — a fila offline repete', async () => {
    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    const jogo = r.matches[2];

    await palpitar({
      predictions: [{ matchId: jogo.matchId, homeScore: 3, awayScore: 3 }],
    }).expect(201);

    const palpite = await prisma.prediction.findFirstOrThrow({
      where: { poolId, userId: membro.id, matchId: jogo.matchId },
      select: { id: true },
    });
    const antes = await prisma.predictionRevision.count({
      where: { predictionId: palpite.id },
    });

    // Dois reenvios idênticos, como a fila de PP-4 faz ao recuperar conexão.
    await palpitar({
      predictions: [{ matchId: jogo.matchId, homeScore: 3, awayScore: 3 }],
    }).expect(201);
    await palpitar({
      predictions: [{ matchId: jogo.matchId, homeScore: 3, awayScore: 3 }],
    }).expect(201);

    // Inflar o histórico com cópias destrói a leitura que ele existe para dar.
    expect(
      await prisma.predictionRevision.count({
        where: { predictionId: palpite.id },
      }),
    ).toBe(antes);

    // Mudar de verdade continua registrando.
    await palpitar({
      predictions: [{ matchId: jogo.matchId, homeScore: 3, awayScore: 4 }],
    }).expect(201);
    expect(
      await prisma.predictionRevision.count({
        where: { predictionId: palpite.id },
      }),
    ).toBe(antes + 1);
  });

  it('recusa palpite em jogo com prazo fechado, sem derrubar o resto do lote', async () => {
    const futura = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    const passada = (await rodada(RODADA_PASSADA).expect(200)).body as Rodada;

    const res = await palpitar({
      predictions: [
        { matchId: passada.matches[0].matchId, homeScore: 1, awayScore: 0 },
        { matchId: futura.matches[1].matchId, homeScore: 2, awayScore: 2 },
      ],
    }).expect(201);

    const out = res.body as Resultado;
    expect(out.savedCount).toBe(1);
    expect(out.rejectedCount).toBe(1);
    expect(out.results.find((r) => !r.saved)?.reason).toBe('PRAZO_FECHADO');

    // O jogo fechado não gravou nada.
    expect(
      await prisma.prediction.count({
        where: {
          poolId,
          userId: membro.id,
          matchId: passada.matches[0].matchId,
        },
      }),
    ).toBe(0);
  });

  it('o admin também é recusado depois do apito — sem exceção de papel', async () => {
    const passada = (await rodada(RODADA_PASSADA, admin.token).expect(200))
      .body as Rodada;

    const res = await palpitar(
      {
        predictions: [
          { matchId: passada.matches[0].matchId, homeScore: 3, awayScore: 0 },
        ],
      },
      admin.token,
    ).expect(201);

    expect((res.body as Resultado).results[0].reason).toBe('PRAZO_FECHADO');
  });

  it('recusa jogo que não pertence ao bolão', async () => {
    const outro = await request(app.getHttpServer())
      .post('/pools')
      .set('Authorization', `Bearer ${admin.token}`)
      .send({
        name: 'Outro bolão',
        competitionCode: 'BSA',
        matchdayFrom: 35,
        matchdayTo: 35,
      })
      .expect(201);
    const outroId = (outro.body as { id: string }).id;

    const deOutro = await prisma.poolMatch.findFirst({
      where: { poolId: outroId },
      select: { matchId: true },
    });

    const res = await palpitar({
      predictions: [{ matchId: deOutro!.matchId, homeScore: 1, awayScore: 1 }],
    }).expect(201);

    expect((res.body as Resultado).results[0].reason).toBe(
      'JOGO_FORA_DO_BOLAO',
    );
  });

  it('recusa placar inválido na borda', async () => {
    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    const id = r.matches[2].matchId;

    await palpitar({
      predictions: [{ matchId: id, homeScore: -1, awayScore: 0 }],
    }).expect(400);
    await palpitar({
      predictions: [{ matchId: id, homeScore: 21, awayScore: 0 }],
    }).expect(400);
    await palpitar({
      predictions: [{ matchId: id, homeScore: 1.5, awayScore: 0 }],
    }).expect(400);
    await palpitar({ predictions: [] }).expect(400);
  });

  it('quem não participa do bolão não palpita nem lê a rodada', async () => {
    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;

    await rodada(RODADA_FUTURA, estranho.token).expect(403);
    await palpitar(
      {
        predictions: [
          { matchId: r.matches[0].matchId, homeScore: 1, awayScore: 0 },
        ],
      },
      estranho.token,
    ).expect(403);
  });

  it('o primeiro palpite travou as regras do bolão', async () => {
    const pool = await prisma.pool.findUnique({
      where: { id: poolId },
      select: { rulesLockedAt: true },
    });
    expect(pool?.rulesLockedAt).not.toBeNull();

    // E o admin não consegue mais mudar a pontuação.
    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/rules`)
      .set('Authorization', `Bearer ${admin.token}`)
      .send({ pointsExact: 50 })
      .expect(409);
  });

  it('palpites seguintes não movem o momento do travamento', async () => {
    const antes = await prisma.pool.findUnique({
      where: { id: poolId },
      select: { rulesLockedAt: true },
    });

    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    await palpitar({
      predictions: [
        { matchId: r.matches[3].matchId, homeScore: 4, awayScore: 4 },
      ],
    }).expect(201);

    const depois = await prisma.pool.findUnique({
      where: { id: poolId },
      select: { rulesLockedAt: true },
    });
    expect(depois?.rulesLockedAt?.getTime()).toBe(
      antes?.rulesLockedAt?.getTime(),
    );
  });

  it('bolão encerrado não aceita palpite', async () => {
    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    await prisma.pool.update({
      where: { id: poolId },
      data: { closedAt: new Date() },
    });

    await palpitar({
      predictions: [
        { matchId: r.matches[4].matchId, homeScore: 1, awayScore: 1 },
      ],
    }).expect(410);

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

  it('exige autenticação', async () => {
    await request(app.getHttpServer())
      .get(`/pools/${poolId}/matchdays/${RODADA_FUTURA}`)
      .expect(401);
  });

  it('PP-5-02 recusa palpite no segundo seguinte ao apito', async () => {
    // Plano docs/testes/PP-5-plano.md, caso PP-5-02. A borda importa: "rodada
    // passada" testa semanas de folga, não o instante em que o prazo virou.
    const futura = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    const jogo = futura.matches[futura.matches.length - 1];

    const original = await prisma.match.findUniqueOrThrow({
      where: { id: jogo.matchId },
      select: { kickoffAt: true },
    });

    await prisma.match.update({
      where: { id: jogo.matchId },
      data: { kickoffAt: new Date(Date.now() - 1000) },
    });

    const res = await palpitar({
      predictions: [{ matchId: jogo.matchId, homeScore: 1, awayScore: 0 }],
    }).expect(201);

    const r = res.body as Resultado;
    expect(r.results[0].saved).toBe(false);
    expect(r.results[0].reason).toBe('PRAZO_FECHADO');

    await prisma.match.update({
      where: { id: jogo.matchId },
      data: { kickoffAt: original.kickoffAt },
    });
  });

  it('PP-5-02b kickoff exatamente agora já está fechado', async () => {
    // Na dúvida o prazo fecha, nunca abre: a comparação é `kickoff <= agora`.
    const futura = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    const jogo = futura.matches[futura.matches.length - 2];
    const original = await prisma.match.findUniqueOrThrow({
      where: { id: jogo.matchId },
      select: { kickoffAt: true },
    });

    // Um pouco no passado para não perder a corrida com o relógio do servidor
    // entre o update e a requisição.
    await prisma.match.update({
      where: { id: jogo.matchId },
      data: { kickoffAt: new Date(Date.now() - 50) },
    });

    const res = await palpitar({
      predictions: [{ matchId: jogo.matchId, homeScore: 2, awayScore: 2 }],
    }).expect(201);
    expect((res.body as Resultado).results[0].reason).toBe('PRAZO_FECHADO');

    await prisma.match.update({
      where: { id: jogo.matchId },
      data: { kickoffAt: original.kickoffAt },
    });
  });

  it('PP-2-08 corpo com userId de outra pessoa é recusado, não ignorado', async () => {
    // Plano docs/testes/PP-2-plano.md. Ignorar campo extra em silêncio é o que
    // faz uma falha de autorização parecer funcionalidade: quem tenta acha que
    // funcionou, e quem revisa não vê nada acontecendo.
    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    const matchId = r.matches[0].matchId;

    const res = await palpitar({
      userId: 'outra-pessoa',
      predictions: [{ matchId, homeScore: 3, awayScore: 3 }],
    });
    expect(res.status).toBe(400);

    // E nada foi gravado no lugar de ninguém.
    expect(
      await prisma.prediction.count({
        where: { poolId, matchId, homeScore: 3, awayScore: 3 },
      }),
    ).toBe(0);
  });

  it('PP-2-05 apuração usa a última versão enviada antes do apito', async () => {
    // Plano docs/testes/PP-2-plano.md. É a garantia que encerra a discussão
    // "eu tinha mudado": vale o último valor aceito, não o primeiro nem o
    // digitado depois.
    const r = (await rodada(RODADA_FUTURA).expect(200)).body as Rodada;
    const matchId = r.matches[1].matchId;
    const kickoffOriginal = (
      await prisma.match.findUniqueOrThrow({
        where: { id: matchId },
        select: { kickoffAt: true },
      })
    ).kickoffAt;

    // Três versões, todas com prazo aberto.
    for (const [casa, fora] of [
      [0, 0],
      [1, 1],
      [2, 1],
    ]) {
      await palpitar({
        predictions: [{ matchId, homeScore: casa, awayScore: fora }],
      }).expect(201);
    }

    // Apito e resultado 2x1: só a última versão dá placar exato.
    await prisma.match.update({
      where: { id: matchId },
      data: {
        kickoffAt: new Date(Date.now() - 60 * 60 * 1000),
        status: 'FINISHED',
        homeScore: 2,
        awayScore: 1,
      },
    });
    await app.get(ScoringService).apurarPendentes();

    const palpite = await prisma.prediction.findFirstOrThrow({
      where: { poolId, matchId, userId: membro.id },
      select: { homeScore: true, awayScore: true, points: true, band: true },
    });
    expect([palpite.homeScore, palpite.awayScore]).toEqual([2, 1]);
    expect(palpite.band).toBe('PLACAR_EXATO');

    // Tentativa depois do apito não muda o que valeu.
    await palpitar({
      predictions: [{ matchId, homeScore: 5, awayScore: 5 }],
    });
    const depois = await prisma.prediction.findFirstOrThrow({
      where: { poolId, matchId, userId: membro.id },
      select: { homeScore: true, awayScore: true },
    });
    expect([depois.homeScore, depois.awayScore]).toEqual([2, 1]);

    // Devolve o jogo ao estado original: `Match` é global entre bolões.
    await prisma.match.update({
      where: { id: matchId },
      data: {
        kickoffAt: kickoffOriginal,
        status: 'TIMED',
        homeScore: null,
        awayScore: null,
      },
    });
  });
});
