import { initializeApp } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import { corrigir } from './correcao';
import { extrato, rodada } from './extrato';
import { textoDoAviso } from './avisos';

/**
 * `MG-7` — correção manual, aviso de recálculo e extrato.
 *
 * As três coisas que fazem o participante confiar no ponto quando ele **muda**:
 * a trilha que diz qual era o placar antes, o aviso que chega a quem foi afetado,
 * e o extrato que explica de onde veio cada ponto (`PR-6`, `PR-7`, `PR-2`).
 *
 * A correção não apura: ela escreve o placar e a auditoria, e quem reage é o
 * gatilho de `MG-5`. Por isso os casos esperam pelo efeito, em vez de recebê-lo
 * de volta da chamada.
 *
 * **Campeonato próprio (`COR`)**: o calendário é global, e dois arquivos de teste
 * na mesma rodada de `BSA` disputam os mesmos jogos.
 */
process.env.FIRESTORE_EMULATOR_HOST ??= '127.0.0.1:8080';
jest.setTimeout(60_000);

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

const HORA = 3600 * 1000;
// Bolão novo a cada execução: o emulador pode ser o mesmo entre rodadas (é o que
// acontece quando ele fica de pé para o teste em device), e bolão reaproveitado
// acumula participante de ontem — o caso do ranking passaria a contar gente que
// este arquivo não colocou lá.
const POOL = `p-correcao-${Date.now()}`;

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 membros = [
  { uid: 'ana', nome: 'Ana', role: 'ADMIN' },
  { uid: 'bruno', nome: 'Bruno', role: 'MEMBER' },
];

async function montar() {
  await db.doc(`boloes/${POOL}`).set({
    name: 'Bolão da correção',
    competitionCode: 'COR',
    matchdayFrom: 15,
    matchdayTo: 15,
    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 m of membros) {
    await db.doc(`boloes/${POOL}/membros/${m.uid}`).set({
      userId: m.uid,
      displayName: m.nome,
      role: m.role,
      joinedAt: new Date(),
      leftAt: null,
    });
  }
}

async function jogo(id: string, opts: Partial<Record<string, unknown>> = {}) {
  await db.doc(`jogos/${id}`).set({
    externalId: id,
    competitionCode: 'COR',
    matchday: 15,
    homeTeam: 'Bahia',
    awayTeam: 'Vitória',
    kickoffAt: new Date(Date.now() - 3 * HORA),
    status: 'FINISHED',
    homeScore: 2,
    awayScore: 1,
    sourceUpdatedAt: null,
    ...opts,
  });
}

async function palpite(matchId: string, uid: string, casa: number, fora: number) {
  await db.doc(`boloes/${POOL}/palpites/${matchId}_${uid}`).set({
    userId: uid,
    matchId,
    homeScore: casa,
    awayScore: fora,
    createdAt: new Date(),
    updatedAt: new Date(),
    matchFinished: true,
    points: null,
    band: null,
    scoredAt: null,
  });
}

beforeAll(montar);

describe('MG-7 correção manual', () => {
  it('MG-7-55 admin corrige e o placar novo vale, com trilha do anterior', async () => {
    await jogo('c-1');
    await palpite('c-1', 'ana', 3, 0);

    const r = await corrigir('ana', POOL, 'c-1', 3, 0, db);
    expect(r).toMatchObject({ mudou: true, afetados: 1 });

    const j = (await db.doc('jogos/c-1').get()).data();
    expect(j).toMatchObject({ homeScore: 3, awayScore: 0, status: 'FINISHED' });

    // **Ponto que cai sem explicação é indistinguível de fraude.** A trilha
    // guarda o placar de antes e quem mexeu.
    const trilha = await db.collection('jogos/c-1/correcoes').get();
    expect(trilha.docs[0]!.data()).toMatchObject({
      previousHome: 2,
      previousAway: 1,
      newHome: 3,
      newAway: 0,
      source: 'MANUAL',
      correctedBy: 'ana',
    });
  });

  it('MG-7-56 a correção reapura pelo mesmo gatilho da fonte oficial', async () => {
    await jogo('c-2');
    await palpite('c-2', 'bruno', 1, 0);

    // 1x0 contra 2x1 é "vencedor e saldo" → 5.
    const cincoPrimeiro = await esperar(
      async () =>
        (await db.doc(`boloes/${POOL}/palpites/c-2_bruno`).get()).data()?.points === 5,
    );
    expect(cincoPrimeiro).toBe(true);

    await corrigir('ana', POOL, 'c-2', 1, 0, db);

    // Com 1x0 oficial, o palpite vira placar exato → 10.
    const virouExato = await esperar(
      async () =>
        (await db.doc(`boloes/${POOL}/palpites/c-2_bruno`).get()).data()?.points === 10,
    );
    expect(virouExato).toBe(true);
  });

  it('MG-7-57 placar igual ao que já estava não mexe em nada', async () => {
    await jogo('c-3');
    const r = await corrigir('ana', POOL, 'c-3', 2, 1, db);
    expect(r.mudou).toBe(false);

    const trilha = await db.collection('jogos/c-3/correcoes').get();
    expect(trilha.empty).toBe(true);
  });

  it('MG-7-58 participante comum não corrige placar', async () => {
    await jogo('c-4');
    const erro = await recusa(corrigir('bruno', POOL, 'c-4', 0, 0, db));
    expect(erro.code).toBe('permission-denied');
  });

  it('MG-7-59 jogo que ainda não começou não recebe placar', async () => {
    await jogo('c-5', {
      kickoffAt: new Date(Date.now() + 5 * HORA),
      status: 'SCHEDULED',
      homeScore: null,
      awayScore: null,
    });

    const erro = await recusa(corrigir('ana', POOL, 'c-5', 1, 1, db));
    expect(erro.details).toMatchObject({ motivo: 'NAO_COMECOU' });
  });

  it('MG-7-60 W.O.: jogo passado sem placar é corrigível', async () => {
    // É a saída para jogo que nunca é reagendado — sem ela o bolão nunca encerra.
    await jogo('c-6', { status: 'POSTPONED', homeScore: null, awayScore: null });

    const r = await corrigir('ana', POOL, 'c-6', 3, 0, db);
    expect(r.mudou).toBe(true);
    expect((await db.doc('jogos/c-6').get()).data()!.status).toBe('FINISHED');
  });

  it('MG-7-61 jogo fora das rodadas do bolão não é corrigível por ele', async () => {
    await db.doc('jogos/c-7').set({
      externalId: 'c-7',
      competitionCode: 'COR',
      matchday: 33,
      homeTeam: 'Outro',
      awayTeam: 'Jogo',
      kickoffAt: new Date(Date.now() - HORA),
      status: 'FINISHED',
      homeScore: 0,
      awayScore: 0,
      sourceUpdatedAt: null,
    });

    // O jogo é global: sem esta checagem, o admin de um bolão mexeria no placar
    // que vale para todos os outros.
    const erro = await recusa(corrigir('ana', POOL, 'c-7', 1, 1, db));
    expect(erro.code).toBe('not-found');
  });
});

describe('MG-7 aviso de recálculo', () => {
  it('MG-7-62 quem mudou de ponto é avisado, com o placar de antes e de agora', async () => {
    await jogo('a-1');
    await palpite('a-1', 'ana', 1, 0);
    await esperar(
      async () =>
        (await db.doc(`boloes/${POOL}/palpites/a-1_ana`).get()).data()?.points === 5,
    );

    await corrigir('ana', POOL, 'a-1', 1, 0, db);

    // **A espera cita o efeito deste caso, não a categoria dele.** Esperar por
    // "existe aviso de placar" passava na hora: Ana já tinha um aviso de uma
    // correção anterior, e o caso seguia sem nunca ter esperado pelo desta. É o
    // mesmo defeito de teste de `MG-5-01` e `MG-6-03`, agora numa terceira
    // forma — e ele se parece com produto quebrado.
    const chegou = await esperar(async () => {
      const avisos = await db
        .collection('usuarios/ana/avisos')
        .where('poolId', '==', POOL)
        .get();
      return avisos.docs.some((d) =>
        (d.data().jogos as { placarPara: string }[]).some(
          (j) => j.placarPara === '1x0',
        ),
      );
    });
    expect(chegou).toBe(true);

    const avisos = await db.collection('usuarios/ana/avisos').get();
    const aviso = avisos.docs
      .map((d) => d.data())
      .find((a) =>
        (a.jogos as { placarPara: string }[]).some((j) => j.placarPara === '1x0'),
      )!;
    expect(aviso.kind).toBe('PLACAR_CORRIGIDO_MANUAL');
    expect(aviso.body).toContain('2x1');
    expect(aviso.body).toContain('1x0');
    expect(aviso.body).toContain('corrigido pelo administrador');
  });

  it('MG-7-63 dois jogos corrigidos seguidos viram um aviso só', async () => {
    // Participante novo a cada execução: o caso **conta** avisos, e aviso de uma
    // execução anterior no mesmo emulador o faria contar errado. Teste que só
    // passa em banco recém-criado é teste que vai falhar sozinho um dia.
    const uid = `carla-${Date.now()}`;
    await db.doc(`usuarios/${uid}`).set({
      displayName: 'Carla',
      email: 'carla@teste.com',
      createdAt: new Date(),
    });
    await db.doc(`boloes/${POOL}/membros/${uid}`).set({
      userId: uid,
      displayName: 'Carla',
      role: 'MEMBER',
      joinedAt: new Date(),
      leftAt: null,
    });

    await jogo('a-2');
    await jogo('a-3');
    await palpite('a-2', uid, 1, 0);
    await palpite('a-3', uid, 1, 0);
    await esperar(
      async () =>
        (await db.doc(`boloes/${POOL}/palpites/a-3_${uid}`).get()).data()?.points === 5,
    );

    const avisosDe = async () =>
      (await db.collection(`usuarios/${uid}/avisos`).get()).docs
        .map((d) => d.data())
        .filter((a) => (a.kind as string).startsWith('PLACAR'));

    await corrigir('ana', POOL, 'a-2', 1, 0, db);
    // Espera o **aviso** da primeira correção, não o ponto dela: o ponto é
    // escrito antes da classificação e do aviso, e esperar por ele deixava a
    // segunda correção chegar antes de haver o que fundir — o teste então
    // acusava de defeito uma corrida que ele mesmo criou.
    const primeiro = await esperar(async () => (await avisosDe()).length === 1);
    expect(primeiro).toBe(true);

    await corrigir('ana', POOL, 'a-3', 1, 0, db);

    // **No NestJS a consolidação era um coletor em memória.** Em Functions cada
    // jogo é uma invocação separada, então dois avisos próximos se fundem em um.
    const fundiu = await esperar(async () => {
      const correcoes = await avisosDe();
      return (
        correcoes.length === 1 && (correcoes[0]!.jogos as unknown[]).length === 2
      );
    });
    expect(fundiu).toBe(true);
  });

  it('MG-7-64 quem não mudou de ponto nem de posição não recebe aviso', async () => {
    // Bolão à parte, com um líder folgado: sem isso, qualquer correção mexe na
    // **posição** de todo mundo e todo mundo é legitimamente avisado — o caso
    // ficaria testando o cenário errado.
    const OUTRO = `p-sem-aviso-${Date.now()}`;
    const davi = `davi-${Date.now()}`;
    await db.doc(`boloes/${OUTRO}`).set({
      name: 'Bolão do líder folgado',
      competitionCode: 'COR',
      matchdayFrom: 16,
      matchdayTo: 16,
      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, nome, role] of [
      ['ana', 'Ana', 'ADMIN'],
      [davi, 'Davi', 'MEMBER'],
    ]) {
      await db.doc(`boloes/${OUTRO}/membros/${uid}`).set({
        userId: uid,
        displayName: nome,
        role,
        joinedAt: new Date(),
        leftAt: null,
      });
    }

    for (const id of ['z-1', 'z-2']) {
      await db.doc(`jogos/${id}`).set({
        externalId: id,
        competitionCode: 'COR',
        matchday: 16,
        homeTeam: 'Santos',
        awayTeam: 'Palmeiras',
        kickoffAt: new Date(Date.now() - 3 * HORA),
        status: 'FINISHED',
        homeScore: 2,
        awayScore: 1,
        sourceUpdatedAt: null,
      });
    }

    // Davi lidera com placar exato; Ana errou e vai passar a acertar em parte.
    await db.doc(`boloes/${OUTRO}/palpites/z-1_${davi}`).set({
      userId: davi, matchId: 'z-1', homeScore: 2, awayScore: 1,
      createdAt: new Date(), updatedAt: new Date(), matchFinished: true,
      points: null, band: null, scoredAt: null,
    });
    await db.doc(`boloes/${OUTRO}/palpites/z-2_ana`).set({
      userId: 'ana', matchId: 'z-2', homeScore: 1, awayScore: 0,
      createdAt: new Date(), updatedAt: new Date(), matchFinished: true,
      points: null, band: null, scoredAt: null,
    });

    const posicaoDe = async (uid: string) =>
      (await db.doc(`boloes/${OUTRO}/classificacao/${uid}`).get()).data()?.position;
    await esperar(async () => (await posicaoDe(davi)) === 1);

    const antes = (await db.collection(`usuarios/${davi}/avisos`).get()).size;
    // 1x0 → placar exato para Ana: ela sai de 5 para 10, e continua atrás de Davi.
    await corrigir('ana', OUTRO, 'z-2', 1, 0, db);
    await esperar(
      async () =>
        (await db.doc(`boloes/${OUTRO}/palpites/z-2_ana`).get()).data()?.points === 10,
    );
    await new Promise((r) => setTimeout(r, 3000));

    expect(await posicaoDe(davi)).toBe(1);
    // Aviso de correção que não mexeu com a pessoa é ruído — e ruído é o que faz
    // desligar a notificação inteira.
    expect((await db.collection(`usuarios/${davi}/avisos`).get()).size).toBe(antes);
  });

  it('MG-7-65 o texto do aviso diz a mudança líquida de posição', () => {
    const um = textoDoAviso(
      [{ jogo: 'A x B', placarDe: '1x0', placarPara: '2x0' }],
      false,
      3,
      1,
    );
    expect(um.title).toBe('Sua pontuação mudou');
    expect(um.body).toContain('de 3º para 1º');

    const varios = textoDoAviso(
      [
        { jogo: 'A x B', placarDe: '1x0', placarPara: '2x0' },
        { jogo: 'C x D', placarDe: '0x0', placarPara: '1x1' },
      ],
      true,
      2,
      2,
    );
    expect(varios.title).toBe('Sua pontuação mudou em 2 jogos');
    // Posição que não mudou não vira frase: dizer "de 2º para 2º" é ruído.
    expect(varios.body).not.toContain('posição');
    expect(varios.body).toContain('corrigido pelo administrador');
  });
});

describe('MG-7 extrato e ranking da rodada', () => {
  it('MG-7-66 o extrato explica cada ponto e a soma bate com o total', async () => {
    const r = await extrato('ana', POOL, 15, db);

    const linha = r.rows.find((l) => l.matchId === 'c-1')!;
    expect(linha).toMatchObject({ estado: 'APURADO', points: 10 });
    expect(linha.band).toBe('PLACAR_EXATO');
    expect(linha.correction).toMatchObject({ previousHome: 2, manual: true });

    const soma = r.rows.reduce((s, l) => s + (l.points ?? 0), 0);
    expect(r.total).toBe(soma);
  });

  it('MG-7-67 jogo terminado sem palpite vale zero, e diz que faltou palpite', async () => {
    await jogo('x-1');
    const r = await extrato('bruno', POOL, 15, db);

    const linha = r.rows.find((l) => l.matchId === 'x-1')!;
    // Omitir a linha faria a soma do extrato não bater com o ranking — que é a
    // desconfiança que `PR-2` existe para eliminar.
    expect(linha).toMatchObject({
      estado: 'SEM_PALPITE',
      points: 0,
      myPrediction: null,
    });
  });

  it('MG-7-68 jogo com prazo aberto não aparece como zero', async () => {
    await jogo('x-2', {
      kickoffAt: new Date(Date.now() + 5 * HORA),
      status: 'SCHEDULED',
      homeScore: null,
      awayScore: null,
    });
    const r = await extrato('bruno', POOL, 15, db);

    const linha = r.rows.find((l) => l.matchId === 'x-2')!;
    expect(linha).toMatchObject({ estado: 'PRAZO_ABERTO', points: null });
  });

  it('MG-7-69 quem não participa não lê extrato nem ranking da rodada', async () => {
    expect((await recusa(extrato('intruso', POOL, 15, db))).code).toBe(
      'permission-denied',
    );
    expect((await recusa(rodada('intruso', POOL, 15, db))).code).toBe(
      'permission-denied',
    );
  });

  it('MG-7-70 ranking da rodada diz que é parcial enquanto falta apurar', async () => {
    const r = await rodada('ana', POOL, 15, db);

    // Há jogo com prazo aberto na rodada: parcial precisa ficar dito, senão
    // ranking parcial parecendo final gera comemoração e briga na sequência.
    expect(r.partial).toBe(true);
    expect(r.scoredMatches).toBeLessThan(r.totalMatches);
    expect(r.rows.map((l) => l.userId)).toEqual(
      expect.arrayContaining(['ana', 'bruno']),
    );
    expect(r.rows.length).toBe(3);
    // Ordenado por ponto, com posição já calculada.
    expect(r.rows[0]!.position).toBe(1);
  });
});
