import { initializeApp } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import { criar, entrar, gerar, invalidar } from './boloes';
import { promover, rebaixar, remover, sair } from './membros';
import { alterar } from './regras';
import { arquivar, encerrarAntes, encerrarSePronto, recriar } from './ciclo-vida';
import { corrigir } from './correcao';
import type { CompeticaoExterna, FonteDeDados, JogoExterno } from './fonte/porta';

/**
 * `MG-8` — paridade dos **fluxos de bolão**, com os IDs de origem.
 *
 * Criação, regras, convite, papel, saída e ciclo de vida. São casos que existem
 * iguais nos dois backends: o que mudou foi **quem recusa**, e é isso que cada um
 * confirma.
 *
 * O cenário é montado pelas próprias callables, não escrito à mão no banco. É mais
 * lento e é de propósito: cenário escrito à mão é cenário que pode ser impossível
 * de alcançar pelo produto, e caso que só passa em estado impossível não prova
 * nada.
 */

process.env.FIRESTORE_EMULATOR_HOST ??= '127.0.0.1:8080';
jest.setTimeout(120_000);

const app = initializeApp({ projectId: 'demo-bolao' }, `fluxos-${Date.now()}`);
const db = getFirestore(app);

const HORA = 3600 * 1000;
const marca = Date.now();
const ANA = `ana-${marca}`;
const BRUNO = `bruno-${marca}`;
const CARLA = `carla-${marca}`;

/**
 * **Um campeonato por caso.** O calendário é global e sobrevive ao bolão: dois
 * casos na mesma competição e rodada disputariam os mesmos jogos, e um horário
 * deixado por outro faria este recusar convite por "o bolão já começou". Foi o
 * que aconteceu em 07/08 entre dois arquivos de teste — aqui a lição vira regra.
 */
let sufixoDaCompeticao = 0;
const proximaCompeticao = () => {
  const letras = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
  const i = sufixoDaCompeticao++;
  return `F${letras[Math.floor(i / 26) % 26]}${letras[i % 26]}`;
};

/** Rodada com jogos em horários diferentes — como uma rodada de verdade. */
function jogosDaRodada(competicao: string, deslocamentos: number[]): JogoExterno[] {
  const rodada = 1;
  return deslocamentos.map((h, i) => ({
    externalId: `flx-${marca}-${competicao}-${i}`,
    competitionCode: competicao,
    rodada,
    timeCasa: `Casa ${i}`,
    timeFora: `Fora ${i}`,
    inicioEm: new Date(Date.now() + h * HORA),
    status: 'SCHEDULED',
    golsCasa: null,
    golsFora: null,
    atualizadoEm: null,
  }));
}

class FonteFalsa implements FonteDeDados {
  constructor(private readonly jogos: JogoExterno[]) {}
  async listarCompeticoes(): Promise<CompeticaoExterna[]> {
    return [
      {
        code: this.jogos[0]?.competitionCode ?? 'FLX',
        nome: 'Fluxos',
        rodadaAtual: 1,
        totalRodadas: 38,
      },
    ];
  }
  async listarJogos(): Promise<JogoExterno[]> {
    return this.jogos;
  }
}

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

async function esperar(cond: () => Promise<boolean>, limiteMs = 25000) {
  const fim = Date.now() + limiteMs;
  while (Date.now() < fim) {
    if (await cond()) return true;
    await new Promise((r) => setTimeout(r, 400));
  }
  return false;
}

async function bolaoNovo(
  nome: string,
  jogos: JogoExterno[],
  regras?: Record<string, number | string[]>,
) {
  // Limpa o calendário desta competição: execuções anteriores do arquivo
  // deixaram jogos com o mesmo código, e eles entrariam nas contas deste caso.
  const antigos = await db
    .collection('jogos')
    .where('competitionCode', '==', jogos[0]!.competitionCode)
    .get();
  await Promise.all(antigos.docs.map((d) => d.ref.delete()));

  return criar(
    ANA,
    {
      name: nome,
      competitionCode: jogos[0]!.competitionCode,
      matchdayFrom: 1,
      matchdayTo: 1,
      ...(regras ? { rules: regras } : {}),
    },
    new FonteFalsa(jogos),
    db,
  );
}

async function comBruno(nome: string, jogos: JogoExterno[]) {
  const b = await bolaoNovo(nome, jogos);
  const { token } = await gerar(ANA, b.id, db);
  await entrar(BRUNO, token, db);
  return b;
}

/**
 * Bolão com Bruno dentro e **a rodada já começada**.
 *
 * Os dois estados não podem ser montados na ordem ingênua: a entrada fecha no
 * primeiro jogo, então convidar depois do apito é recusado — corretamente. O
 * caminho é o mesmo da vida real: entra todo mundo, e **depois** o jogo começa.
 * Quem move o horário aqui é o teste; em produção é a sincronização.
 */
async function comBrunoEOJogoComecado(nome: string, competicao: string) {
  const jogos = jogosDaRodada(competicao, [5]);
  const b = await comBruno(nome, jogos);
  await db.doc(`jogos/${jogos[0]!.externalId}`).update({
    kickoffAt: new Date(Date.now() - 3 * HORA),
  });
  return { bolao: b, jogoId: jogos[0]!.externalId };
}

const palpitar = (poolId: string, matchId: string, uid: string) =>
  db.doc(`boloes/${poolId}/palpites/${matchId}_${uid}`).set({
    userId: uid,
    matchId,
    homeScore: 1,
    awayScore: 0,
    createdAt: new Date(),
    updatedAt: new Date(),
    matchFinished: false,
    points: null,
    band: null,
    scoredAt: null,
  });

beforeAll(async () => {
  for (const uid of [ANA, BRUNO, CARLA]) {
    await db.doc(`usuarios/${uid}`).set({
      displayName: uid,
      email: `${uid}@teste.com`,
      createdAt: new Date(),
    });
  }
});

describe('CC-1 / CC-2 — criação e regras da criação', () => {
  it('CC-1-10 intervalo que cruza hoje: prazo aberto parcial', async () => {
    const b = await bolaoNovo('cruza hoje', jogosDaRodada(proximaCompeticao(), [-2, 5, 30]));
    // O bolão é criado, e o número diz a verdade: 2 de 3 dão para palpitar. Sem
    // ele o admin descobre pelo grupo reclamando.
    expect(b.totalMatches).toBe(3);
    expect(b.openMatches).toBe(2);
  });

  it('CC-1-12 horários distintos por jogo, não por rodada', async () => {
    const camp = proximaCompeticao();
    const b = await bolaoNovo('horários', jogosDaRodada(camp, [5, 8, 30]));
    const jogos = await db
      .collection('jogos')
      .where('competitionCode', '==', camp)
      .where('matchday', '==', 1)
      .get();

    const horarios = jogos.docs.map((d) =>
      (d.data().kickoffAt as FirebaseFirestore.Timestamp).toMillis(),
    );
    // Prazo por rodada fecharia o jogo de sábado no horário do de domingo.
    expect(new Set(horarios).size).toBe(3);
    expect(b.totalMatches).toBe(3);
  });

  it('CC-2-05 a criação aceita tabela diferente do padrão', async () => {
    const b = await bolaoNovo('tabela própria', jogosDaRodada(proximaCompeticao(), [5]), {
      pointsExact: 25,
      pointsWinnerDiff: 12,
      pointsWinner: 6,
      tiebreakOrder: ['WINNERS', 'EXACT_SCORES'],
    });
    const bolao = (await db.doc(`boloes/${b.id}`).get()).data();
    expect(bolao).toMatchObject({
      pointsExact: 25,
      pointsWinnerDiff: 12,
      pointsWinner: 6,
      tiebreakOrder: ['WINNERS', 'EXACT_SCORES'],
    });
  });

  it('CC-2-11 valores inválidos e critério desconhecido são recusados', async () => {
    const b = await bolaoNovo('validação', jogosDaRodada(proximaCompeticao(), [5]));

    const erro = await recusa(
      alterar(
        ANA,
        b.id,
        {
          pointsExact: -1,
          pointsWinnerDiff: 5,
          pointsWinner: 3,
          tiebreakOrder: ['EXACT_SCORES'],
        },
        db,
      ),
    );
    expect(erro.code).toBe('invalid-argument');

    // Critério desconhecido não vira desempate mudo: cai no padrão, e o bolão
    // continua tendo como desempatar.
    await alterar(
      ANA,
      b.id,
      {
        pointsExact: 10,
        pointsWinnerDiff: 5,
        pointsWinner: 3,
        tiebreakOrder: ['INVENTADO'],
      },
      db,
    );
    const bolao = (await db.doc(`boloes/${b.id}`).get()).data();
    expect(bolao!.tiebreakOrder).toEqual(['EXACT_SCORES', 'WINNERS']);
  });
});

describe('CC-3 — travamento das regras', () => {
  it('CC-3-01 alterar antes de qualquer palpite', async () => {
    const b = await bolaoNovo('destravado', jogosDaRodada(proximaCompeticao(), [5]));
    await alterar(
      ANA,
      b.id,
      {
        pointsExact: 15,
        pointsWinnerDiff: 7,
        pointsWinner: 4,
        tiebreakOrder: ['WINNERS'],
      },
      db,
    );
    expect((await db.doc(`boloes/${b.id}`).get()).data()!.pointsExact).toBe(15);
  });

  it('CC-3-03 e CC-3-06 o primeiro palpite trava, e os seguintes não movem a trava', async () => {
    const jogos = jogosDaRodada(proximaCompeticao(), [5, 8]);
    const b = await comBruno('trava', jogos);

    await palpitar(b.id, jogos[0]!.externalId, BRUNO);
    await esperar(
      async () => (await db.doc(`boloes/${b.id}`).get()).data()?.rulesLockedAt != null,
    );
    const primeiraTrava = (await db.doc(`boloes/${b.id}`).get()).data()!.rulesLockedAt;

    await palpitar(b.id, jogos[1]!.externalId, BRUNO);
    await new Promise((r) => setTimeout(r, 3000));

    // O momento do travamento é o do **primeiro** palpite: mover a cada palpite
    // faria "quando travou" ser uma data que muda, e a auditoria perderia sentido.
    const depois = (await db.doc(`boloes/${b.id}`).get()).data()!.rulesLockedAt;
    expect(depois.toMillis()).toBe(primeiraTrava.toMillis());

    const erro = await recusa(
      alterar(
        ANA,
        b.id,
        {
          pointsExact: 15,
          pointsWinnerDiff: 7,
          pointsWinner: 4,
          tiebreakOrder: ['WINNERS'],
        },
        db,
      ),
    );
    expect(erro.details).toMatchObject({ motivo: 'TRAVADAS' });
  });
});

describe('CC-4 / CC-5 — convite', () => {
  it('CC-4-01 admin gera convite com validade de 1 dia', async () => {
    const b = await bolaoNovo('convite', jogosDaRodada(proximaCompeticao(), [30]));
    const { expiresAt } = await gerar(ANA, b.id, db);

    const horas = (new Date(expiresAt).getTime() - Date.now()) / HORA;
    expect(horas).toBeGreaterThan(23);
    expect(horas).toBeLessThanOrEqual(24);
  });

  it('CC-4-02 o convidado cai no bolão certo', async () => {
    const primeiro = await bolaoNovo('destino-1', jogosDaRodada(proximaCompeticao(), [30]));
    const segundo = await bolaoNovo('destino-2', jogosDaRodada(proximaCompeticao(), [30]));
    const { token } = await gerar(ANA, segundo.id, db);

    const entrada = await entrar(BRUNO, token, db);
    expect(entrada.poolId).toBe(segundo.id);
    expect(entrada.poolName).toBe('destino-2');
    expect(entrada.poolId).not.toBe(primeiro.id);
  });

  it('CC-4-09 bolão encerrado recusa entrada e geração', async () => {
    const b = await bolaoNovo('encerrado', jogosDaRodada(proximaCompeticao(), [30]));
    const { token } = await gerar(ANA, b.id, db);
    await encerrarAntes(ANA, b.id, db);

    expect((await recusa(gerar(ANA, b.id, db))).details).toMatchObject({
      motivo: 'ENCERRADO',
    });
    expect((await recusa(entrar(BRUNO, token, db))).details).toMatchObject({
      motivo: 'ENCERRADO',
    });
  });

  it('CC-4-10 invalidar sem convite ativo não estoura', async () => {
    const b = await bolaoNovo('sem convite', jogosDaRodada(proximaCompeticao(), [30]));
    // Nunca gerou nenhum. Invalidar tem de ser inofensivo — o admin não sabe se
    // existe convite ativo, e a ação existe para ele poder ter certeza.
    await expect(invalidar(ANA, b.id, db)).resolves.toBeUndefined();
  });

  it('CC-4-11 reabrir o link não duplica participação', async () => {
    const b = await bolaoNovo('link repetido', jogosDaRodada(proximaCompeticao(), [30]));
    const { token } = await gerar(ANA, b.id, db);

    await entrar(BRUNO, token, db);
    await entrar(BRUNO, token, db);
    await entrar(BRUNO, token, db);

    const membros = await db.collection(`boloes/${b.id}/membros`).get();
    expect(membros.size).toBe(2);
  });

  it('CC-5-03 convite inválido é distinto de expirado', async () => {
    const erro = await recusa(entrar(BRUNO, 'nao-existe', db));
    expect(erro.code).toBe('not-found');
    expect(erro.details).toMatchObject({ motivo: 'INVALIDO' });
  });
});

describe('CC-7 / CC-8 / CC-9 — participantes e papel', () => {
  it('CC-7-02 e CC-7-09 quem saiu aparece marcado, e o papel aparece', async () => {
    const b = await comBruno('lista', jogosDaRodada(proximaCompeticao(), [30]));
    const { token } = await gerar(ANA, b.id, db);
    await entrar(CARLA, token, db);
    await sair(CARLA, b.id, db);

    const membros = await db.collection(`boloes/${b.id}/membros`).get();
    const porUid = new Map(membros.docs.map((d) => [d.id, d.data()]));

    // Sumir com quem saiu apagaria o histórico dela do bolão aos olhos de quem
    // ficou — e os palpites dela continuam contando no passado.
    expect(porUid.get(CARLA)!.leftAt).not.toBeNull();
    expect(porUid.get(ANA)!.role).toBe('ADMIN');
    expect(porUid.get(BRUNO)!.role).toBe('MEMBER');
  });

  it('CC-8-02 admin rebaixa o outro admin', async () => {
    const b = await comBruno('rebaixa', jogosDaRodada(proximaCompeticao(), [30]));
    await promover(ANA, b.id, BRUNO, db);
    expect(await rebaixar(ANA, b.id, BRUNO, db)).toEqual({ changed: true });
    expect(
      (await db.doc(`boloes/${b.id}/membros/${BRUNO}`).get()).data()!.role,
    ).toBe('MEMBER');
  });

  it('CC-8-06 promover quem já é admin é no-op', async () => {
    const b = await comBruno('no-op', jogosDaRodada(proximaCompeticao(), [30]));
    await promover(ANA, b.id, BRUNO, db);
    // Repetir não é erro: o admin clicou duas vezes, e o estado desejado já é o
    // que está lá.
    expect(await promover(ANA, b.id, BRUNO, db)).toEqual({ changed: false });
  });

  it('CC-8-07 promover quem saiu do bolão é recusado', async () => {
    const b = await comBruno('promover-saiu', jogosDaRodada(proximaCompeticao(), [30]));
    await sair(BRUNO, b.id, db);
    const erro = await recusa(promover(ANA, b.id, BRUNO, db));
    expect(erro.code).toBe('failed-precondition');
  });

  it('CC-9-02 e CC-9-03 o histórico do removido permanece, e quem ficou não é recalculado', async () => {
    const { bolao: b, jogoId } = await comBrunoEOJogoComecado(
      'remoção',
      proximaCompeticao(),
    );

    await palpitar(b.id, jogoId, ANA);
    await palpitar(b.id, jogoId, BRUNO);
    await db.doc(`jogos/${jogoId}`).update({
      status: 'FINISHED',
      homeScore: 1,
      awayScore: 0,
    });
    await esperar(
      async () =>
        (await db.doc(`boloes/${b.id}/classificacao/${ANA}`).get()).data()?.points === 10,
    );

    await remover(ANA, b.id, BRUNO, db);
    await new Promise((r) => setTimeout(r, 3000));

    // O palpite do removido **fica**: o admin não apaga histórico alheio.
    const palpite = await db.doc(`boloes/${b.id}/palpites/${jogoId}_${BRUNO}`).get();
    expect(palpite.exists).toBe(true);
    expect(palpite.data()!.points).toBe(10);

    // E a pontuação de quem ficou não muda por causa da saída de outro.
    const ana = await db.doc(`boloes/${b.id}/classificacao/${ANA}`).get();
    expect(ana.data()!.points).toBe(10);
  });

  it('CC-9-08 e CC-9-10 remover quem já saiu, ou quem não é do bolão', async () => {
    const b = await comBruno('remover-borda', jogosDaRodada(proximaCompeticao(), [30]));
    await sair(BRUNO, b.id, db);

    expect((await recusa(remover(ANA, b.id, BRUNO, db))).code).toBe(
      'failed-precondition',
    );
    expect((await recusa(remover(ANA, b.id, CARLA, db))).code).toBe('not-found');
  });
});

describe('CC-10 — a lista de bolões', () => {
  it('CC-10-02 quem entrou pelo convite aparece sem admin', async () => {
    const b = await comBruno('lista-convidado', jogosDaRodada(proximaCompeticao(), [30]));

    const apareceu = await esperar(
      async () => (await db.doc(`usuarios/${BRUNO}/boloes/${b.id}`).get()).exists,
    );
    expect(apareceu).toBe(true);
    expect(
      (await db.doc(`usuarios/${BRUNO}/boloes/${b.id}`).get()).data()!.isAdmin,
    ).toBe(false);
  });

  it('CC-10-06 bolão de que saí deixa de aparecer', async () => {
    const b = await comBruno('lista-saida', jogosDaRodada(proximaCompeticao(), [30]));
    await esperar(
      async () => (await db.doc(`usuarios/${BRUNO}/boloes/${b.id}`).get()).exists,
    );

    await sair(BRUNO, b.id, db);
    const sumiu = await esperar(
      async () => !(await db.doc(`usuarios/${BRUNO}/boloes/${b.id}`).get()).exists,
    );
    expect(sumiu).toBe(true);
  });

  it('CC-10-08 bolão arquivado continua na lista, marcado', async () => {
    const b = await comBruno('lista-arquivada', jogosDaRodada(proximaCompeticao(), [30]));
    await esperar(
      async () => (await db.doc(`usuarios/${BRUNO}/boloes/${b.id}`).get()).exists,
    );

    await encerrarAntes(ANA, b.id, db);
    await arquivar(ANA, b.id, true, db);

    // Sumir com o arquivado fazia o bolão desaparecer do app sem nada explicar.
    const marcado = await esperar(async () => {
      const d = await db.doc(`usuarios/${BRUNO}/boloes/${b.id}`).get();
      return d.exists && d.data()!.archived === true;
    });
    expect(marcado).toBe(true);
  });
});

describe('CV-1 / CV-3 / CV-6 — encerrar e arquivar', () => {
  it('CV-1-08 a classificação final acompanha o campeão', async () => {
    const { bolao: b, jogoId } = await comBrunoEOJogoComecado(
      'final',
      proximaCompeticao(),
    );

    await palpitar(b.id, jogoId, ANA);
    await db.doc(`jogos/${jogoId}`).update({
      status: 'FINISHED',
      homeScore: 1,
      awayScore: 0,
    });
    await esperar(
      async () =>
        (await db.doc(`boloes/${b.id}/classificacao/${ANA}`).get()).data()?.points === 10,
    );

    const r = await encerrarSePronto(ANA, b.id, db);
    expect(r.champions.map((c) => c.userId)).toEqual([ANA]);
    // O ranking final vem junto do campeão, e não numa segunda chamada: campeão
    // sem a tabela ao lado é anúncio sem prova.
    expect(r.finalRanking).toHaveLength(2);
    expect(r.finalRanking[0]).toMatchObject({ position: 1, points: 10 });
  });

  it('CV-3-01 e CV-3-06 encerramento antecipado, e encerrar duas vezes', async () => {
    const b = await comBruno('antecipado', jogosDaRodada(proximaCompeticao(), [30]));

    const r = await encerrarAntes(ANA, b.id, db);
    expect(r.closedEarly).toBe(true);
    expect((await recusa(encerrarAntes(ANA, b.id, db))).details).toMatchObject({
      motivo: 'JA_ENCERRADO',
    });
  });

  it('CV-3-07 é a saída para bolão travado por jogo adiado', async () => {
    const { bolao: b, jogoId } = await comBrunoEOJogoComecado(
      'travado',
      proximaCompeticao(),
    );
    await db.doc(`jogos/${jogoId}`).update({ status: 'POSTPONED' });

    // Sem placar, o encerramento normal recusa **para sempre** — e o bolão fica
    // pendurado num jogo que talvez nunca aconteça.
    expect((await recusa(encerrarSePronto(ANA, b.id, db))).details).toMatchObject({
      motivo: 'JOGO_PENDENTE',
    });
    expect((await encerrarAntes(ANA, b.id, db)).closedEarly).toBe(true);
  });

  it('CV-6-01 e CV-6-03 arquivar exige encerrado', async () => {
    const b = await comBruno('arquivo', jogosDaRodada(proximaCompeticao(), [30]));
    expect((await recusa(arquivar(ANA, b.id, true, db))).details).toMatchObject({
      motivo: 'NAO_ENCERRADO',
    });

    await encerrarAntes(ANA, b.id, db);
    expect(await arquivar(ANA, b.id, true, db)).toEqual({ archived: true });
  });

  it('CV-6-07 e CV-6-08 correção em bolão arquivado recalcula e pode trocar o campeão', async () => {
    const { bolao: b, jogoId } = await comBrunoEOJogoComecado(
      'arquivado-corrigido',
      proximaCompeticao(),
    );

    // Ana acerta em cheio, Bruno erra a faixa.
    await palpitar(b.id, jogoId, ANA);
    await db.doc(`boloes/${b.id}/palpites/${jogoId}_${BRUNO}`).set({
      userId: BRUNO,
      matchId: jogoId,
      homeScore: 3,
      awayScore: 2,
      createdAt: new Date(),
      updatedAt: new Date(),
      matchFinished: false,
      points: null,
      band: null,
      scoredAt: null,
    });
    await db.doc(`jogos/${jogoId}`).update({
      status: 'FINISHED',
      homeScore: 1,
      awayScore: 0,
    });
    await esperar(
      async () =>
        (await db.doc(`boloes/${b.id}/classificacao/${ANA}`).get()).data()?.points === 10,
    );

    await encerrarAntes(ANA, b.id, db);
    await arquivar(ANA, b.id, true, db);

    // Arquivado é somente leitura **para palpite** — não para a verdade do
    // placar. Correção continua valendo, e pode trocar o campeão de um bolão que
    // já estava guardado.
    await corrigir(ANA, b.id, jogoId, 3, 2, db);
    const trocou = await esperar(async () => {
      const bruno = (await db.doc(`boloes/${b.id}/classificacao/${BRUNO}`).get()).data();
      return bruno?.points === 10 && bruno?.position === 1;
    });
    expect(trocou).toBe(true);
  });
});

describe('CV-4 / CV-7 — saída e temporada nova', () => {
  it('CV-4-01 e CV-4-03 quem sai deixa o ranking ativo, e quem fica não é recalculado', async () => {
    const { bolao: b, jogoId } = await comBrunoEOJogoComecado(
      'saida',
      proximaCompeticao(),
    );

    await palpitar(b.id, jogoId, ANA);
    await palpitar(b.id, jogoId, BRUNO);
    await db.doc(`jogos/${jogoId}`).update({
      status: 'FINISHED',
      homeScore: 1,
      awayScore: 0,
    });
    await esperar(async () => {
      const c = await db.collection(`boloes/${b.id}/classificacao`).get();
      return c.size === 2;
    });

    await sair(BRUNO, b.id, db);
    const saiuDoRanking = await esperar(async () => {
      const c = await db.collection(`boloes/${b.id}/classificacao`).get();
      return c.docs.every((d) => d.id !== BRUNO);
    });
    expect(saiuDoRanking).toBe(true);

    const ana = await db.doc(`boloes/${b.id}/classificacao/${ANA}`).get();
    expect(ana.data()!.points).toBe(10);
  });

  it('CV-4-06 o criador nunca sai, e é o que garante admin sempre', async () => {
    const b = await comBruno('unico-admin', jogosDaRodada(proximaCompeticao(), [30]));
    const erro = await recusa(sair(ANA, b.id, db));
    expect(erro.code).toBe('failed-precondition');
    expect(erro.message).toContain('administrador permanente');
  });

  it('CV-7-03, CV-7-04 e CV-7-06 a temporada nova nasce destravada, sem quem saiu, com quem recriou de admin', async () => {
    const b = await comBruno('temporada', jogosDaRodada(proximaCompeticao(), [30]));
    const { token } = await gerar(ANA, b.id, db);
    await entrar(CARLA, token, db);
    await sair(CARLA, b.id, db);
    await encerrarAntes(ANA, b.id, db);

    const nova = await recriar(
      ANA,
      b.id,
      1,
      1,
      undefined,
      new FonteFalsa(jogosDaRodada(proximaCompeticao(), [40])),
      db,
    );

    const novo = (await db.doc(`boloes/${nova.id}`).get()).data();
    expect(novo!.rulesLockedAt).toBeNull();

    const membros = await db.collection(`boloes/${nova.id}/membros`).get();
    // Quem saiu não é reconvidado: a saída é definitiva, e reconvidar seria
    // desfazer por fora uma decisão que o produto trata como sem volta.
    expect(membros.docs.map((d) => d.id).sort()).toEqual([ANA, BRUNO].sort());
    expect(membros.docs.find((d) => d.id === ANA)!.data().role).toBe('ADMIN');
  });

  it('CV-7-09 o reconvidado pode sair, e a saída continua definitiva', async () => {
    const b = await comBruno('temporada-saida', jogosDaRodada(proximaCompeticao(), [30]));
    await encerrarAntes(ANA, b.id, db);

    const nova = await recriar(
      ANA,
      b.id,
      1,
      1,
      undefined,
      new FonteFalsa(jogosDaRodada(proximaCompeticao(), [40])),
      db,
    );

    await sair(BRUNO, nova.id, db);
    const { token } = await gerar(ANA, nova.id, db);
    const erro = await recusa(entrar(BRUNO, token, db));
    expect(erro.details).toMatchObject({ motivo: 'SAIU' });
  });
});
