process.env.THROTTLE_LOGIN_LIMIT = '1000';
// O cron não deve competir com este teste pelo mesmo banco.
process.env.JOBS_HABILITADOS = 'false';

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 { LembretesService } from '../src/lembretes/lembretes.service';
import { PrismaService } from '../src/prisma/prisma.service';

type Aviso = { kind: string; title: string; body: string };

describe('Lembrete de prazo (e2e)', () => {
  let app: INestApplication<App>;
  let prisma: PrismaService;
  let lembretes: LembretesService;
  let admin: { id: string; token: string };
  let quemPalpitou: { id: string; token: string };
  let quemNao: { id: string; token: string };
  let quemSaiu: { id: string; token: string };
  let quemFoiRemovido: { id: string; token: string };
  let poolId: string;
  let jogo: string;

  // Rodada exclusiva: esta suíte mexe no horário dos jogos.
  const RODADA = 35;

  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);
    lembretes = app.get(LembretesService);

    admin = await criarConta('lemb-admin@e2e.teste', 'Admin');
    quemPalpitou = await criarConta('lemb-palpitou@e2e.teste', 'Palpitou');
    quemNao = await criarConta('lemb-naopalpitou@e2e.teste', 'NaoPalpitou');
    quemSaiu = await criarConta('lemb-saiu@e2e.teste', 'Saiu');
    quemFoiRemovido = await criarConta('lemb-removido@e2e.teste', 'Removido');

    const pool = await request(app.getHttpServer())
      .post('/pools')
      .set('Authorization', `Bearer ${admin.token}`)
      .send({
        name: 'Bolão lembretes',
        competitionCode: 'BSA',
        matchdayFrom: RODADA,
        matchdayTo: RODADA,
      })
      .expect(201);
    poolId = (pool.body as { id: string }).id;

    for (const u of [quemPalpitou, quemNao, quemSaiu, quemFoiRemovido]) {
      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;

    // Coloca o jogo dentro da janela de aviso: 30 min à frente.
    await prisma.match.update({
      where: { id: jogo },
      data: { kickoffAt: new Date(Date.now() + 30 * 60 * 1000) },
    });

    // Tira os outros jogos da rodada da janela. O calendário falso é ancorado
    // em **hoje**, então quantos jogos caem na janela muda conforme o dia passa
    // — sem isto a suíte depende da data em que roda.
    await prisma.match.updateMany({
      where: {
        competitionCode: 'BSA',
        matchday: RODADA,
        id: { not: jogo },
      },
      data: { kickoffAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) },
    });

    // Um palpita, outro não, e o terceiro sai do bolão.
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${quemPalpitou.token}`)
      .send({ predictions: [{ matchId: jogo, homeScore: 1, awayScore: 0 }] })
      .expect(201);

    await request(app.getHttpServer())
      .post(`/pools/${poolId}/members/leave`)
      .set('Authorization', `Bearer ${quemSaiu.token}`)
      .expect(200);

    // E um quarto é **removido pelo admin** — vínculo encerrado pelo outro
    // caminho (CC-9), que precisa calar o lembrete do mesmo jeito.
    await request(app.getHttpServer())
      .delete(`/pools/${poolId}/members/${quemFoiRemovido.id}`)
      .set('Authorization', `Bearer ${admin.token}`)
      .expect(200);
  });

  afterAll(async () => {
    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: 'lemb-' } } });
    await app.close();
  });

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

  const lembretesDe = (userId: string) =>
    prisma.predictionReminder.count({ where: { userId, matchId: jogo } });

  it('lembra apenas quem NÃO palpitou', async () => {
    const r = await lembretes.dispararPendentes();
    expect(r.jogosNaJanela).toBeGreaterThan(0);

    expect(await lembretesDe(quemNao.id)).toBe(1);
    expect(await lembretesDe(admin.id)).toBe(1);
    // Quem palpitou não é incomodado — é a regra que impede o casual de
    // desligar as notificações.
    expect(await lembretesDe(quemPalpitou.id)).toBe(0);
  });

  it('não lembra quem saiu do bolão', async () => {
    expect(await lembretesDe(quemSaiu.id)).toBe(0);
  });

  it('CC-9-06 não lembra quem foi removido pelo admin', async () => {
    // Plano docs/testes/CC-9-plano.md. Cobrar prazo de quem foi tirado do bolão
    // é a pior notificação possível: pede ação impossível e lembra a expulsão.
    expect(await lembretesDe(quemFoiRemovido.id)).toBe(0);
  });

  it('o aviso fica no banco e é legível sem push', async () => {
    const lista = (await avisos(quemNao.token).expect(200)).body as Aviso[];
    const lembrete = lista.find((a) => a.kind === 'PRAZO_FECHANDO');

    expect(lembrete).toBeDefined();
    expect(lembrete?.body).toMatch(/ainda.*não palpitou/i);
    expect(lembrete?.body).toMatch(/depois do apito/i);
  });

  it('não lembra duas vezes o mesmo jogo', async () => {
    await lembretes.dispararPendentes();
    await lembretes.dispararPendentes();

    // A unicidade é a garantia, não a lógica do job.
    expect(await lembretesDe(quemNao.id)).toBe(1);

    const lista = (await avisos(quemNao.token).expect(200)).body as Aviso[];
    const doJogo = await prisma.match.findUniqueOrThrow({
      where: { id: jogo },
      select: { homeTeam: true },
    });
    // Escopado ao jogo: contar todos os avisos do bolão mediria quantos jogos
    // da rodada caem na janela, não a unicidade que a story exige.
    expect(
      lista.filter(
        (a) =>
          a.kind === 'PRAZO_FECHANDO' && a.body.includes(doJogo.homeTeam),
      ),
    ).toHaveLength(1);
  });

  it('quem palpita depois do agendamento deixa de ser candidato', async () => {
    const outroJogo = await prisma.poolMatch.findFirst({
      where: { poolId, matchId: { not: jogo } },
      select: { matchId: true },
    });

    // Coloca na janela e palpita antes de rodar o job.
    await prisma.match.update({
      where: { id: outroJogo!.matchId },
      data: { kickoffAt: new Date(Date.now() + 20 * 60 * 1000) },
    });
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${quemNao.token}`)
      .send({
        predictions: [
          { matchId: outroJogo!.matchId, homeScore: 2, awayScore: 2 },
        ],
      })
      .expect(201);

    await lembretes.dispararPendentes();

    expect(
      await prisma.predictionReminder.count({
        where: { userId: quemNao.id, matchId: outroJogo!.matchId },
      }),
    ).toBe(0);
  });

  it('jogo fora da janela não gera lembrete', async () => {
    const distante = await prisma.poolMatch.findFirst({
      where: {
        poolId,
        match: {
          kickoffAt: { gt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000) },
        },
      },
      select: { matchId: true },
    });

    if (distante) {
      await lembretes.dispararPendentes();
      expect(
        await prisma.predictionReminder.count({
          where: { matchId: distante.matchId },
        }),
      ).toBe(0);
    }
  });

  it('bolão encerrado não gera lembrete', async () => {
    await prisma.pool.update({
      where: { id: poolId },
      data: { closedAt: new Date() },
    });
    await prisma.predictionReminder.deleteMany({ where: { poolId } });

    const r = await lembretes.dispararPendentes();
    expect(await prisma.predictionReminder.count({ where: { poolId } })).toBe(
      0,
    );
    expect(r.lembretesEnviados).toBe(0);

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

  it('push desativado não impede o aviso — entrega zero, aviso gravado', async () => {
    await prisma.predictionReminder.deleteMany({ where: { poolId } });
    await prisma.notification.deleteMany({ where: { userId: quemNao.id } });

    const r = await lembretes.dispararPendentes();

    expect(r.lembretesEnviados).toBeGreaterThan(0);
    // Sem credencial de FCM/APNs nada é entregue por push...
    expect(r.pushEntregues).toBe(0);
    // ...mas o aviso existe no banco, que é o requisito real.
    const lista = (await avisos(quemNao.token).expect(200)).body as Aviso[];
    expect(lista.some((a) => a.kind === 'PRAZO_FECHANDO')).toBe(true);
  });
});
