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 type { CompeticaoExterna, FonteDeDados, JogoExterno } from './fonte/porta';
import { FonteIndisponivelError } from './fonte/porta';

/**
 * `MG-7` — participação no Firestore: criar, convidar, entrar, sair, papel.
 *
 * São as regras que o NestJS aplicava em `pools.service`, `convites.service` e
 * `members.service`. Aqui elas moram em callable, e o que este arquivo verifica
 * é que **a regra sobreviveu à mudança de casa** — não a aritmética, que não tem.
 *
 * Roda contra os emuladores porque metade das regras é sobre estado que só
 * existe no banco: quantos admins restam, se o vínculo já foi encerrado, se o
 * primeiro jogo já começou.
 */
process.env.FIRESTORE_EMULATOR_HOST ??= '127.0.0.1:8080';
jest.setTimeout(60_000);

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

const HORA = 3600 * 1000;

function jogo(externalId: string, rodada: number, daquiA: number): JogoExterno {
  return {
    externalId,
    competitionCode: 'BSA',
    rodada,
    timeCasa: `Casa ${externalId}`,
    timeFora: `Fora ${externalId}`,
    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: 'BSA', nome: 'Brasileirão', rodadaAtual: 20, totalRodadas: 38 }];
  }
  async listarJogos(): Promise<JogoExterno[]> {
    return this.jogos;
  }
}

class FonteMorta implements FonteDeDados {
  async listarCompeticoes(): Promise<CompeticaoExterna[]> {
    throw new FonteIndisponivelError('timeout');
  }
  async listarJogos(): Promise<JogoExterno[]> {
    throw new FonteIndisponivelError('timeout');
  }
}

const NOMES = { ana: 'Ana', bruno: 'Bruno', carla: 'Carla' };

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

/** Falha com a mensagem, em vez de só com "expected rejection". */
async function recusa(promessa: Promise<unknown>): Promise<{ code: string; message: string; details?: unknown }> {
  try {
    await promessa;
  } catch (e) {
    const erro = e as { code: string; message: string; details?: unknown };
    return erro;
  }
  throw new Error('esperava recusa, mas a operação foi aceita');
}

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

const futuro = [jogo('j-fut-1', 20, 48 * HORA), jogo('j-fut-2', 20, 50 * HORA)];

describe('MG-7 criação do bolão', () => {
  it('MG-7-01 cria o bolão com o criador dentro como admin', async () => {
    const r = await criar(
      'ana',
      {
        name: 'Bolão do setor',
        competitionCode: 'BSA',
        matchdayFrom: 20,
        matchdayTo: 20,
      },
      new FonteFalsa(futuro),
      db,
    );

    expect(r.totalMatches).toBe(2);
    expect(r.openMatches).toBe(2);

    const membro = (await db.doc(`boloes/${r.id}/membros/ana`).get()).data();
    expect(membro).toMatchObject({ role: 'ADMIN', displayName: 'Ana', leftAt: null });

    const bolao = (await db.doc(`boloes/${r.id}`).get()).data();
    // Regras omitidas caem no padrão do PRD, e caem **no documento**: a apuração
    // lê a tabela daqui, nunca de constante (`CC-2 BE-3`).
    expect(bolao).toMatchObject({
      createdBy: 'ana',
      pointsExact: 10,
      pointsWinnerDiff: 5,
      pointsWinner: 3,
      rulesLockedAt: null,
      closedAt: null,
      activeMembers: 1,
    });
  });

  it('MG-7-02 o bolão aparece na tela inicial do criador', async () => {
    const r = await criar(
      'ana',
      { name: 'Bolão da lista', competitionCode: 'BSA', matchdayFrom: 20, matchdayTo: 20 },
      new FonteFalsa(futuro),
      db,
    );

    const apareceu = await esperar(async () =>
      (await db.doc(`usuarios/ana/boloes/${r.id}`).get()).exists,
    );
    expect(apareceu).toBe(true);

    const indice = (await db.doc(`usuarios/ana/boloes/${r.id}`).get()).data();
    expect(indice).toMatchObject({
      name: 'Bolão da lista',
      isAdmin: true,
      closed: false,
      archived: false,
    });
  });

  it('MG-7-03 rodadas sem jogo não viram bolão vazio', async () => {
    const erro = await recusa(
      criar(
        'ana',
        { name: 'Bolão sem jogo', competitionCode: 'BSA', matchdayFrom: 1, matchdayTo: 1 },
        new FonteFalsa([]),
        db,
      ),
    );
    expect(erro.code).toBe('invalid-argument');
  });

  it('MG-7-04 fonte fora do ar não cria bolão pela metade', async () => {
    const erro = await recusa(
      criar(
        'ana',
        { name: 'Bolão sem fonte', competitionCode: 'BSA', matchdayFrom: 20, matchdayTo: 20 },
        new FonteMorta(),
        db,
      ),
    );
    expect(erro.code).toBe('unavailable');
  });

  it('MG-7-05 rodada final antes da inicial é recusada', async () => {
    const erro = await recusa(
      criar(
        'ana',
        { name: 'Bolão invertido', competitionCode: 'BSA', matchdayFrom: 30, matchdayTo: 20 },
        new FonteFalsa(futuro),
        db,
      ),
    );
    expect(erro.code).toBe('invalid-argument');
  });

  it('MG-7-06 bolão só de rodadas passadas nasce avisando: nenhum jogo aberto', async () => {
    const passado = [jogo('j-passado-1', 5, -48 * HORA)];
    const r = await criar(
      'ana',
      { name: 'Bolão atrasado', competitionCode: 'BSA', matchdayFrom: 5, matchdayTo: 5 },
      new FonteFalsa(passado),
      db,
    );
    // Ele é criado — o que não pode é o admin descobrir pelo grupo que ninguém
    // consegue palpitar.
    expect(r.openMatches).toBe(0);
  });

  it('MG-7-07 criar não sobrescreve o placar de jogo que já existe', async () => {
    const jogoComPlacar = jogo('j-com-placar', 21, 48 * HORA);
    await db.doc('jogos/j-com-placar').set({
      externalId: 'j-com-placar',
      competitionCode: 'BSA',
      matchday: 21,
      homeTeam: 'Casa',
      awayTeam: 'Fora',
      kickoffAt: new Date(Date.now() - 48 * HORA),
      status: 'FINISHED',
      homeScore: 2,
      awayScore: 1,
      sourceUpdatedAt: null,
    });

    await criar(
      'bruno',
      { name: 'Bolão do mesmo jogo', competitionCode: 'BSA', matchdayFrom: 21, matchdayTo: 21 },
      new FonteFalsa([jogoComPlacar]),
      db,
    );

    // O calendário é global. Reescrever aqui apagaria o placar que a
    // sincronização trouxe — e com ele a apuração de outro bolão.
    const depois = (await db.doc('jogos/j-com-placar').get()).data();
    expect(depois).toMatchObject({ status: 'FINISHED', homeScore: 2, awayScore: 1 });
  });
});

describe('MG-7 convite e entrada', () => {
  async function bolaoAberto(nome: string) {
    return criar(
      'ana',
      { name: nome, competitionCode: 'BSA', matchdayFrom: 20, matchdayTo: 20 },
      new FonteFalsa(futuro),
      db,
    );
  }

  it('MG-7-08 quem entra pelo convite vira participante', async () => {
    const b = await bolaoAberto('Bolão do convite');
    const { token } = await gerar('ana', b.id, db);

    const r = await entrar('bruno', token, db);
    expect(r).toMatchObject({ poolId: b.id, jaParticipava: false });

    const membro = (await db.doc(`boloes/${b.id}/membros/bruno`).get()).data();
    expect(membro).toMatchObject({ role: 'MEMBER', displayName: 'Bruno' });
  });

  it('MG-7-09 abrir o mesmo link duas vezes não duplica nem dá erro', async () => {
    const b = await bolaoAberto('Bolão do link repetido');
    const { token } = await gerar('ana', b.id, db);

    await entrar('bruno', token, db);
    const segunda = await entrar('bruno', token, db);
    expect(segunda.jaParticipava).toBe(true);
  });

  it('MG-7-10 gerar outro convite mata o link anterior na hora', async () => {
    const b = await bolaoAberto('Bolão do link trocado');
    const primeiro = await gerar('ana', b.id, db);
    await gerar('ana', b.id, db);

    const erro = await recusa(entrar('bruno', primeiro.token, db));
    expect(erro.code).toBe('not-found');
    expect(erro.details).toMatchObject({ motivo: 'INVALIDO' });
  });

  it('MG-7-11 convite invalidado para de funcionar', async () => {
    const b = await bolaoAberto('Bolão do link cancelado');
    const { token } = await gerar('ana', b.id, db);
    await invalidar('ana', b.id, db);

    const erro = await recusa(entrar('bruno', token, db));
    expect(erro.code).toBe('not-found');
  });

  it('MG-7-12 convite expirado diz que expirou, e não "inválido"', async () => {
    const b = await bolaoAberto('Bolão do link vencido');
    const { token } = await gerar('ana', b.id, db);
    // Envelhece o convite direto no banco: esperar 24 h não é teste.
    const convites = await db.collection('convites').where('poolId', '==', b.id).get();
    await convites.docs[0]!.ref.update({ expiresAt: new Date(Date.now() - 1000) });

    const erro = await recusa(entrar('bruno', token, db));
    expect(erro.code).toBe('failed-precondition');
    expect(erro.details).toMatchObject({ motivo: 'EXPIRADO' });
  });

  it('MG-7-13 só admin gera convite', async () => {
    const b = await bolaoAberto('Bolão do convite alheio');
    const { token } = await gerar('ana', b.id, db);
    await entrar('bruno', token, db);

    const erro = await recusa(gerar('bruno', b.id, db));
    expect(erro.code).toBe('permission-denied');
  });

  it('MG-7-14 bolão que já começou não gera convite nem aceita entrada', async () => {
    const jaComecou = [jogo('j-comecou', 22, -HORA), jogo('j-comecou-2', 22, 48 * HORA)];
    const b = await criar(
      'ana',
      { name: 'Bolão em andamento', competitionCode: 'BSA', matchdayFrom: 22, matchdayTo: 22 },
      new FonteFalsa(jaComecou),
      db,
    );

    const erro = await recusa(gerar('ana', b.id, db));
    expect(erro.code).toBe('failed-precondition');
    expect(erro.details).toMatchObject({ motivo: 'JA_COMECOU' });
  });

  it('MG-7-15 convite gerado antes do apito não vale depois dele', async () => {
    const b = await bolaoAberto('Bolão que começou depois');
    const { token } = await gerar('ana', b.id, db);
    // O apito acontece entre gerar e usar — é o caso real do link mandado no
    // grupo e aberto no dia seguinte.
    await db.doc('jogos/j-fut-1').update({ kickoffAt: new Date(Date.now() - HORA) });

    const erro = await recusa(entrar('carla', token, db));
    expect(erro.details).toMatchObject({ motivo: 'JA_COMECOU' });

    await db.doc('jogos/j-fut-1').update({ kickoffAt: new Date(Date.now() + 48 * HORA) });
  });

  it('MG-7-16 quem saiu não volta pelo convite', async () => {
    const b = await bolaoAberto('Bolão sem volta');
    const { token } = await gerar('ana', b.id, db);
    await entrar('bruno', token, db);
    await sair('bruno', b.id, db);

    const erro = await recusa(entrar('bruno', token, db));
    expect(erro.code).toBe('permission-denied');
    expect(erro.details).toMatchObject({ motivo: 'SAIU' });
  });

  it('MG-7-17 bolão encerrado não aceita entrada', async () => {
    const b = await bolaoAberto('Bolão encerrado');
    const { token } = await gerar('ana', b.id, db);
    await db.doc(`boloes/${b.id}`).update({ closedAt: new Date() });

    const erro = await recusa(entrar('carla', token, db));
    expect(erro.details).toMatchObject({ motivo: 'ENCERRADO' });
  });
});

describe('MG-7 papel e saída', () => {
  async function comBruno(nome: string) {
    const b = await criar(
      'ana',
      { name: nome, competitionCode: 'BSA', matchdayFrom: 20, matchdayTo: 20 },
      new FonteFalsa(futuro),
      db,
    );
    const { token } = await gerar('ana', b.id, db);
    await entrar('bruno', token, db);
    return b;
  }

  it('MG-7-18 sair some com o bolão da tela inicial, mas não do histórico', async () => {
    const b = await comBruno('Bolão da saída');
    await esperar(async () => (await db.doc(`usuarios/bruno/boloes/${b.id}`).get()).exists);

    await sair('bruno', b.id, db);

    const saiu = await esperar(
      async () => !(await db.doc(`usuarios/bruno/boloes/${b.id}`).get()).exists,
    );
    expect(saiu).toBe(true);
    // O vínculo é encerrado, **não apagado**: o palpite dele continua no bolão.
    const membro = (await db.doc(`boloes/${b.id}/membros/bruno`).get()).data();
    expect(membro!.leftAt).not.toBeNull();
  });

  it('MG-7-19 a contagem de participantes acompanha entrada e saída', async () => {
    const b = await comBruno('Bolão da contagem');
    const doisAtivos = await esperar(
      async () => (await db.doc(`boloes/${b.id}`).get()).data()?.activeMembers === 2,
    );
    expect(doisAtivos).toBe(true);

    await sair('bruno', b.id, db);

    const voltouAUm = await esperar(
      async () => (await db.doc(`boloes/${b.id}`).get()).data()?.activeMembers === 1,
    );
    expect(voltouAUm).toBe(true);
  });

  it('MG-7-20 quem criou não sai nem é removido nem é rebaixado', async () => {
    const b = await comBruno('Bolão do criador permanente');
    await promover('ana', b.id, 'bruno', db);

    expect((await recusa(sair('ana', b.id, db))).code).toBe('failed-precondition');
    expect((await recusa(remover('bruno', b.id, 'ana', db))).code).toBe('failed-precondition');
    expect((await recusa(rebaixar('bruno', b.id, 'ana', db))).code).toBe('failed-precondition');
  });

  it('MG-7-21 o bolão nunca fica sem admin: o segundo admin sai, o criador fica', async () => {
    const b = await comBruno('Bolão do segundo admin');
    await promover('ana', b.id, 'bruno', db);

    // Bruno é admin e sai — pode, porque Ana continua. **A guarda de "não pode
    // ficar sem admin" nunca chega a disparar:** o criador é admin permanente e
    // não sai, não é removido e não é rebaixado, então sempre sobra um. Ela fica
    // como rede, não como caminho — e este caso é o que documenta isso.
    await sair('bruno', b.id, db);

    const admins = await db
      .collection(`boloes/${b.id}/membros`)
      .where('role', '==', 'ADMIN')
      .where('leftAt', '==', null)
      .get();
    expect(admins.docs.map((d) => d.id)).toEqual(['ana']);

    // E quem saiu perde o poder junto com o vínculo.
    const erro = await recusa(rebaixar('bruno', b.id, 'ana', db));
    expect(erro.code).toBe('permission-denied');
  });

  it('MG-7-22 promover reflete no índice de quem foi promovido', async () => {
    const b = await comBruno('Bolão da promoção');
    await esperar(async () => (await db.doc(`usuarios/bruno/boloes/${b.id}`).get()).exists);

    await promover('ana', b.id, 'bruno', db);

    const virouAdmin = await esperar(
      async () =>
        (await db.doc(`usuarios/bruno/boloes/${b.id}`).get()).data()?.isAdmin === true,
    );
    expect(virouAdmin).toBe(true);
  });

  it('MG-7-23 admin remove participante; o removido não some do bolão', async () => {
    const b = await comBruno('Bolão da remoção');
    await remover('ana', b.id, 'bruno', db);

    const membro = (await db.doc(`boloes/${b.id}/membros/bruno`).get()).data();
    expect(membro!.leftAt).not.toBeNull();

    const erro = await recusa(remover('ana', b.id, 'bruno', db));
    expect(erro.code).toBe('failed-precondition');
  });

  it('MG-7-24 admin usa a ação de sair para sair, não a de remover', async () => {
    const b = await comBruno('Bolão da própria remoção');
    await promover('ana', b.id, 'bruno', db);

    const erro = await recusa(remover('bruno', b.id, 'bruno', db));
    expect(erro.code).toBe('invalid-argument');
  });

  it('MG-7-25 renomear o bolão chega na tela inicial de todo participante', async () => {
    const b = await comBruno('Nome velho');
    await esperar(async () => (await db.doc(`usuarios/bruno/boloes/${b.id}`).get()).exists);

    await db.doc(`boloes/${b.id}`).update({ name: 'Nome novo' });

    const propagou = await esperar(
      async () =>
        (await db.doc(`usuarios/bruno/boloes/${b.id}`).get()).data()?.name === 'Nome novo',
    );
    expect(propagou).toBe(true);
  });

  it('MG-7-26 encerrar o bolão aparece rotulado na tela inicial', async () => {
    const b = await comBruno('Bolão a encerrar');
    await esperar(async () => (await db.doc(`usuarios/bruno/boloes/${b.id}`).get()).exists);

    await db.doc(`boloes/${b.id}`).update({ closedAt: new Date() });

    const rotulado = await esperar(
      async () =>
        (await db.doc(`usuarios/bruno/boloes/${b.id}`).get()).data()?.closed === true,
    );
    expect(rotulado).toBe(true);
  });
});
