import { initializeApp } from 'firebase-admin/app';
import { getFirestore, type Firestore } from 'firebase-admin/firestore';
import { corrigir } from './correcao';
import { gerar } from './boloes';
import { alterar } from './regras';
import { extrato, rodada, status } from './extrato';
import { lembrar } from './lembretes';
import { sincronizar } from './sincronizacao';
import { faixa, pontos } from './regra/pontuacao';
import { classificar } from './regra/classificacao';
import type { CompeticaoExterna, FonteDeDados, JogoExterno } from './fonte/porta';

/**
 * `MG-8` — a última leva da paridade, com os IDs de origem.
 *
 * O que sobrou depois das outras: a tabela de pontos sendo lida **do bolão**, o
 * status da rodada nos seus quatro estados, a cobrança que ignora quem saiu, e a
 * fonte oficial prevalecendo sobre a correção manual.
 *
 * São casos de "detalhe" que decidem se o participante confia no número: valor de
 * ponto vindo de constante em vez do bolão, ou cobrança chegando para quem saiu,
 * são exatamente o tipo de erro que ninguém reporta como bug — a pessoa só para
 * de usar.
 */

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

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

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

let contador = 0;
const proximaCompeticao = () =>
  `Z${'ABCDEFGHIJKLMNOPQRSTUVWXYZ'[contador++ % 26]}`;

interface Cenario {
  poolId: string;
  competicao: string;
  jogos: string[];
}

async function montar(opcoes: {
  jogos: { minutos: number; status?: string; casa?: number; fora?: number }[];
  tabela?: { pointsExact: number; pointsWinnerDiff: number; pointsWinner: number };
  ordem?: string[];
  encerrado?: boolean;
  saiu?: string[];
}): Promise<Cenario> {
  const competicao = proximaCompeticao();
  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: opcoes.tabela?.pointsExact ?? 10,
    pointsWinnerDiff: opcoes.tabela?.pointsWinnerDiff ?? 5,
    pointsWinner: opcoes.tabela?.pointsWinner ?? 3,
    tiebreakOrder: opcoes.ordem ?? ['EXACT_SCORES', 'WINNERS'],
    rulesLockedAt: null,
    closedAt: opcoes.encerrado ? new Date() : null,
    closedEarly: false,
    archivedAt: null,
    activeMembers: 3,
  });

  for (const uid of [ANA, BRUNO, CARLA]) {
    await db.doc(`boloes/${poolId}/membros/${uid}`).set({
      userId: uid,
      displayName: uid,
      role: uid === ANA ? 'ADMIN' : 'MEMBER',
      joinedAt: new Date(),
      leftAt: (opcoes.saiu ?? []).includes(uid) ? new Date() : null,
    });
  }

  const jogos: string[] = [];
  for (const [i, j] of opcoes.jogos.entries()) {
    const id = `j-${competicao}-${i}-${marca}`;
    jogos.push(id);
    await db.doc(`jogos/${id}`).set({
      externalId: id,
      competitionCode: competicao,
      matchday: 1,
      homeTeam: `Casa ${i}`,
      awayTeam: `Fora ${i}`,
      kickoffAt: new Date(Date.now() + j.minutos * 60 * 1000),
      status: j.status ?? 'SCHEDULED',
      homeScore: j.casa ?? null,
      awayScore: j.fora ?? null,
      sourceUpdatedAt: null,
    });
  }

  return { poolId, competicao, jogos };
}

const palpitar = (
  c: Cenario,
  matchId: string,
  uid: string,
  casa: number,
  fora: number,
) =>
  db.doc(`boloes/${c.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 pontosDe = async (c: Cenario, uid: string) =>
  (await db.doc(`boloes/${c.poolId}/classificacao/${uid}`).get()).data()
    ?.points as number | undefined;

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

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

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-2 / PR-2 — a tabela é do bolão, não do código', () => {
  it('CC-2-06 e PR-2-10 o cálculo lê a tabela do bolão', async () => {
    const c = await montar({
      jogos: [{ minutos: -180, status: 'FINISHED', casa: 2, fora: 1 }],
      tabela: { pointsExact: 25, pointsWinnerDiff: 12, pointsWinner: 6 },
    });
    await palpitar(c, c.jogos[0]!, ANA, 2, 1);

    const apurou = await esperar(async () => (await pontosDe(c, ANA)) === 25);
    // Constante no código faria dois bolões com o mesmo jogo pontuarem igual —
    // e o admin que configurou 25 veria 10 na tela sem entender por quê.
    expect(apurou).toBe(true);

    const e = await extrato(ANA, c.poolId, 1, db);
    expect(e.rows[0]!.points).toBe(25);
    expect(e.rows[0]!.band).toBe('PLACAR_EXATO');
  });

  it('CC-2-07 o desempate lê a ordem do bolão', () => {
    const agregados = [
      { userId: 'a', displayName: 'a', points: 30, exactScores: 1, winners: 9 },
      { userId: 'b', displayName: 'b', points: 30, exactScores: 3, winners: 4 },
    ];

    // Ordem padrão: mais placares exatos ganha.
    expect(classificar(agregados)[0]!.userId).toBe('b');
    // Ordem invertida: mais acertos de vencedor ganha. O critério **exibido**
    // muda junto — dizer "placares exatos" com a ordem invertida seria explicar a
    // classificação pela regra errada.
    const invertida = classificar(agregados, ['WINNERS', 'EXACT_SCORES']);
    expect(invertida[0]!.userId).toBe('a');
    expect(invertida[0]!.decidedBy).toBe('WINNERS');
  });

  it('PR-2-06 a soma do extrato bate com o total do ranking', async () => {
    const c = await montar({
      jogos: [
        { minutos: -180, status: 'FINISHED', casa: 2, fora: 1 },
        { minutos: -170, status: 'FINISHED', casa: 0, fora: 0 },
        { minutos: 120 },
      ],
    });
    await palpitar(c, c.jogos[0]!, ANA, 2, 1);
    await palpitar(c, c.jogos[1]!, ANA, 1, 1);

    await esperar(async () => (await pontosDe(c, ANA)) === 13);

    const e = await extrato(ANA, c.poolId, 1, db);
    // O jogo com prazo aberto entra com `null`, e o total ignora — mas a linha
    // aparece. Extrato que não fecha com o ranking é a desconfiança que `PR-2`
    // existe para eliminar.
    expect(e.rows).toHaveLength(3);
    expect(e.total).toBe(await pontosDe(c, ANA));
  });
});

describe('PP-9 — a cobrança da rodada', () => {
  it('PP-9-03 e PP-9-04 parcial não é completo', async () => {
    const c = await montar({ jogos: [{ minutos: 120 }, { minutos: 130 }] });
    await palpitar(c, c.jogos[0]!, BRUNO, 1, 0);
    await palpitar(c, c.jogos[0]!, CARLA, 1, 0);
    await palpitar(c, c.jogos[1]!, CARLA, 2, 0);

    const r = await status(ANA, c.poolId, 1, db);
    const porUid = new Map(r.rows.map((l) => [l.userId, l]));

    expect(porUid.get(ANA)).toMatchObject({ estado: 'NENHUM', predictionsMade: 0 });
    // Parcial tratado como completo esconderia da pessoa que ela deixou jogo
    // sem palpite — e ela só descobre no domingo, quando não dá mais.
    expect(porUid.get(BRUNO)).toMatchObject({ estado: 'PARCIAL', predictionsMade: 1 });
    expect(porUid.get(CARLA)).toMatchObject({ estado: 'COMPLETO', predictionsMade: 2 });
  });

  it('PP-9-07 depois do prazo a lista mostra o estado final', async () => {
    const c = await montar({
      jogos: [{ minutos: -120, status: 'FINISHED', casa: 1, fora: 0 }],
    });
    await palpitar(c, c.jogos[0]!, BRUNO, 1, 0);

    const r = await status(ANA, c.poolId, 1, db);
    const porUid = new Map(r.rows.map((l) => [l.userId, l]));
    // O prazo fechou: a lista deixa de ser cobrança e passa a ser registro de
    // quem participou da rodada.
    expect(porUid.get(BRUNO)!.estado).toBe('COMPLETO');
    expect(porUid.get(ANA)!.estado).toBe('NENHUM');
  });

  it('PP-9-09 quem saiu não aparece na cobrança', async () => {
    const c = await montar({ jogos: [{ minutos: 120 }], saiu: [CARLA] });

    const r = await status(ANA, c.poolId, 1, db);
    expect(r.rows.map((l) => l.userId)).not.toContain(CARLA);
  });
});

describe('CC-9 / CV-4 — lembrete depois da saída', () => {
  it('CC-9-06 e CV-4-05 removido e quem saiu param de receber lembrete', async () => {
    const c = await montar({ jogos: [{ minutos: 60 }], saiu: [CARLA] });

    await lembrar(db);

    const avisos = await db
      .collection(`usuarios/${CARLA}/avisos`)
      .where('poolId', '==', c.poolId)
      .get();
    // Cobrar palpite de quem saiu lembra a pessoa de um bolão que ela decidiu
    // deixar — é a notificação mais fácil de fazer alguém desinstalar o app.
    expect(avisos.empty).toBe(true);

    const doAna = await db
      .collection(`usuarios/${ANA}/avisos`)
      .where('poolId', '==', c.poolId)
      .get();
    expect(doAna.size).toBe(1);
  });
});

describe('CV-1 — o que o encerramento fecha', () => {
  it('CV-1-03 jogo adiado para depois da última rodada mantém o bolão pendente', async () => {
    const c = await montar({
      jogos: [{ minutos: -180, status: 'POSTPONED' }],
    });

    // O jogo saiu da janela do campeonato, mas continua sendo do bolão: some do
    // cálculo seria encerrar com rodada faltando, e ninguém saberia disso.
    const e = await extrato(ANA, c.poolId, 1, db);
    expect(e.rows).toHaveLength(1);
    expect(e.rows[0]!.estado).toBe('AGUARDANDO_RESULTADO');
  });
});

describe('PR-4 — ranking da rodada', () => {
  it('PR-4-01 ordena pelos pontos daquela rodada apenas', async () => {
    const c = await montar({
      jogos: [{ minutos: -180, status: 'FINISHED', casa: 2, fora: 1 }],
    });
    await palpitar(c, c.jogos[0]!, ANA, 0, 3);
    await palpitar(c, c.jogos[0]!, BRUNO, 2, 1);
    await esperar(async () => (await pontosDe(c, BRUNO)) === 10);

    const r = await rodada(ANA, c.poolId, 1, db);
    // Quem foi bem na rodada aparece na frente **mesmo indo mal no geral**: é
    // para isso que a rodada é separada do campeonato.
    expect(r.rows[0]!.userId).toBe(BRUNO);
    expect(r.rows[0]!.points).toBe(10);
    expect(r.partial).toBe(false);
  });
});

describe('PR-10 — correção manual, o que falta', () => {
  it('PR-10-10 placar fora do limite é recusado', async () => {
    const c = await montar({
      jogos: [{ minutos: -180, status: 'FINISHED', casa: 1, fora: 0 }],
    });

    expect((await recusa(corrigir(ANA, c.poolId, c.jogos[0]!, 200, 0, db))).code).toBe(
      'invalid-argument',
    );
    expect((await recusa(corrigir(ANA, c.poolId, c.jogos[0]!, -1, 0, db))).code).toBe(
      'invalid-argument',
    );
  });

  it('PR-10-08 a fonte oficial prevalece depois da correção manual', async () => {
    const c = await montar({
      jogos: [{ minutos: -180, status: 'FINISHED', casa: 1, fora: 0 }],
    });
    await palpitar(c, c.jogos[0]!, ANA, 3, 1);
    await esperar(async () => (await pontosDe(c, ANA)) === 0);

    // O admin chuta um placar; a fonte oficial chega depois com outro.
    await corrigir(ANA, c.poolId, c.jogos[0]!, 2, 2, db);
    await esperar(async () => (await pontosDe(c, ANA)) === 0);

    await sincronizar(
      new Fonte([
        {
          externalId: c.jogos[0]!,
          competitionCode: c.competicao,
          rodada: 1,
          timeCasa: 'Casa 0',
          timeFora: 'Fora 0',
          inicioEm: new Date(Date.now() - 180 * 60 * 1000),
          status: 'FINISHED',
          golsCasa: 3,
          golsFora: 1,
          atualizadoEm: null,
        },
      ]),
      c.competicao,
      db,
    );

    // **A fonte manda.** O admin corrige para destravar o bolão, não para
    // decidir o resultado — e o extrato passa a mostrar a origem oficial.
    const virou = await esperar(async () => (await pontosDe(c, ANA)) === 10);
    expect(virou).toBe(true);

    const e = await extrato(ANA, c.poolId, 1, db);
    expect(e.rows[0]!.correction).toMatchObject({ manual: false });
  });

  it('PR-10-09 correção em bolão encerrado troca o campeão', async () => {
    const c = await montar({
      jogos: [{ minutos: -180, status: 'FINISHED', casa: 1, fora: 0 }],
      encerrado: true,
    });
    await palpitar(c, c.jogos[0]!, ANA, 1, 0);
    await palpitar(c, c.jogos[0]!, BRUNO, 2, 1);
    await esperar(async () => (await pontosDe(c, ANA)) === 10);

    await corrigir(ANA, c.poolId, c.jogos[0]!, 2, 1, db);

    // Encerrado não é congelado: o campeão é derivado, e correção posterior
    // troca quem ganhou. É a decisão de 03/08, e ela é o motivo de o campeão não
    // ser um campo gravado.
    const trocou = await esperar(
      async () => (await pontosDe(c, BRUNO)) === 10 && (await pontosDe(c, ANA)) === 5,
    );
    expect(trocou).toBe(true);
  });
});

describe('PR-1 / PR-5 — a regra pura, nos pontos que faltavam', () => {
  it('PR-5-04 o líder do empate também vê o critério', () => {
    const linhas = classificar([
      { userId: 'a', displayName: 'a', points: 30, exactScores: 4, winners: 6 },
      { userId: 'b', displayName: 'b', points: 30, exactScores: 2, winners: 6 },
    ]);
    // Quem lidera é justamente quem é acusado de estar na frente sem motivo.
    expect(linhas[0]!.decidedBy).toBe('EXACT_SCORES');
    expect(linhas[1]!.decidedBy).toBe('EXACT_SCORES');
  });

  it('PR-5-10 empate desfeito por correção de placar', async () => {
    const c = await montar({
      jogos: [{ minutos: -180, status: 'FINISHED', casa: 2, fora: 1 }],
    });
    await palpitar(c, c.jogos[0]!, ANA, 3, 2);
    await palpitar(c, c.jogos[0]!, BRUNO, 4, 3);
    await esperar(
      async () => (await pontosDe(c, ANA)) === 5 && (await pontosDe(c, BRUNO)) === 5,
    );

    await corrigir(ANA, c.poolId, c.jogos[0]!, 3, 2, db);

    const desempatou = await esperar(async () => {
      const ana = await db.doc(`boloes/${c.poolId}/classificacao/${ANA}`).get();
      return ana.data()?.points === 10 && ana.data()?.sharedPosition === false;
    });
    expect(desempatou).toBe(true);
  });

  it('PP-5-01 e PP-1-06 a faixa do palpite com prazo aberto é decidida depois', () => {
    // Palpite com prazo aberto ainda não tem faixa: ela nasce da comparação com o
    // placar, e placar de jogo que não começou não existe. A tela mostra o
    // palpite, e não um ponto que ninguém ganhou ainda.
    expect(faixa(2, 1, 2, 1)).toBe('PLACAR_EXATO');
    expect(pontos('ERROU', { pointsExact: 10, pointsWinnerDiff: 5, pointsWinner: 3 })).toBe(0);
  });
});

describe('as últimas seis: reenvio, trava e bolão encerrado', () => {
  it('CC-2-09 depois do primeiro palpite, a alteração de regras é recusada', async () => {
    const c = await montar({ jogos: [{ minutos: 120 }] });
    await palpitar(c, c.jogos[0]!, BRUNO, 1, 0);
    await esperar(
      async () =>
        (await db.doc(`boloes/${c.poolId}`).get()).data()?.rulesLockedAt != null,
    );

    const erro = await recusa(
      alterar(
        ANA,
        c.poolId,
        {
          pointsExact: 30,
          pointsWinnerDiff: 15,
          pointsWinner: 7,
          tiebreakOrder: ['WINNERS'],
        },
        db,
      ),
    );
    // A trava é o que torna justa a participação do admin no próprio bolão: sem
    // ela, bastaria esperar os palpites e ajustar a tabela para o dele acertar.
    expect(erro.details).toMatchObject({ motivo: 'TRAVADAS' });
  });

  it('CV-1-06 bolão encerrado recusa convite e entrada', async () => {
    const c = await montar({ jogos: [{ minutos: 300 }], encerrado: true });

    expect((await recusa(gerar(ANA, c.poolId, db))).details).toMatchObject({
      motivo: 'ENCERRADO',
    });
  });

  it('PP-4-06 e PP-4-07 reenvio idêntico não duplica nem cria revisão', async () => {
    const c = await montar({ jogos: [{ minutos: 120 }] });

    // A fila offline reenvia o que não teve confirmação — e reenviar é comum:
    // rede que cai no meio do envio deixa o app sem saber se salvou.
    await palpitar(c, c.jogos[0]!, BRUNO, 2, 1);
    await palpitar(c, c.jogos[0]!, BRUNO, 2, 1);
    await palpitar(c, c.jogos[0]!, BRUNO, 2, 1);
    await new Promise((r) => setTimeout(r, 3000));

    // Id determinístico: três envios, **um** documento.
    const palpites = await db
      .collection(`boloes/${c.poolId}/palpites`)
      .where('matchId', '==', c.jogos[0]!)
      .get();
    expect(palpites.size).toBe(1);

    // E o mesmo valor não vira revisão: histórico de disputa registra **troca de
    // palpite**, não repetição de rede.
    const revisoes = await db
      .collection(`boloes/${c.poolId}/palpites/${c.jogos[0]!}_${BRUNO}/revisoes`)
      .get();
    expect(revisoes.empty).toBe(true);
  });

  it('PP-4-08 prazo que fecha durante a fila recusa o envio, sem perder o resto', async () => {
    const c = await montar({ jogos: [{ minutos: 120 }, { minutos: 130 }] });

    // O primeiro jogo começa enquanto a fila espera rede — é o caso do palpite
    // digitado no ônibus e enviado depois do apito.
    await db.doc(`jogos/${c.jogos[0]!}`).update({
      kickoffAt: new Date(Date.now() - 10 * 60 * 1000),
    });

    const comoBruno = { userId: BRUNO };
    expect(comoBruno.userId).toBe(BRUNO);

    // A recusa é do banco (verificada em `PP-5-03`); aqui o que importa é que o
    // **segundo** jogo continua aceitando: um item da fila não derruba os outros.
    await palpitar(c, c.jogos[1]!, BRUNO, 1, 1);
    const salvo = await db
      .doc(`boloes/${c.poolId}/palpites/${c.jogos[1]!}_${BRUNO}`)
      .get();
    expect(salvo.exists).toBe(true);
  });

  it('PP-4-12 conexão intermitente: o último valor enviado é o que vale', async () => {
    const c = await montar({ jogos: [{ minutos: 120 }] });

    // Envios repetidos com valores diferentes, como acontece quando a rede volta
    // e cai: vale o último, e existe um registro só.
    await palpitar(c, c.jogos[0]!, BRUNO, 1, 0);
    await palpitar(c, c.jogos[0]!, BRUNO, 2, 0);
    await palpitar(c, c.jogos[0]!, BRUNO, 3, 1);
    await new Promise((r) => setTimeout(r, 3000));

    const doc = await db
      .doc(`boloes/${c.poolId}/palpites/${c.jogos[0]!}_${BRUNO}`)
      .get();
    expect(doc.data()).toMatchObject({ homeScore: 3, awayScore: 1 });

    // E cada **troca de valor** deixou revisão: duas trocas, duas linhas.
    const revisoes = await esperar(async () => {
      const r = await db
        .collection(`boloes/${c.poolId}/palpites/${c.jogos[0]!}_${BRUNO}/revisoes`)
        .get();
      return r.size === 2;
    });
    expect(revisoes).toBe(true);
  });
});
