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

/**
 * `MG-7` — regras e ciclo de vida no Firestore.
 *
 * **Campeonato próprio (`CIC`) de propósito.** O calendário é global: dois
 * arquivos de teste usando `BSA` na mesma rodada disputam os mesmos jogos, e um
 * jogo com horário no passado deixado por outro arquivo faz este aqui recusar
 * convite por "o bolão já começou" — falha que parece defeito e é vizinhança.
 *
 * A trava das regras (`CC-3`) e o encerramento (`CV-1`/`CV-3`) são as duas
 * regras que mudaram de **lugar** nesta migração: eram comparação dentro de um
 * service, viraram callable com gatilho. O que se verifica aqui é que a regra
 * continua valendo, e que a escrita direta no documento não a contorna.
 */
process.env.FIRESTORE_EMULATOR_HOST ??= '127.0.0.1:8080';
jest.setTimeout(60_000);

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

const HORA = 3600 * 1000;

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

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

const REGRAS = {
  pointsExact: 12,
  pointsWinnerDiff: 6,
  pointsWinner: 2,
  tiebreakOrder: ['WINNERS', 'EXACT_SCORES'],
};

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

/** Bolão com dois jogos abertos, Ana admin e Bruno participante. */
async function bolaoCom(nome: string, jogos: JogoExterno[]) {
  const b = await criar(
    'ana',
    {
      name: nome,
      competitionCode: 'CIC',
      matchdayFrom: jogos[0]!.rodada,
      matchdayTo: jogos[0]!.rodada,
    },
    new FonteFalsa(jogos),
    db,
  );
  const { token } = await gerar('ana', b.id, db);
  await entrar('bruno', token, db);
  return b;
}

describe('MG-7 trava das regras', () => {
  it('MG-7-41 admin altera as regras enquanto ninguém palpitou', async () => {
    const b = await bolaoCom('Bolão das regras', [jogo('r-1', 31, 48 * HORA)]);

    await alterar('ana', b.id, REGRAS, db);

    const bolao = (await db.doc(`boloes/${b.id}`).get()).data();
    expect(bolao).toMatchObject({
      pointsExact: 12,
      pointsWinnerDiff: 6,
      pointsWinner: 2,
      tiebreakOrder: ['WINNERS', 'EXACT_SCORES'],
    });
  });

  it('MG-7-42 participante comum não altera as regras', async () => {
    const b = await bolaoCom('Bolão das regras alheias', [jogo('r-2', 32, 48 * HORA)]);
    const erro = await recusa(alterar('bruno', b.id, REGRAS, db));
    expect(erro.code).toBe('permission-denied');
  });

  it('MG-7-43 o primeiro palpite trava as regras', async () => {
    const b = await bolaoCom('Bolão que trava', [jogo('r-3', 33, 48 * HORA)]);

    await db.doc(`boloes/${b.id}/palpites/r-3_bruno`).set({
      userId: 'bruno',
      matchId: 'r-3',
      homeScore: 2,
      awayScore: 1,
      createdAt: new Date(),
      updatedAt: new Date(),
      matchFinished: false,
      points: null,
      band: null,
      scoredAt: null,
    });

    const travou = await esperar(
      async () => (await db.doc(`boloes/${b.id}`).get()).data()?.rulesLockedAt != null,
    );
    expect(travou).toBe(true);

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

describe('MG-7 encerramento', () => {
  it('MG-7-44 jogo pendente bloqueia o encerramento, dizendo quantos', async () => {
    const b = await bolaoCom('Bolão com jogo pendente', [jogo('e-1', 34, 48 * HORA)]);

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

  it('MG-7-45 tudo apurado encerra e devolve o campeão', async () => {
    const b = await bolaoCom('Bolão a encerrar', [jogo('e-2', 35, 48 * HORA)]);

    // **A classificação é conquistada, não escrita à mão.** Antes este caso
    // gravava a linha do Bruno direto no banco, e passava por acidente: nada
    // recalculava o agregado fora da apuração. Quando o recálculo passou a rodar
    // também na entrada de participante (defeito `CV-4-01`, 08/08), a linha
    // plantada era sobrescrita — e o caso caiu. O cenário certo é o que o produto
    // alcança: palpite, jogo terminado, apuração.
    await db.doc(`boloes/${b.id}/palpites/e-2_bruno`).set({
      userId: 'bruno',
      matchId: 'e-2',
      homeScore: 2,
      awayScore: 1,
      createdAt: new Date(),
      updatedAt: new Date(),
      matchFinished: false,
      points: null,
      band: null,
      scoredAt: null,
    });
    await db.doc('jogos/e-2').update({ status: 'FINISHED', homeScore: 2, awayScore: 1 });

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

    const r = await encerrarSePronto('ana', b.id, db);
    expect(r.closedEarly).toBe(false);
    expect(r.champions).toEqual([
      { userId: 'bruno', displayName: 'Bruno', points: 10 },
    ]);
  });

  it('MG-7-46 encerrar duas vezes devolve o mesmo, sem reencerrar', async () => {
    const b = await bolaoCom('Bolão idempotente', [jogo('e-3', 36, 48 * HORA)]);
    await db.doc('jogos/e-3').update({ status: 'FINISHED', homeScore: 0, awayScore: 0 });

    const primeira = await encerrarSePronto('ana', b.id, db);
    const segunda = await encerrarSePronto('bruno', b.id, db);
    expect(segunda.closedAt).toBe(primeira.closedAt);
  });

  it('MG-7-47 ninguém pontuou: encerra sem campeão', async () => {
    const b = await bolaoCom('Bolão sem acerto', [jogo('e-4', 37, 48 * HORA)]);
    await db.doc('jogos/e-4').update({ status: 'FINISHED', homeScore: 1, awayScore: 1 });
    await db.doc(`boloes/${b.id}/classificacao/bruno`).set({
      userId: 'bruno',
      displayName: 'Bruno',
      points: 0,
      exactScores: 0,
      winners: 0,
      position: 1,
      sharedPosition: false,
      decidedBy: 'PONTOS',
      updatedAt: new Date(),
    });

    const r = await encerrarSePronto('ana', b.id, db);
    // Declarar todo mundo co-campeão de zero ponto é pior que não ter campeão.
    expect(r.champions).toEqual([]);
  });

  it('MG-7-48 encerramento antecipado é do admin e fica marcado como tal', async () => {
    const b = await bolaoCom('Bolão travado', [jogo('e-5', 38, 48 * HORA)]);

    expect((await recusa(encerrarAntes('bruno', b.id, db))).code).toBe(
      'permission-denied',
    );

    const r = await encerrarAntes('ana', b.id, db);
    expect(r.closedEarly).toBe(true);

    // **O horário do jogo não foi tocado.** Ele é global: empurrar o `kickoffAt`
    // para fechar o prazo deste bolão fecharia o dos outros que têm o mesmo jogo.
    const j = (await db.doc('jogos/e-5').get()).data();
    expect(j!.kickoffAt.toMillis()).toBeGreaterThan(Date.now());
  });

  it('MG-7-49 arquivar exige encerrar antes, e volta atrás', async () => {
    const b = await bolaoCom('Bolão a arquivar', [jogo('e-6', 30, 48 * HORA)]);

    const erro = await recusa(arquivar('ana', b.id, true, db));
    expect(erro.details).toMatchObject({ motivo: 'NAO_ENCERRADO' });

    await encerrarAntes('ana', b.id, db);
    await arquivar('ana', b.id, true, db);
    expect((await db.doc(`boloes/${b.id}`).get()).data()!.archivedAt).not.toBeNull();

    await arquivar('ana', b.id, false, db);
    expect((await db.doc(`boloes/${b.id}`).get()).data()!.archivedAt).toBeNull();
  });
});

describe('MG-7 temporada nova', () => {
  it('MG-7-50 recriar herda regras e galera, e zera a pontuação', async () => {
    const b = await bolaoCom('Bolão 2025', [jogo('t-1', 25, 48 * HORA)]);
    await alterar('ana', b.id, REGRAS, db);
    await db.doc(`boloes/${b.id}/classificacao/bruno`).set({
      userId: 'bruno',
      displayName: 'Bruno',
      points: 42,
      exactScores: 3,
      winners: 4,
      position: 1,
      sharedPosition: false,
      decidedBy: 'PONTOS',
      updatedAt: new Date(),
    });
    await encerrarAntes('ana', b.id, db);

    const nova = await recriar(
      'ana',
      b.id,
      26,
      26,
      'Bolão 2026',
      new FonteFalsa([jogo('t-2', 26, 72 * HORA)]),
      db,
    );

    expect(nova).toMatchObject({
      name: 'Bolão 2026',
      inheritedMembers: 2,
      totalMatches: 1,
      recreatedFrom: b.id,
    });

    const novo = (await db.doc(`boloes/${nova.id}`).get()).data();
    // Herda a tabela de pontos...
    expect(novo).toMatchObject({ pointsExact: 12, pointsWinner: 2 });
    // ...e nasce destravado, sem encerramento.
    expect(novo!.rulesLockedAt).toBeNull();
    expect(novo!.closedAt).toBeNull();

    // Nada de pontuação herdada: a competição nova começa em zero para todos.
    const classificacao = await db
      .collection(`boloes/${nova.id}/classificacao`)
      .get();
    expect(classificacao.empty).toBe(true);

    const palpites = await db.collection(`boloes/${nova.id}/palpites`).get();
    expect(palpites.empty).toBe(true);
  });

  it('MG-7-51 quem foi reconvidado é avisado', async () => {
    const b = await bolaoCom('Bolão 2024', [jogo('t-3', 27, 48 * HORA)]);
    await encerrarAntes('ana', b.id, db);

    const nova = await recriar(
      'ana',
      b.id,
      28,
      28,
      undefined,
      new FonteFalsa([jogo('t-4', 28, 72 * HORA)]),
      db,
    );

    const avisos = await db
      .collection('usuarios/bruno/avisos')
      .where('poolId', '==', nova.id)
      .get();
    expect(avisos.size).toBe(1);
    expect(avisos.docs[0]!.data()).toMatchObject({
      kind: 'BOLAO_RECRIADO',
      readAt: null,
    });

    // Quem recriou não se avisa: ele acabou de fazer isso.
    const meus = await db
      .collection('usuarios/ana/avisos')
      .where('poolId', '==', nova.id)
      .get();
    expect(meus.empty).toBe(true);
  });

  it('MG-7-52 recriar exige a temporada atual encerrada', async () => {
    const b = await bolaoCom('Bolão em andamento', [jogo('t-5', 29, 48 * HORA)]);

    const erro = await recusa(
      recriar('ana', b.id, 30, 30, undefined, new FonteFalsa([jogo('t-6', 30, 72 * HORA)]), db),
    );
    expect(erro.details).toMatchObject({ motivo: 'NAO_ENCERRADO' });
  });

  it('MG-7-53 temporada nova sem jogo na fonte não é criada', async () => {
    const b = await bolaoCom('Bolão sem próxima', [jogo('t-7', 24, 48 * HORA)]);
    await encerrarAntes('ana', b.id, db);

    // No NestJS a recriação ligava só os jogos que já estivessem no banco, e
    // temporada nova nunca está: o bolão nascia **sem jogo nenhum**. Aqui recriar
    // é criar, e passa pela fonte igual.
    const erro = await recusa(
      recriar('ana', b.id, 23, 23, undefined, new FonteFalsa([]), db),
    );
    expect(erro.code).toBe('invalid-argument');
  });

  it('MG-7-54 o bolão novo aparece na tela inicial de quem foi herdado', async () => {
    const b = await bolaoCom('Bolão herdado', [jogo('t-8', 22, 48 * HORA)]);
    await encerrarAntes('ana', b.id, db);

    const nova = await recriar(
      'ana',
      b.id,
      21,
      21,
      undefined,
      new FonteFalsa([jogo('t-9', 21, 72 * HORA)]),
      db,
    );

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