import {
  assertFails,
  assertSucceeds,
  initializeTestEnvironment,
  RulesTestEnvironment,
} from '@firebase/rules-unit-testing';
import { doc, getDoc, getDocs, query, setDoc, updateDoc, where, collection } from 'firebase/firestore';
import { readFileSync } from 'node:fs';
import { initializeApp } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import { alterar } from './regras';
import { arquivar, encerrarAntes, recriar } from './ciclo-vida';
import { promover, remover } from './membros';
import { corrigir } from './correcao';
import { extrato, rodada, status } from './extrato';
import { entrar, gerar } from './boloes';
import type { CompeticaoExterna, FonteDeDados, JogoExterno } from './fonte/porta';

/**
 * `MG-8` — paridade de **segurança e autorização**, com os IDs de origem.
 *
 * Os casos aqui não são novos: são os mesmos do NestJS, reexecutados contra o
 * Firebase e mantendo o **id original**. É o que permite comparar linha a linha —
 * `PP-7-05` continua sendo `PP-7-05`, e se falhar aqui é defeito de migração, não
 * "comportamento novo".
 *
 * Este arquivo cobre o grupo mais caro de perder: sigilo, prazo, papel e
 * autorização. São exatamente os que o risco da story avisa que seriam os
 * primeiros a sumir se "os testes passam" bastasse como prova.
 *
 * **Uma diferença de mecanismo aparece em quase todos:** no NestJS a recusa vinha
 * de um `if` num service e chegava como 403. Aqui, ou o **banco** recusa (rule),
 * ou a **callable** recusa. Onde o meio mudou, o caso diz qual dos dois é.
 */

const PROJETO = 'demo-bolao';
const REGRAS = readFileSync(`${__dirname}/../../firestore.rules`, 'utf8');

const POOL = 'p-paridade';
const OUTRO = 'p-outro';
const ANA = 'ana'; // criadora e admin
const BRUNO = 'bruno'; // participante comum
const CARLA = 'carla'; // saiu do bolão
const DE_FORA = 'defora';

const JOGO_ABERTO = 'j-aberto';
const JOGO_COMECADO = 'j-comecado';
const JOGO_TERMINADO = 'j-terminado';
const JOGO_DE_OUTRO = 'j-de-outro';

const HORA = 3600 * 1000;

const admin = initializeApp({ projectId: PROJETO }, `paridade-${Date.now()}`);
const db = getFirestore(admin);

jest.setTimeout(60_000);

function jogoExterno(id: string, rodadaNum: number, daquiA: number): JogoExterno {
  return {
    externalId: id,
    competitionCode: 'PAR',
    rodada: rodadaNum,
    timeCasa: 'Casa',
    timeFora: 'Fora',
    inicioEm: new Date(Date.now() + daquiA),
    status: 'SCHEDULED',
    golsCasa: null,
    golsFora: null,
    atualizadoEm: null,
  };
}

class FonteFalsa implements FonteDeDados {
  constructor(private readonly jogos: JogoExterno[]) {}
  async listarCompeticoes(): Promise<CompeticaoExterna[]> {
    return [{ code: 'PAR', nome: 'Paridade', rodadaAtual: 10, totalRodadas: 38 }];
  }
  async listarJogos(): Promise<JogoExterno[]> {
    return this.jogos;
  }
}

const palpite = (uid: string, matchId: string, extras: Record<string, unknown> = {}) => ({
  userId: uid,
  matchId,
  homeScore: 2,
  awayScore: 1,
  createdAt: new Date(),
  updatedAt: new Date(),
  matchFinished: false,
  points: null,
  band: null,
  scoredAt: null,
  ...extras,
});

async function recusa(promessa: Promise<unknown>) {
  try {
    await promessa;
  } catch (e) {
    return e as { code: string; message: string; details?: unknown };
  }
  throw new Error('esperava recusa, mas a operação foi aceita');
}

describe('MG-8 paridade — sigilo, prazo e papel', () => {
  let ambiente: RulesTestEnvironment;

  beforeAll(async () => {
    ambiente = await initializeTestEnvironment({
      projectId: PROJETO,
      firestore: { rules: REGRAS, host: '127.0.0.1', port: 8080 },
    });
  });

  afterAll(() => ambiente.cleanup());

  beforeEach(async () => {
    await ambiente.clearFirestore();

    const bolao = (createdBy: string, extras: Record<string, unknown> = {}) => ({
      name: 'Bolão da paridade',
      competitionCode: 'PAR',
      matchdayFrom: 10,
      matchdayTo: 10,
      createdBy,
      createdAt: new Date(),
      pointsExact: 10,
      pointsWinnerDiff: 5,
      pointsWinner: 3,
      tiebreakOrder: ['EXACT_SCORES', 'WINNERS'],
      rulesLockedAt: null,
      closedAt: null,
      closedEarly: false,
      archivedAt: null,
      activeMembers: 2,
      ...extras,
    });

    const membro = (uid: string, role: string, leftAt: Date | null = null) => ({
      userId: uid,
      displayName: uid,
      role,
      joinedAt: new Date(),
      leftAt,
    });

    await db.doc(`boloes/${POOL}`).set(bolao(ANA));
    await db.doc(`boloes/${POOL}/membros/${ANA}`).set(membro(ANA, 'ADMIN'));
    await db.doc(`boloes/${POOL}/membros/${BRUNO}`).set(membro(BRUNO, 'MEMBER'));
    await db
      .doc(`boloes/${POOL}/membros/${CARLA}`)
      .set(membro(CARLA, 'MEMBER', new Date()));

    await db.doc(`boloes/${OUTRO}`).set(bolao(DE_FORA, { matchdayFrom: 11, matchdayTo: 11 }));
    await db.doc(`boloes/${OUTRO}/membros/${DE_FORA}`).set(membro(DE_FORA, 'ADMIN'));

    const jogo = (id: string, minutos: number, extras: Record<string, unknown> = {}) => ({
      externalId: id,
      competitionCode: 'PAR',
      matchday: 10,
      homeTeam: 'Casa',
      awayTeam: 'Fora',
      kickoffAt: new Date(Date.now() + minutos * 60 * 1000),
      status: 'SCHEDULED',
      homeScore: null,
      awayScore: null,
      sourceUpdatedAt: null,
      ...extras,
    });

    await db.doc(`jogos/${JOGO_ABERTO}`).set(jogo(JOGO_ABERTO, 120));
    await db.doc(`jogos/${JOGO_COMECADO}`).set(jogo(JOGO_COMECADO, -30, { status: 'IN_PLAY' }));
    await db.doc(`jogos/${JOGO_TERMINADO}`).set(
      jogo(JOGO_TERMINADO, -180, { status: 'FINISHED', homeScore: 2, awayScore: 1 }),
    );
    await db.doc(`jogos/${JOGO_DE_OUTRO}`).set({ ...jogo(JOGO_DE_OUTRO, 120), matchday: 11 });
  });

  const como = (uid: string) => ambiente.authenticatedContext(uid).firestore();
  const anonimo = () => ambiente.unauthenticatedContext().firestore();

  // ---- sigilo do palpite -------------------------------------------------

  describe('sigilo (PP-7)', () => {
    beforeEach(async () => {
      await db
        .doc(`boloes/${POOL}/palpites/${JOGO_ABERTO}_${BRUNO}`)
        .set(palpite(BRUNO, JOGO_ABERTO));
      await db
        .doc(`boloes/${POOL}/palpites/${JOGO_TERMINADO}_${BRUNO}`)
        .set(palpite(BRUNO, JOGO_TERMINADO, { matchFinished: true, points: 10, band: 'PLACAR_EXATO' }));
    });

    it('PP-7-05 bypass pela API antes do fim é recusado pelo banco', async () => {
      // No NestJS o service filtrava a resposta; aqui **o banco recusa a leitura**.
      // A diferença importa: lá, quem chamasse a API direto dependia do filtro
      // estar certo; aqui não existe resposta para filtrar.
      await assertFails(
        getDoc(doc(como(ANA), `boloes/${POOL}/palpites/${JOGO_ABERTO}_${BRUNO}`)),
      );
    });

    it('PP-7-09 sem cache local de palpite alheio: a consulta nem retorna', async () => {
      // Se a consulta trouxesse e a tela escondesse, o palpite alheio estaria no
      // aparelho — e cache é o vazamento mais difícil de perceber.
      await assertFails(
        getDocs(
          query(
            collection(como(ANA), `boloes/${POOL}/palpites`),
            where('matchId', '==', JOGO_ABERTO),
          ),
        ),
      );
    });

    it('PP-7-10 status da rodada não devolve placar nenhum', async () => {
      const r = await status(ANA, POOL, 10, db);
      const bruto = JSON.stringify(r);
      expect(bruto).not.toContain('homeScore');
      expect(bruto).not.toContain('awayScore');
      expect(r.rows.every((l) => 'predictionsMade' in l)).toBe(true);
    });

    it('PP-9-02 nenhum placar na resposta, só o estado', async () => {
      const r = await status(ANA, POOL, 10, db);
      const bruno = r.rows.find((l) => l.userId === BRUNO)!;
      expect(bruno.estado).toBe('PARCIAL');
      expect(Object.keys(bruno).sort()).toEqual(
        ['displayName', 'estado', 'predictionsMade', 'userId'].sort(),
      );
    });

    it('PP-9-05 participante comum é recusado na cobrança', async () => {
      expect((await recusa(status(BRUNO, POOL, 10, db))).code).toBe(
        'permission-denied',
      );
    });

    it('PP-9-06 admin promovido no meio da rodada continua sem ver placar', async () => {
      await promover(ANA, POOL, BRUNO, db);
      const r = await status(BRUNO, POOL, 10, db);
      expect(JSON.stringify(r)).not.toContain('homeScore');
    });

    it('CC-8-09 admin novo não vê palpite oculto', async () => {
      await promover(ANA, POOL, BRUNO, db);
      // Virar admin não abre o palpite de ninguém: o sigilo **não tem exceção de
      // papel**, e o admin joga no próprio bolão.
      await assertFails(
        getDoc(doc(como(BRUNO), `boloes/${POOL}/palpites/${JOGO_ABERTO}_${ANA}`)),
      );
    });

    it('PR-2-07 extrato de terceiro respeita o sigilo', async () => {
      // O extrato é sempre o **meu**: não existe rota para pedir o de outro.
      const meu = await extrato(BRUNO, POOL, 10, db);
      const daAna = await extrato(ANA, POOL, 10, db);
      expect(meu.rows.some((l) => l.myPrediction != null)).toBe(true);
      expect(daAna.rows.every((l) => l.myPrediction == null)).toBe(true);
    });

    it('PP-6-03 reabertura não revela palpite de ninguém', async () => {
      // Jogo adiado volta a ter prazo aberto, e o `matchFinished` volta a false —
      // se ficasse true, o adiamento **abriria** os palpites de todos.
      await db.doc(`jogos/${JOGO_TERMINADO}`).update({
        status: 'POSTPONED',
        homeScore: null,
        awayScore: null,
        kickoffAt: new Date(Date.now() + 3 * HORA),
      });
      await new Promise((r) => setTimeout(r, 3000));

      const p = await db
        .doc(`boloes/${POOL}/palpites/${JOGO_TERMINADO}_${BRUNO}`)
        .get();
      expect(p.data()!.points).toBeNull();
    });
  });

  // ---- prazo -------------------------------------------------------------

  describe('prazo (PP-5)', () => {
    it('PP-5-03 bypass pela API com prazo vencido é recusado pelo banco', async () => {
      await assertFails(
        setDoc(
          doc(como(BRUNO), `boloes/${POOL}/palpites/${JOGO_COMECADO}_${BRUNO}`),
          palpite(BRUNO, JOGO_COMECADO),
        ),
      );
    });

    it('PP-5-04 admin não tem exceção de prazo', async () => {
      await assertFails(
        setDoc(
          doc(como(ANA), `boloes/${POOL}/palpites/${JOGO_COMECADO}_${ANA}`),
          palpite(ANA, JOGO_COMECADO),
        ),
      );
    });

    it('PP-5-11 jogo de outro bolão não recebe palpite neste', async () => {
      // O calendário é global; o que amarra jogo e bolão é a faixa de rodadas.
      // A rule não consegue checar isso — quem checa é a leitura da rodada, que
      // simplesmente não oferece o jogo. O palpite gravado à força fica **órfão**:
      // não entra na apuração deste bolão, porque a apuração parte do jogo.
      const r = await rodada(BRUNO, POOL, 10, db);
      expect(r.rows.length).toBeGreaterThan(0);
      const extratoDele = await extrato(BRUNO, POOL, 10, db);
      expect(extratoDele.rows.map((l) => l.matchId)).not.toContain(JOGO_DE_OUTRO);
    });

    it('PP-1-10 sem autenticação, não palpita', async () => {
      await assertFails(
        setDoc(
          doc(anonimo(), `boloes/${POOL}/palpites/${JOGO_ABERTO}_${BRUNO}`),
          palpite(BRUNO, JOGO_ABERTO),
        ),
      );
    });

    it('PP-6-04 não existe reabertura manual pelo admin', async () => {
      // Reabrir prazo é mover o `kickoffAt`, e o jogo é escrita de Function.
      await assertFails(
        updateDoc(doc(como(ANA), `jogos/${JOGO_COMECADO}`), {
          kickoffAt: new Date(Date.now() + 2 * HORA),
        }),
      );
    });

    it('CV-3-04 depois de encerrar, palpite é recusado', async () => {
      await encerrarAntes(ANA, POOL, db);
      await assertFails(
        setDoc(
          doc(como(BRUNO), `boloes/${POOL}/palpites/${JOGO_ABERTO}_${BRUNO}`),
          palpite(BRUNO, JOGO_ABERTO),
        ),
      );
    });
  });

  // ---- papel e autorização ------------------------------------------------

  describe('papel (CC-8, CC-9)', () => {
    it('CC-8-10 admin rebaixado perde os poderes na hora', async () => {
      await promover(ANA, POOL, BRUNO, db);
      await db.doc(`boloes/${POOL}/membros/${BRUNO}`).update({ role: 'MEMBER' });
      expect((await recusa(gerar(BRUNO, POOL, db))).code).toBe('permission-denied');
    });

    it('CC-8-08 promover alguém de outro bolão é recusado', async () => {
      expect((await recusa(promover(ANA, POOL, DE_FORA, db))).code).toBe('not-found');
    });

    it('CC-9-07 o criador não pode ser removido', async () => {
      await promover(ANA, POOL, BRUNO, db);
      expect((await recusa(remover(BRUNO, POOL, ANA, db))).code).toBe(
        'failed-precondition',
      );
    });

    it('CC-3-05 participante comum não altera regras nem destravadas', async () => {
      const regras = {
        pointsExact: 20,
        pointsWinnerDiff: 10,
        pointsWinner: 5,
        tiebreakOrder: ['WINNERS'],
      };
      expect((await recusa(alterar(BRUNO, POOL, regras, db))).code).toBe(
        'permission-denied',
      );
    });

    it('CC-3-04 bypass pela API com regras travadas é recusado', async () => {
      await db.doc(`boloes/${POOL}`).update({ rulesLockedAt: new Date() });
      const erro = await recusa(
        alterar(ANA, POOL, {
          pointsExact: 20,
          pointsWinnerDiff: 10,
          pointsWinner: 5,
          tiebreakOrder: ['WINNERS'],
        }, db),
      );
      expect(erro.details).toMatchObject({ motivo: 'TRAVADAS' });

      // E pela escrita direta também não: era por aí que passava antes de `MG-7`.
      await assertFails(
        updateDoc(doc(como(ANA), `boloes/${POOL}`), { pointsExact: 20 }),
      );
    });

    it('CV-3-05 participante comum não encerra', async () => {
      expect((await recusa(encerrarAntes(BRUNO, POOL, db))).code).toBe(
        'permission-denied',
      );
    });

    it('CV-6-04 participante comum não arquiva', async () => {
      expect((await recusa(arquivar(BRUNO, POOL, true, db))).code).toBe(
        'permission-denied',
      );
    });

    it('CV-7-08 participante comum não recria', async () => {
      const erro = await recusa(
        recriar(BRUNO, POOL, 11, 11, undefined, new FonteFalsa([jogoExterno('x', 11, HORA)]), db),
      );
      expect(erro.code).toBe('permission-denied');
    });

    it('CC-7-04 quem não participa não lê a lista de participantes', async () => {
      await assertFails(getDocs(collection(como(DE_FORA), `boloes/${POOL}/membros`)));
      await assertSucceeds(getDocs(collection(como(BRUNO), `boloes/${POOL}/membros`)));
    });

    it('PR-4-07 autorização do ranking da rodada é a mesma do geral', async () => {
      expect((await recusa(rodada(DE_FORA, POOL, 10, db))).code).toBe(
        'permission-denied',
      );
      await assertFails(
        getDocs(collection(como(DE_FORA), `boloes/${POOL}/classificacao`)),
      );
    });

    it('PR-10-07 histórico de correção exige participar do bolão', async () => {
      await corrigir(ANA, POOL, JOGO_TERMINADO, 3, 0, db);
      // A trilha é legível por quem está no app — ela é a prova contra "o admin
      // mexeu no meu ponto" —, mas o palpite continua fechado.
      await assertSucceeds(
        getDocs(collection(como(BRUNO), `jogos/${JOGO_TERMINADO}/correcoes`)),
      );
      await assertFails(
        getDocs(collection(anonimo(), `jogos/${JOGO_TERMINADO}/correcoes`)),
      );
    });

    it('PR-10-05 a origem MANUAL fica registrada com quem corrigiu', async () => {
      await corrigir(ANA, POOL, JOGO_TERMINADO, 4, 0, db);
      const trilha = await db.collection(`jogos/${JOGO_TERMINADO}/correcoes`).get();
      expect(trilha.docs.map((d) => d.data())).toContainEqual(
        expect.objectContaining({ source: 'MANUAL', correctedBy: ANA }),
      );
    });
  });

  // ---- convite ------------------------------------------------------------

  describe('convite (CC-4, CC-5)', () => {
    // **Bolão próprio, com todos os jogos no futuro.** O bolão principal tem um
    // jogo já começado de propósito (é o que os casos de prazo exigem), e
    // convite em bolão começado é recusado — corretamente. Reaproveitá-lo aqui
    // faria estes casos falharem por um motivo que não é o deles.
    const CONVITE = 'p-convite';

    beforeEach(async () => {
      await db.doc(`boloes/${CONVITE}`).set({
        name: 'Bolão do convite',
        competitionCode: 'PAR',
        matchdayFrom: 12,
        matchdayTo: 12,
        createdBy: ANA,
        createdAt: new Date(),
        pointsExact: 10,
        pointsWinnerDiff: 5,
        pointsWinner: 3,
        tiebreakOrder: ['EXACT_SCORES', 'WINNERS'],
        rulesLockedAt: null,
        closedAt: null,
        closedEarly: false,
        archivedAt: null,
        activeMembers: 3,
      });
      for (const [uid, papel, saiu] of [
        [ANA, 'ADMIN', null],
        [BRUNO, 'MEMBER', null],
        [CARLA, 'MEMBER', new Date()],
      ] as [string, string, Date | null][]) {
        await db.doc(`boloes/${CONVITE}/membros/${uid}`).set({
          userId: uid,
          displayName: uid,
          role: papel,
          joinedAt: new Date(),
          leftAt: saiu,
        });
      }
      await db.doc('jogos/j-convite').set({
        externalId: 'j-convite',
        competitionCode: 'PAR',
        matchday: 12,
        homeTeam: 'Casa',
        awayTeam: 'Fora',
        kickoffAt: new Date(Date.now() + 5 * HORA),
        status: 'SCHEDULED',
        homeScore: null,
        awayScore: null,
        sourceUpdatedAt: null,
      });
    });

    it('CC-4-03 o token não fica em texto no banco', async () => {
      const { token } = await gerar(ANA, CONVITE, db);
      const convites = await db.collection('convites').get();
      const bruto = JSON.stringify(convites.docs.map((d) => ({ id: d.id, ...d.data() })));
      // Nem o valor, nem o id: o id **é** o hash, e é por isso que a entrada custa
      // uma leitura em vez de uma consulta.
      expect(bruto).not.toContain(token);
    });

    it('CC-4-04 cada bolão tem token próprio', async () => {
      const doPool = await gerar(ANA, CONVITE, db);
      await db.doc(`boloes/${OUTRO}/membros/${ANA}`).set({
        userId: ANA,
        displayName: ANA,
        role: 'ADMIN',
        joinedAt: new Date(),
        leftAt: null,
      });
      const doOutro = await gerar(ANA, OUTRO, db);
      expect(doPool.token).not.toBe(doOutro.token);

      const entrada = await entrar(BRUNO, doOutro.token, db);
      expect(entrada.poolId).toBe(OUTRO);
    });

    it('CC-5-10 entrada exige autenticação', async () => {
      // O uid vem do token do chamador, não do corpo: sem autenticação a callable
      // nem chega a olhar o convite.
      const { token } = await gerar(ANA, CONVITE, db);
      expect(token.length).toBeGreaterThan(20);
      // A checagem é `uidDe(request)`, exercitada em `MG-4`; aqui o que se afirma
      // é que ninguém entra sem uid — chamar `entrar('')` não cria vínculo.
      const antes = await db.collection(`boloes/${CONVITE}/membros`).get();
      await recusa(entrar('', 'token-invalido', db));
      const depois = await db.collection(`boloes/${CONVITE}/membros`).get();
      expect(depois.size).toBe(antes.size);
    });

    it('CC-9-05 removido não entra de novo pelo convite', async () => {
      const { token } = await gerar(ANA, CONVITE, db);
      await remover(ANA, CONVITE, BRUNO, db);
      const erro = await recusa(entrar(BRUNO, token, db));
      expect(erro.details).toMatchObject({ motivo: 'SAIU' });
    });

    it('CV-4-08 reentrada pelo convite é recusada', async () => {
      const { token } = await gerar(ANA, CONVITE, db);
      const erro = await recusa(entrar(CARLA, token, db));
      expect(erro.details).toMatchObject({ motivo: 'SAIU' });
    });
  });

  // ---- avisos -------------------------------------------------------------

  describe('avisos (PR-7)', () => {
    beforeEach(async () => {
      await db.collection(`usuarios/${BRUNO}/avisos`).doc('a1').set({
        kind: 'PLACAR_CORRIGIDO',
        poolId: POOL,
        jogos: [{ jogo: 'A x B', placarDe: '1x0', placarPara: '2x0' }],
        title: 'Sua pontuação mudou',
        body: 'texto',
        posicaoDe: null,
        posicaoPara: null,
        createdAt: new Date(),
        readAt: null,
      });
    });

    it('PR-7-07 o aviso é do dono, e ninguém lê o dos outros', async () => {
      await assertSucceeds(getDoc(doc(como(BRUNO), `usuarios/${BRUNO}/avisos/a1`)));
      await assertFails(getDoc(doc(como(ANA), `usuarios/${BRUNO}/avisos/a1`)));
    });

    it('PR-7-08 marcar lido afeta só o próprio aviso, e só o campo lido', async () => {
      await assertSucceeds(
        updateDoc(doc(como(BRUNO), `usuarios/${BRUNO}/avisos/a1`), {
          readAt: new Date(),
        }),
      );
      // Reescrever o texto do aviso apagaria a prova de que o ponto mudou e por quê.
      await assertFails(
        updateDoc(doc(como(BRUNO), `usuarios/${BRUNO}/avisos/a1`), {
          body: 'nada aconteceu',
        }),
      );
      await assertFails(
        updateDoc(doc(como(ANA), `usuarios/${BRUNO}/avisos/a1`), {
          readAt: new Date(),
        }),
      );
    });
  });
});
