import { initializeApp } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import { sincronizar } from './sincronizacao';
import { FonteIndisponivelError, type FonteDeDados, type JogoExterno } from './fonte/porta';

/**
 * `MG-6` — sincronização com lock em **documento**.
 *
 * O caso que dá nome à story é `MG-6-03`: a trava em processo do NestJS deixa de
 * existir em Functions, porque cada invocação é um processo novo e duas podem estar
 * em máquinas diferentes. Sem lock compartilhado, o defeito de 07/08 volta — duas
 * sincronizações gravando a mesma correção, histórico duplicado e aviso de recálculo
 * saindo duas vezes.
 */
process.env.FIRESTORE_EMULATOR_HOST ??= '127.0.0.1:8080';
jest.setTimeout(60_000);

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

const CODIGO = 'TESTE';
const JOGO = 'm-sync';

const jogoExterno = (extra: Partial<JogoExterno> = {}): JogoExterno => ({
  externalId: 'ext-1',
  competitionCode: CODIGO,
  rodada: 23,
  timeCasa: 'Fluminense',
  timeFora: 'Palmeiras',
  inicioEm: new Date('2026-08-15T19:30:00Z'),
  status: 'TIMED',
  golsCasa: null,
  golsFora: null,
  atualizadoEm: new Date('2026-08-07T05:00:00Z'),
  ...extra,
});

/** Fonte controlada: devolve o que o teste mandar, ou falha. */
const fonteQueDevolve = (jogos: JogoExterno[], demoraMs = 0): FonteDeDados => ({
  listarCompeticoes: async () => [],
  listarJogos: async () => {
    // A demora existe para o teste conseguir criar **sobreposição real**. Fonte
    // instantânea faz a primeira execução terminar e soltar o lock antes de a
    // segunda tentar — e aí as duas rodam em sequência, o que é legítimo.
    if (demoraMs > 0) await new Promise((r) => setTimeout(r, demoraMs));
    return jogos;
  },
});
const fonteForaDoAr: FonteDeDados = {
  listarCompeticoes: async () => {
    throw new FonteIndisponivelError('simulada');
  },
  listarJogos: async () => {
    throw new FonteIndisponivelError('simulada');
  },
};

const guardado = async () => (await db.doc(`jogos/${JOGO}`).get()).data();

describe('MG-6 — sincronização', () => {
  beforeEach(async () => {
    await db.doc(`jogos/${JOGO}`).set({
      externalId: 'ext-1',
      competitionCode: CODIGO,
      matchday: 23,
      homeTeam: 'Fluminense',
      awayTeam: 'Palmeiras',
      kickoffAt: new Date('2026-08-15T19:30:00Z'),
      status: 'TIMED',
      homeScore: null,
      awayScore: null,
      sourceUpdatedAt: null,
    });
    await db.doc(`sincronizacoes/${CODIGO}`).delete();
  });

  afterEach(async () => {
    await db.doc(`jogos/${JOGO}`).delete();
    await db.doc(`sincronizacoes/${CODIGO}`).delete();
  });

  it('MG-6-01 placar novo da fonte é aplicado', async () => {
    const r = await sincronizar(
      fonteQueDevolve([jogoExterno({ status: 'FINISHED', golsCasa: 2, golsFora: 1 })]), CODIGO, db);

    expect(r.placaresAlterados).toBe(1);
    expect((await guardado())?.homeScore).toBe(2);
    expect((await guardado())?.status).toBe('FINISHED');
  });

  it('MG-6-02 horário novo é aplicado — é o que reabre o palpite', async () => {
    const remarcado = new Date('2026-09-01T21:00:00Z');
    const r = await sincronizar(
      fonteQueDevolve([jogoExterno({ inicioEm: remarcado })]), CODIGO, db);

    expect(r.horariosAlterados).toBe(1);
    expect((await guardado())?.kickoffAt.toMillis()).toBe(remarcado.getTime());
  });

  it('MG-6-03 duas concorrentes não duplicam efeito — o invariante', async () => {
    // É **este** o defeito de 07/08 (`PP-10-11`): duas sincronizações liam o placar
    // antigo ao mesmo tempo e as duas gravavam correção, deixando o histórico do
    // jogo duplicado e o aviso de recálculo saindo duas vezes para todo mundo.
    //
    // O que o teste afirma é o invariante, não o mecanismo: **uma** alteração de
    // placar no total. Se a primeira terminar antes de a segunda começar, as duas
    // executam e a segunda não acha nada para mudar — o que é igualmente correto.
    const fonte = fonteQueDevolve([
      jogoExterno({ status: 'FINISHED', golsCasa: 3, golsFora: 0 }),
    ]);

    const [a, b] = await Promise.all([
      sincronizar(fonte, CODIGO, db),
      sincronizar(fonte, CODIGO, db),
    ]);

    expect(a.placaresAlterados + b.placaresAlterados).toBe(1);
    expect((await guardado())?.homeScore).toBe(3);
  });

  it('MG-6-03b com sobreposição real, a segunda é ignorada — o mecanismo', async () => {
    // A trava em processo do NestJS deixa de existir em Functions: cada invocação é
    // um processo novo e duas podem estar em máquinas diferentes. Este caso força a
    // sobreposição com uma fonte lenta, e é o que prova que o lock em documento
    // bloqueia de verdade.
    const lenta = fonteQueDevolve(
      [jogoExterno({ status: 'FINISHED', golsCasa: 3, golsFora: 0 })],
      1500,
    );

    const [a, b] = await Promise.all([
      sincronizar(lenta, CODIGO, db),
      // Um respiro para a primeira pegar o lock antes de a segunda tentar.
      new Promise((r) => setTimeout(r, 300)).then(() =>
        sincronizar(lenta, CODIGO, db),
      ),
    ]);

    const ignoradas = [a, b].filter((r) => r.ignorada);
    expect(ignoradas).toHaveLength(1);
    // A ignorada volta com zero, não com os números da outra: somar duas vezes
    // mentiria no relatório do job.
    expect(ignoradas[0]?.placaresAlterados).toBe(0);
  });

  it('MG-6-04 lock abandonado não trava a sincronização para sempre', async () => {
    // Function que morre no meio (timeout, erro fatal) deixaria o lock preso e a
    // sincronização pararia **em silêncio** — o modo de falha que esta story existe
    // para eliminar. Daí a validade.
    await db.doc(`sincronizacoes/${CODIGO}`).set({
      emAndamento: true,
      desde: new Date(Date.now() - 60 * 60 * 1000),
    });

    const r = await sincronizar(
      fonteQueDevolve([jogoExterno({ status: 'FINISHED', golsCasa: 1, golsFora: 1 })]), CODIGO, db);

    expect(r.ignorada).toBe(false);
    expect((await guardado())?.homeScore).toBe(1);
  });

  it('MG-6-05 fonte fora do ar não sobrescreve dado bom', async () => {
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED', homeScore: 2, awayScore: 1,
    });

    const r = await sincronizar(fonteForaDoAr, CODIGO, db);

    expect(r.falhou).toBe(true);
    // Apagar horário e placar levaria embora o prazo e a apuração de todos.
    expect((await guardado())?.homeScore).toBe(2);
    expect((await guardado())?.status).toBe('FINISHED');
  });

  it('MG-6-06 jogo que a fonte não conhece mais permanece intacto', async () => {
    await db.doc(`jogos/${JOGO}`).update({
      status: 'FINISHED', homeScore: 4, awayScore: 0,
    });

    // Fonte responde, mas sem o nosso jogo. Apagar levaria embora os palpites de
    // todos por causa de uma falha da fonte (`PP-10-08`).
    const r = await sincronizar(fonteQueDevolve([]), CODIGO, db);

    expect(r.falhou).toBe(false);
    expect((await guardado())?.homeScore).toBe(4);
  });

  it('MG-6-07 nada mudou: nenhuma escrita', async () => {
    // Escrever igual custa operação e, pior, acordaria os gatilhos de apuração e
    // sigilo à toa — no serviço em que custo é por operação.
    const antes = (await db.doc(`jogos/${JOGO}`).get()).updateTime;
    const r = await sincronizar(fonteQueDevolve([jogoExterno()]), CODIGO, db);
    const depois = (await db.doc(`jogos/${JOGO}`).get()).updateTime;

    expect(r.jogosVerificados).toBe(1);
    expect(r.placaresAlterados).toBe(0);
    expect(depois!.isEqual(antes!)).toBe(true);
  });

  it('MG-6-08 o lock é por competição — uma não bloqueia a outra', async () => {
    await db.doc(`sincronizacoes/${CODIGO}`).set({
      emAndamento: true,
      desde: new Date(),
    });

    const outra = await sincronizar(fonteQueDevolve([]), 'OUTRA', db);
    expect(outra.ignorada).toBe(false);

    await db.doc('sincronizacoes/OUTRA').delete();
  });
});
