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

type Membro = {
  userId: string;
  displayName: string;
  role: string;
  isCreator: boolean;
  active: boolean;
};
type Lista = { total: number; rows: Membro[] };
type StatusRodada = {
  totalMatches: number;
  rows: { displayName: string; predictionsMade: number; estado: string }[];
};

describe('Participantes, admin e saída (e2e)', () => {
  let app: INestApplication<App>;
  let prisma: PrismaService;
  let criador: { id: string; token: string };
  let ana: { id: string; token: string };
  let bruno: { id: string; token: string };
  let poolId: string;

  const RODADA = 31;

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

    criador = await criarConta('memb-criador@e2e.teste', 'Criador');
    ana = await criarConta('memb-ana@e2e.teste', 'Ana');
    bruno = await criarConta('memb-bruno@e2e.teste', 'Bruno');

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

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

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

  const promover = (alvo: string, token: string) =>
    request(app.getHttpServer())
      .patch(`/pools/${poolId}/members/${alvo}/promote`)
      .set('Authorization', `Bearer ${token}`);

  const rebaixar = (alvo: string, token: string) =>
    request(app.getHttpServer())
      .patch(`/pools/${poolId}/members/${alvo}/demote`)
      .set('Authorization', `Bearer ${token}`);

  it('lista participantes marcando quem é o criador', async () => {
    const l = (await listar(ana.token).expect(200)).body as Lista;
    expect(l.total).toBe(3);

    const oCriador = l.rows.find((m) => m.isCreator);
    expect(oCriador?.displayName).toBe('Criador');
    expect(oCriador?.role).toBe('ADMIN');
  });

  it('quem não participa não vê a lista', async () => {
    const estranho = await criarConta('memb-estranho@e2e.teste', 'Estranho');
    await listar(estranho.token).expect(403);
  });

  it('participante comum não promove ninguém', async () => {
    await promover(bruno.id, ana.token).expect(403);
  });

  it('admin promove outro participante a admin', async () => {
    await promover(ana.id, criador.token).expect(200);

    const l = (await listar(ana.token).expect(200)).body as Lista;
    expect(l.rows.find((m) => m.userId === ana.id)?.role).toBe('ADMIN');
  });

  it('admin novo NÃO ganha acesso a palpite oculto', async () => {
    const jogo = await prisma.poolMatch.findFirst({
      where: { poolId },
      select: { matchId: true },
    });

    // Ana agora é admin, mas o jogo não terminou: continua em segredo.
    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/matches/${jogo!.matchId}/predictions`)
      .set('Authorization', `Bearer ${ana.token}`)
      .expect(200);

    expect((res.body as { revealed: boolean }).revealed).toBe(false);
  });

  it('o criador é admin permanente e não pode ser rebaixado', async () => {
    const res = await rebaixar(criador.id, ana.token).expect(409);
    expect(JSON.stringify(res.body)).toMatch(/permanente/i);
  });

  it('não deixa o bolão sem administrador', async () => {
    // Rebaixa Ana (sobra o criador): permitido.
    await rebaixar(ana.id, criador.token).expect(200);

    // Agora só o criador é admin — e ele é irrebaixável, então o bolão nunca
    // fica sem admin por este caminho.
    await rebaixar(criador.id, criador.token).expect(409);
  });

  it('o criador não consegue sair do bolão', async () => {
    const res = await request(app.getHttpServer())
      .post(`/pools/${poolId}/members/leave`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(409);

    expect(JSON.stringify(res.body)).toMatch(/permanente|não pode sair/i);
  });

  it('participante sai, o vínculo é encerrado e o histórico permanece', async () => {
    const jogos = await prisma.poolMatch.findMany({
      where: { poolId },
      select: { matchId: true },
      take: 2,
    });
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${bruno.token}`)
      .send({
        predictions: jogos.map((j) => ({
          matchId: j.matchId,
          homeScore: 1,
          awayScore: 1,
        })),
      })
      .expect(201);

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

    const membro = await prisma.poolMember.findUnique({
      where: { poolId_userId: { poolId, userId: bruno.id } },
      select: { leftAt: true },
    });
    expect(membro?.leftAt).not.toBeNull();

    // Os palpites dele continuam existindo — o admin não apaga histórico alheio.
    expect(
      await prisma.prediction.count({ where: { poolId, userId: bruno.id } }),
    ).toBe(2);
  });

  it('quem saiu não palpita mais nem lê o bolão', async () => {
    const jogo = await prisma.poolMatch.findFirst({
      where: { poolId },
      select: { matchId: true },
    });

    await request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${bruno.token}`)
      .send({
        predictions: [{ matchId: jogo!.matchId, homeScore: 2, awayScore: 2 }],
      })
      .expect(403);

    await listar(bruno.token).expect(403);
  });

  it('remoção pelo admin usa a mesma regra da saída — preserva histórico', async () => {
    const antes = await prisma.prediction.count({
      where: { poolId, userId: ana.id },
    });

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

    const membro = await prisma.poolMember.findUnique({
      where: { poolId_userId: { poolId, userId: ana.id } },
      select: { leftAt: true },
    });
    expect(membro?.leftAt).not.toBeNull();
    expect(
      await prisma.prediction.count({ where: { poolId, userId: ana.id } }),
    ).toBe(antes);
  });

  it('o criador não pode ser removido', async () => {
    await request(app.getHttpServer())
      .delete(`/pools/${poolId}/members/${criador.id}`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(409);
  });

  it('admin vê quem falta palpitar, SEM os placares', async () => {
    const carlos = await criarConta('memb-carlos@e2e.teste', 'Carlos');
    await prisma.poolMember.create({
      data: { poolId, userId: carlos.id, role: 'MEMBER' },
    });

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId },
      select: { matchId: true },
      take: 3,
    });
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/predictions`)
      .set('Authorization', `Bearer ${carlos.token}`)
      .send({
        predictions: jogos.map((j) => ({
          matchId: j.matchId,
          homeScore: 3,
          awayScore: 3,
        })),
      })
      .expect(201);

    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/members/prediction-status/${RODADA}`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(200);

    const s = res.body as StatusRodada;
    const doCarlos = s.rows.find((r) => r.displayName === 'Carlos');
    expect(doCarlos?.predictionsMade).toBe(3);
    expect(doCarlos?.estado).toBe('PARCIAL');
    expect(s.rows.find((r) => r.displayName === 'Criador')?.estado).toBe(
      'NENHUM',
    );

    // O critério que mais importa: nenhum placar na resposta.
    const texto = JSON.stringify(res.body);
    expect(texto).not.toContain('homeScore');
    expect(texto).not.toContain('awayScore');
  });

  it('participante comum não vê quem falta palpitar', async () => {
    const carlos = await prisma.user.findFirst({
      where: { email: 'memb-carlos@e2e.teste' },
      select: { id: true },
    });
    const login = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ email: 'memb-carlos@e2e.teste', password: 'senhaSegura123' })
      .expect(200);

    expect(carlos).not.toBeNull();
    await request(app.getHttpServer())
      .get(`/pools/${poolId}/members/prediction-status/${RODADA}`)
      .set(
        'Authorization',
        `Bearer ${(login.body as { accessToken: string }).accessToken}`,
      )
      .expect(403);
  });

  it('CC-8-06 promover quem já é admin é no-op', async () => {
    // Plano docs/testes/CC-8-plano.md. Toque duplo no menu não pode virar erro.
    const alvo = await criarConta('memb-noop@e2e.teste', 'Noop');
    await prisma.poolMember.create({
      data: { poolId, userId: alvo.id, role: 'ADMIN' },
    });

    const res = await request(app.getHttpServer())
      .patch(`/pools/${poolId}/members/${alvo.id}/promote`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(200);

    expect((res.body as { changed: boolean }).changed).toBe(false);
    expect(
      (
        await prisma.poolMember.findUniqueOrThrow({
          where: { poolId_userId: { poolId, userId: alvo.id } },
        })
      ).role,
    ).toBe('ADMIN');
  });

  it('CC-8-07 promover quem saiu do bolão é recusado', async () => {
    // Plano docs/testes/CC-8-plano.md. A saída é definitiva; promover quem saiu
    // daria poder de admin a quem não está mais no bolão.
    const alvo = await criarConta('memb-saiu-prom@e2e.teste', 'SaiuProm');
    await prisma.poolMember.create({
      data: {
        poolId,
        userId: alvo.id,
        role: 'MEMBER',
        leftAt: new Date(),
      },
    });

    const res = await request(app.getHttpServer())
      .patch(`/pools/${poolId}/members/${alvo.id}/promote`)
      .set('Authorization', `Bearer ${criador.token}`);
    expect(res.status).toBeGreaterThanOrEqual(400);

    expect(
      (
        await prisma.poolMember.findUniqueOrThrow({
          where: { poolId_userId: { poolId, userId: alvo.id } },
        })
      ).role,
    ).toBe('MEMBER');
  });

  it('CC-8-08 promover alguém de fora do bolão não cria vínculo', async () => {
    // Plano docs/testes/CC-8-plano.md. Aceitar aqui seria uma via de entrada que
    // ignora o convite.
    const deFora = await criarConta('memb-fora-prom@e2e.teste', 'ForaProm');

    const res = await request(app.getHttpServer())
      .patch(`/pools/${poolId}/members/${deFora.id}/promote`)
      .set('Authorization', `Bearer ${criador.token}`);
    expect(res.status).toBeGreaterThanOrEqual(400);

    expect(
      await prisma.poolMember.count({
        where: { poolId, userId: deFora.id },
      }),
    ).toBe(0);
  });

  it('CC-8-10 admin rebaixado perde os poderes na hora', async () => {
    // Plano docs/testes/CC-8-plano.md. Permissão lida do token — ou em cache —
    // deixaria o rebaixamento sem efeito até o próximo login.
    const alvo = await criarConta('memb-rebaixado@e2e.teste', 'Rebaixado');
    await prisma.poolMember.create({
      data: { poolId, userId: alvo.id, role: 'ADMIN' },
    });

    // Com poder: consegue gerar convite (ação de admin).
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/invite`)
      .set('Authorization', `Bearer ${alvo.token}`)
      .expect(201);

    await request(app.getHttpServer())
      .patch(`/pools/${poolId}/members/${alvo.id}/demote`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(200);

    // **Mesmo token**, sem novo login: o poder tem que ter sumido.
    await request(app.getHttpServer())
      .post(`/pools/${poolId}/invite`)
      .set('Authorization', `Bearer ${alvo.token}`)
      .expect(403);
  });

  it('CC-9-04 participante comum não remove ninguém', async () => {
    // Plano docs/testes/CC-9-plano.md. Sem isso qualquer um expulsa o rival do
    // ranking — e a expulsão é irreversível.
    const vitima = await criarConta('memb-vitima@e2e.teste', 'Vitima');
    const comum = await criarConta('memb-comum-rm@e2e.teste', 'ComumRm');
    for (const u of [vitima, comum]) {
      await prisma.poolMember.create({
        data: { poolId, userId: u.id, role: 'MEMBER' },
      });
    }

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

    expect(
      (
        await prisma.poolMember.findUniqueOrThrow({
          where: { poolId_userId: { poolId, userId: vitima.id } },
        })
      ).leftAt,
    ).toBeNull();
  });

  it('CC-9-05 quem foi removido não entra de novo pelo convite', async () => {
    // Plano docs/testes/CC-9-plano.md. A remoção reusa a regra de encerramento
    // de vínculo da saída voluntária (CV-4): não há reentrada.
    const removido = await criarConta('memb-removido@e2e.teste', 'Removido');
    await prisma.poolMember.create({
      data: { poolId, userId: removido.id, role: 'MEMBER' },
    });

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

    const convite = await request(app.getHttpServer())
      .post(`/pools/${poolId}/invite`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(201);

    const res = await request(app.getHttpServer())
      .post('/pools/join')
      .set('Authorization', `Bearer ${removido.token}`)
      .send({ token: (convite.body as { token: string }).token })
      .expect(409);
    expect((res.body as { message: string }).message).toMatch(/definitiv/i);
  });

  it('CC-9-08 remover quem já saiu é recusado e não reescreve a data', async () => {
    // Plano docs/testes/CC-9-plano.md. A data original de saída é o que separa
    // o palpite válido do inválido no histórico.
    const jaSaiu = await criarConta('memb-jasaiu@e2e.teste', 'JaSaiu');
    const saidaEm = new Date('2026-01-15T12:00:00.000Z');
    await prisma.poolMember.create({
      data: { poolId, userId: jaSaiu.id, role: 'MEMBER', leftAt: saidaEm },
    });

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

    expect(
      (
        await prisma.poolMember.findUniqueOrThrow({
          where: { poolId_userId: { poolId, userId: jaSaiu.id } },
        })
      ).leftAt?.toISOString(),
    ).toBe(saidaEm.toISOString());
  });

  it('CC-9-09 admin removendo a si mesmo é mandado para a ação de sair', async () => {
    // Plano docs/testes/CC-9-plano.md. A saída tem regra própria (último admin,
    // criador); passar por aqui seria porta lateral para bolão sem admin.
    const outroAdmin = await criarConta('memb-autoremove@e2e.teste', 'AutoRm');
    await prisma.poolMember.create({
      data: { poolId, userId: outroAdmin.id, role: 'ADMIN' },
    });

    const res = await request(app.getHttpServer())
      .delete(`/pools/${poolId}/members/${outroAdmin.id}`)
      .set('Authorization', `Bearer ${outroAdmin.token}`)
      .expect(400);
    expect((res.body as { message: string }).message).toMatch(/sair/i);
  });

  it('CC-9-10 remover quem não é do bolão responde 404', async () => {
    // Plano docs/testes/CC-9-plano.md. E não pode tocar no vínculo que essa
    // pessoa tem em **outro** bolão.
    const deFora = await criarConta('memb-fora-rm@e2e.teste', 'ForaRm');
    const outro = await prisma.pool.create({
      data: {
        name: 'Bolão alheio',
        competitionCode: 'BSA',
        matchdayFrom: RODADA,
        matchdayTo: RODADA,
        createdById: deFora.id,
        members: { create: { userId: deFora.id, role: 'ADMIN' } },
      },
    });

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

    expect(
      (
        await prisma.poolMember.findUniqueOrThrow({
          where: { poolId_userId: { poolId: outro.id, userId: deFora.id } },
        })
      ).leftAt,
    ).toBeNull();
    await prisma.pool.delete({ where: { id: outro.id } });
  });

  it('CC-7-07 lista paginada devolve o total completo', async () => {
    // Plano docs/testes/CC-7-plano.md. Sem o total o app não sabe se acabou.
    const res = await request(app.getHttpServer())
      .get(`/pools/${poolId}/members?limit=2&offset=0`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(200);

    const body = res.body as { total: number; rows: unknown[] };
    expect(body.rows).toHaveLength(2);
    expect(body.total).toBeGreaterThan(2);
  });

  it('CC-7-08 paginação recusa faixa inválida e limita o teto', async () => {
    // Plano docs/testes/CC-7-plano.md. Devolver lista vazia para limit=0 é pior
    // que recusar: a tela diz "ninguém no bolão" e o admin acredita.
    const url = `/pools/${poolId}/members`;
    const como = (q: string) =>
      request(app.getHttpServer())
        .get(`${url}?${q}`)
        .set('Authorization', `Bearer ${criador.token}`);

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

    const teto = await como('limit=100000').expect(200);
    expect((teto.body as { rows: unknown[] }).rows.length).toBeLessThanOrEqual(
      100,
    );
  });

  it('PP-9-04/07/09 completo, estado final depois do prazo, e quem saiu fora', async () => {
    // Plano docs/testes/PP-9-plano.md. Faltavam três coisas: o estado COMPLETO,
    // a lista **depois** de o prazo fechar (que é quando o admin a usa para
    // explicar o ranking) e a ausência de quem saiu — cobrar palpite de quem saiu
    // é cobrar ação impossível.
    const pool = (
      await request(app.getHttpServer())
        .post('/pools')
        .set('Authorization', `Bearer ${criador.token}`)
        .send({
          name: 'Bolão da cobrança',
          competitionCode: 'BSA',
          matchdayFrom: 19,
          matchdayTo: 19,
        })
        .expect(201)
    ).body as { id: string };

    const completo = await criarConta('memb-completo@e2e.teste', 'Completo');
    const parcial = await criarConta('memb-parcial@e2e.teste', 'Parcial');
    const desistente = await criarConta('memb-desist@e2e.teste', 'Desistente');
    for (const u of [completo, parcial, desistente]) {
      await prisma.poolMember.create({
        data: { poolId: pool.id, userId: u.id, role: 'MEMBER' },
      });
    }

    const jogos = await prisma.poolMatch.findMany({
      where: { poolId: pool.id },
      orderBy: { match: { kickoffAt: 'asc' } },
      select: { matchId: true },
    });
    const originais = await prisma.match.findMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      select: { id: true, kickoffAt: true },
    });
    // Abre o prazo de todos: a rodada 19 está no passado no calendário falso.
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { kickoffAt: new Date(Date.now() + 2 * 60 * 60 * 1000) },
    });

    // Um palpita em tudo, outro em metade, o terceiro sai do bolão.
    await request(app.getHttpServer())
      .post(`/pools/${pool.id}/predictions`)
      .set('Authorization', `Bearer ${completo.token}`)
      .send({
        predictions: jogos.map((j) => ({
          matchId: j.matchId,
          homeScore: 1,
          awayScore: 0,
        })),
      })
      .expect(201);
    await request(app.getHttpServer())
      .post(`/pools/${pool.id}/predictions`)
      .set('Authorization', `Bearer ${parcial.token}`)
      .send({
        predictions: jogos.slice(0, 2).map((j) => ({
          matchId: j.matchId,
          homeScore: 2,
          awayScore: 2,
        })),
      })
      .expect(201);
    await request(app.getHttpServer())
      .post(`/pools/${pool.id}/members/leave`)
      .set('Authorization', `Bearer ${desistente.token}`)
      .expect(200);

    const antes = (
      await request(app.getHttpServer())
        .get(`/pools/${pool.id}/members/prediction-status/19`)
        .set('Authorization', `Bearer ${criador.token}`)
        .expect(200)
    ).body as StatusRodada;

    expect(
      antes.rows.find((r) => r.displayName === 'Completo')?.estado,
    ).toBe('COMPLETO');
    expect(antes.rows.find((r) => r.displayName === 'Parcial')?.estado).toBe(
      'PARCIAL',
    );
    expect(
      antes.rows.some((r) => r.displayName === 'Desistente'),
    ).toBe(false);

    // Prazo fechado: o estado continua o mesmo, e nenhum placar aparece.
    await prisma.match.updateMany({
      where: { id: { in: jogos.map((j) => j.matchId) } },
      data: { kickoffAt: new Date(Date.now() - 60 * 60 * 1000) },
    });
    const depois = await request(app.getHttpServer())
      .get(`/pools/${pool.id}/members/prediction-status/19`)
      .set('Authorization', `Bearer ${criador.token}`)
      .expect(200);
    const s = depois.body as StatusRodada;
    expect(s.rows.find((r) => r.displayName === 'Completo')?.estado).toBe(
      'COMPLETO',
    );
    expect(s.rows.find((r) => r.displayName === 'Parcial')?.estado).toBe(
      'PARCIAL',
    );
    const texto = JSON.stringify(depois.body);
    expect(texto).not.toContain('homeScore');
    expect(texto).not.toContain('awayScore');

    // Devolve os horários: `Match` é global entre bolões.
    for (const m of originais) {
      await prisma.match.update({
        where: { id: m.id },
        data: { kickoffAt: m.kickoffAt },
      });
    }
  });
});
