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

/**
 * `MG-8` — paridade do **palpite e da sincronização**, com os IDs de origem.
 *
 * Aqui moram as regras que corrompem a competição quando quebram: prazo, sigilo,
 * reabertura de jogo adiado e o que a fonte oficial pode ou não sobrescrever.
 *
 * O palpite é escrita direta com rule, então a recusa por prazo é verificada nos
 * casos de rule (`paridade-seguranca.spec.ts`). O que se verifica aqui é o outro
 * lado: o que acontece com o palpite **já feito** quando o mundo muda embaixo
 * dele.
 */

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

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

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

let contador = 0;
const proximaCompeticao = () => {
  const letras = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
  const i = contador++;
  return `P${letras[Math.floor(i / 26) % 26]}${letras[i % 26]}`;
};

interface Externo {
  id: string;
  daquiA: number;
  status?: JogoExterno['status'];
  casa?: number | null;
  fora?: number | null;
}

/**
 * Base fixa para os horários.
 *
 * Sem ela, cada chamada gerava `Date.now() + deslocamento` — instantes
 * **diferentes** para o mesmo deslocamento, alguns milissegundos à parte. A
 * sincronização então via "horário mudou" em jogo que não mudou, e o caso
 * `PP-10-01` acusava o produto de uma alteração que o teste tinha inventado.
 */
const BASE = Date.now();

function externos(competicao: string, jogos: Externo[]): JogoExterno[] {
  return jogos.map((j) => ({
    externalId: `${competicao}-${j.id}`,
    competitionCode: competicao,
    rodada: 1,
    timeCasa: 'Casa',
    timeFora: 'Fora',
    inicioEm: new Date(BASE + j.daquiA),
    status: j.status ?? 'SCHEDULED',
    golsCasa: j.casa ?? null,
    golsFora: j.fora ?? null,
    atualizadoEm: null,
  }));
}

class Fonte implements FonteDeDados {
  constructor(
    private jogos: JogoExterno[],
    private readonly falha = false,
  ) {}
  trocar(jogos: JogoExterno[]) {
    this.jogos = jogos;
  }
  async listarCompeticoes(): Promise<CompeticaoExterna[]> {
    if (this.falha) throw new FonteIndisponivelError('teste');
    return [
      {
        code: this.jogos[0]?.competitionCode ?? 'PXX',
        nome: 'Palpite',
        rodadaAtual: 1,
        totalRodadas: 38,
      },
    ];
  }
  async listarJogos(): Promise<JogoExterno[]> {
    if (this.falha) throw new FonteIndisponivelError('teste');
    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;
}

/** Bolão com Ana e Bruno, e o calendário desta competição no estado pedido. */
async function cenario(competicao: string, jogos: JogoExterno[]) {
  const poolId = `p-${competicao}-${marca}`;
  await db.doc(`boloes/${poolId}`).set({
    name: poolId,
    competitionCode: competicao,
    matchdayFrom: 1,
    matchdayTo: 1,
    createdBy: ANA,
    createdAt: new Date(),
    pointsExact: 10,
    pointsWinnerDiff: 5,
    pointsWinner: 3,
    tiebreakOrder: ['EXACT_SCORES', 'WINNERS'],
    rulesLockedAt: null,
    closedAt: null,
    closedEarly: false,
    archivedAt: null,
    activeMembers: 2,
  });
  for (const uid of [ANA, BRUNO]) {
    await db.doc(`boloes/${poolId}/membros/${uid}`).set({
      userId: uid,
      displayName: uid,
      role: uid === ANA ? 'ADMIN' : 'MEMBER',
      joinedAt: new Date(),
      leftAt: null,
    });
  }
  for (const j of jogos) {
    await db.doc(`jogos/${j.externalId}`).set({
      externalId: j.externalId,
      competitionCode: j.competitionCode,
      matchday: j.rodada,
      homeTeam: j.timeCasa,
      awayTeam: j.timeFora,
      kickoffAt: j.inicioEm,
      status: j.status,
      homeScore: j.golsCasa,
      awayScore: j.golsFora,
      sourceUpdatedAt: null,
    });
  }
  return poolId;
}

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

const palpite = async (poolId: string, matchId: string, uid: string) =>
  (await db.doc(`boloes/${poolId}/palpites/${matchId}_${uid}`).get()).data();

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

describe('PP-2 — editar o palpite', () => {
  it('PP-2-02 e PP-2-05 substituir mantém um registro só, e vale a última versão', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [{ id: 'j1', daquiA: 5 * HORA }]);
    const poolId = await cenario(camp, jogos);
    const jogoId = jogos[0]!.externalId;

    await palpitar(poolId, jogoId, BRUNO, 1, 0);
    await palpitar(poolId, jogoId, BRUNO, 3, 2);

    // O id é determinístico (`{matchId}_{uid}`): editar **sobrescreve**. Dois
    // registros para o mesmo jogo seriam dois palpites valendo ao mesmo tempo.
    const todos = await db
      .collection(`boloes/${poolId}/palpites`)
      .where('matchId', '==', jogoId)
      .get();
    expect(todos.size).toBe(1);
    expect(todos.docs[0]!.data()).toMatchObject({ homeScore: 3, awayScore: 2 });

    // E a troca deixou rastro: é a prova de quem escreveu o quê, para disputa.
    const revisoes = await esperar(async () => {
      const r = await db
        .collection(`boloes/${poolId}/palpites/${jogoId}_${BRUNO}/revisoes`)
        .get();
      return r.size === 1;
    });
    expect(revisoes).toBe(true);
  });
});

describe('PP-6 — jogo adiado', () => {
  it('PP-6-01, PP-6-02 e PP-10-02 adiamento reabre o palpite, mantém o anterior e vem do job', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [{ id: 'j1', daquiA: -HORA, status: 'SCHEDULED' }]);
    const poolId = await cenario(camp, jogos);
    const jogoId = jogos[0]!.externalId;
    await palpitar(poolId, jogoId, BRUNO, 2, 1);

    const fonte = new Fonte(externos(camp, [{ id: 'j1', daquiA: 30 * HORA }]));
    const r = await sincronizar(fonte, camp, db);
    expect(r.horariosAlterados).toBe(1);

    const jogo = (await db.doc(`jogos/${jogoId}`).get()).data();
    expect(jogo!.kickoffAt.toMillis()).toBeGreaterThan(Date.now());

    // O que a pessoa já tinha escrito **continua valendo**: reabrir o prazo não é
    // apagar o palpite, é permitir trocá-lo.
    expect(await palpite(poolId, jogoId, BRUNO)).toMatchObject({
      homeScore: 2,
      awayScore: 1,
    });
  });

  it('PP-6-05 antecipação do horário fecha mais cedo', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [{ id: 'j1', daquiA: 30 * HORA }]);
    const poolId = await cenario(camp, jogos);

    await sincronizar(new Fonte(externos(camp, [{ id: 'j1', daquiA: 2 * HORA }])), camp, db);

    const jogo = (await db.doc(`jogos/${jogos[0]!.externalId}`).get()).data();
    const faltam = (jogo!.kickoffAt.toMillis() - Date.now()) / HORA;
    // Prazo que só sabe adiar deixaria alguém palpitar depois do apito novo.
    expect(faltam).toBeLessThan(3);
  });

  it('PP-6-06 adiamento para o passado não reabre', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [{ id: 'j1', daquiA: 30 * HORA }]);
    const poolId = await cenario(camp, jogos);

    await sincronizar(new Fonte(externos(camp, [{ id: 'j1', daquiA: -2 * HORA }])), camp, db);

    const jogo = (await db.doc(`jogos/${jogos[0]!.externalId}`).get()).data();
    expect(jogo!.kickoffAt.toMillis()).toBeLessThan(Date.now());
  });

  it('PP-6-08 mesmo horário na fonte não conta como adiamento', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [{ id: 'j1', daquiA: 30 * HORA }]);
    await cenario(camp, jogos);

    const iguais = externos(camp, [{ id: 'j1', daquiA: 30 * HORA }]);
    iguais[0]!.inicioEm = jogos[0]!.inicioEm;
    const r = await sincronizar(new Fonte(iguais), camp, db);

    // Escrita igual acordaria os gatilhos de apuração e sigilo à toa — e no
    // serviço em que custo é por operação, "escrever igual" não é inofensivo.
    expect(r.horariosAlterados).toBe(0);
    expect(r.placaresAlterados).toBe(0);
  });

  it('PP-6-09 adiamento e placar novo na mesma sincronização', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [
      { id: 'j1', daquiA: -3 * HORA },
      { id: 'j2', daquiA: -3 * HORA },
    ]);
    const poolId = await cenario(camp, jogos);
    await palpitar(poolId, jogos[1]!.externalId, BRUNO, 2, 1);

    const r = await sincronizar(
      new Fonte(
        externos(camp, [
          { id: 'j1', daquiA: 30 * HORA },
          { id: 'j2', daquiA: -3 * HORA, status: 'FINISHED', casa: 2, fora: 1 },
        ]),
      ),
      camp,
      db,
    );

    expect(r.horariosAlterados).toBe(1);
    expect(r.placaresAlterados).toBe(1);

    // O adiado reabre e o terminado apura — na mesma volta, sem um atrapalhar o
    // outro.
    const apurou = await esperar(
      async () => (await palpite(poolId, jogos[1]!.externalId, BRUNO))?.points === 10,
    );
    expect(apurou).toBe(true);
  });

  it('PP-6-10 adiamento sem nova data mantém o jogo pendente, não some', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [{ id: 'j1', daquiA: -3 * HORA }]);
    const poolId = await cenario(camp, jogos);

    await sincronizar(
      new Fonte(externos(camp, [{ id: 'j1', daquiA: -3 * HORA, status: 'POSTPONED' }])),
      camp,
      db,
    );

    const jogo = (await db.doc(`jogos/${jogos[0]!.externalId}`).get()).data();
    expect(jogo!.status).toBe('POSTPONED');
    // Some do calendário seria pior: o participante veria a rodada encolher sem
    // explicação, e o bolão nunca fecharia sozinho.
    const e = await extrato(BRUNO, poolId, 1, db);
    expect(e.rows.map((l) => l.matchId)).toContain(jogos[0]!.externalId);
  });
});

describe('PP-7 — sigilo, do lado de quem pode ver', () => {
  it('PP-7-07 o próprio palpite é sempre visível ao dono', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [{ id: 'j1', daquiA: 5 * HORA }]);
    const poolId = await cenario(camp, jogos);
    await palpitar(poolId, jogos[0]!.externalId, BRUNO, 4, 2);

    const e = await extrato(BRUNO, poolId, 1, db);
    expect(e.rows[0]!.myPrediction).toMatchObject({ homeScore: 4, awayScore: 2 });
  });

  it('PP-7-08 revelado e vazio é diferente de em segredo', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [{ id: 'j1', daquiA: -3 * HORA }]);
    const poolId = await cenario(camp, jogos);

    // Jogo terminado em que **ninguém palpitou**: a lista é vazia, e isso não é
    // sigilo — é ausência. Confundir os dois faz o app dizer "ainda é segredo"
    // sobre um jogo que acabou ontem.
    await db.doc(`jogos/${jogos[0]!.externalId}`).update({
      status: 'FINISHED',
      homeScore: 1,
      awayScore: 1,
    });

    const e = await extrato(BRUNO, poolId, 1, db);
    expect(e.rows[0]!.estado).toBe('SEM_PALPITE');
    expect(e.rows[0]!.points).toBe(0);
  });
});

describe('PP-10 — sincronização com a fonte', () => {
  it('PP-10-01 o job atualiza horário e placar do que mudou', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [
      { id: 'j1', daquiA: 30 * HORA },
      { id: 'j2', daquiA: -3 * HORA },
    ]);
    await cenario(camp, jogos);

    const r = await sincronizar(
      new Fonte(
        externos(camp, [
          { id: 'j1', daquiA: 40 * HORA },
          { id: 'j2', daquiA: -3 * HORA, status: 'FINISHED', casa: 3, fora: 1 },
        ]),
      ),
      camp,
      db,
    );

    expect(r).toMatchObject({
      jogosVerificados: 2,
      horariosAlterados: 1,
      placaresAlterados: 1,
      falhou: false,
    });
  });

  it('PP-10-03 placar novo em jogo já apurado dispara o recálculo', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [
      { id: 'j1', daquiA: -3 * HORA, status: 'FINISHED', casa: 1, fora: 0 },
    ]);
    const poolId = await cenario(camp, jogos);
    await palpitar(poolId, jogos[0]!.externalId, BRUNO, 1, 0);
    await esperar(
      async () => (await palpite(poolId, jogos[0]!.externalId, BRUNO))?.points === 10,
    );

    await sincronizar(
      new Fonte(
        externos(camp, [
          { id: 'j1', daquiA: -3 * HORA, status: 'FINISHED', casa: 2, fora: 1 },
        ]),
      ),
      camp,
      db,
    );

    // A correção **da fonte** entra pela mesma porta da correção manual, e reapura
    // pelo mesmo gatilho.
    const reapurou = await esperar(
      async () => (await palpite(poolId, jogos[0]!.externalId, BRUNO))?.points === 5,
    );
    expect(reapurou).toBe(true);

    const trilha = await db.collection(`jogos/${jogos[0]!.externalId}/correcoes`).get();
    expect(trilha.docs[0]!.data()).toMatchObject({ source: 'OFFICIAL', previousHome: 1 });
  });

  it('PP-10-04 fonte indisponível não corrompe o que está guardado', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [
      { id: 'j1', daquiA: -3 * HORA, status: 'FINISHED', casa: 2, fora: 1 },
    ]);
    await cenario(camp, jogos);

    const r = await sincronizar(new Fonte([], true), camp, db);
    expect(r.falhou).toBe(true);

    // Apagar horário e placar levaria embora o prazo e a apuração de todos. O que
    // está guardado vale mais que uma resposta vazia.
    const jogo = (await db.doc(`jogos/${jogos[0]!.externalId}`).get()).data();
    expect(jogo).toMatchObject({ homeScore: 2, awayScore: 1, status: 'FINISHED' });
  });

  it('PP-10-05 job parado recupera o atraso sem perder mudanças', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [
      { id: 'j1', daquiA: -5 * HORA },
      { id: 'j2', daquiA: -4 * HORA },
    ]);
    const poolId = await cenario(camp, jogos);
    await palpitar(poolId, jogos[0]!.externalId, BRUNO, 1, 0);
    await palpitar(poolId, jogos[1]!.externalId, BRUNO, 1, 0);

    // Duas rodadas de mudanças acumuladas enquanto o job estava fora do ar: a
    // sincronização não é um "diff desde a última vez", é o estado atual da
    // fonte inteiro — por isso o atraso se recupera sozinho.
    const r = await sincronizar(
      new Fonte(
        externos(camp, [
          { id: 'j1', daquiA: -5 * HORA, status: 'FINISHED', casa: 1, fora: 0 },
          { id: 'j2', daquiA: -4 * HORA, status: 'FINISHED', casa: 2, fora: 2 },
        ]),
      ),
      camp,
      db,
    );
    expect(r.placaresAlterados).toBe(2);

    const apurou = await esperar(
      async () =>
        (await palpite(poolId, jogos[0]!.externalId, BRUNO))?.points === 10 &&
        (await palpite(poolId, jogos[1]!.externalId, BRUNO))?.points === 0,
    );
    expect(apurou).toBe(true);
  });

  it('PP-10-09 jogo novo na fonte que o bolão não tem é ignorado', async () => {
    const camp = proximaCompeticao();
    const jogos = externos(camp, [{ id: 'j1', daquiA: 30 * HORA }]);
    await cenario(camp, jogos);

    const r = await sincronizar(
      new Fonte(
        externos(camp, [
          { id: 'j1', daquiA: 30 * HORA },
          { id: 'novo', daquiA: 35 * HORA },
        ]),
      ),
      camp,
      db,
    );

    // A sincronização atualiza o que existe; **criar** jogo é da criação de bolão.
    // Jogo novo entrando sozinho mudaria o tamanho de um bolão já em andamento.
    expect(r.jogosVerificados).toBe(1);
    expect((await db.doc(`jogos/${camp}-novo`).get()).exists).toBe(false);
  });
});
