import { getFirestore, type Firestore, type Transaction } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { FootballDataAdapter } from './fonte/football-data';
import { FonteIndisponivelError, type FonteDeDados } from './fonte/porta';

/**
 * Sincronização com a fonte oficial, de hora em hora.
 *
 * Substitui o `@Cron` do NestJS — e resolve a task de infraestrutura que estava
 * aberta desde o começo do projeto (`PP-10 OPS-1`): o Cloud Scheduler **não
 * hiberna**, e free tier que dorme por inatividade fazia `PP-6` e `PR-1` pararem em
 * silêncio, com o prazo fechando na hora errada e nenhum erro aparecendo.
 *
 * A escrita do jogo é a **única** porta: quem apura é o gatilho de `MG-5`, quem
 * libera o sigilo é o de `MG-3`. Esta Function não calcula ponto nem mexe em
 * palpite — se calculasse, existiriam dois caminhos de apuração e um deles
 * envelheceria.
 */
export const sincronizarComAFonte = onSchedule(
  {
    schedule: 'every 60 minutes',
    timeZone: 'America/Sao_Paulo',
    region: 'southamerica-east1',
    secrets: ['FOOTBALL_DATA_TOKEN'],
  },
  async () => {
    await sincronizar(new FootballDataAdapter(process.env.FOOTBALL_DATA_TOKEN!));
  },
);

export interface ResultadoSync {
  jogosVerificados: number;
  horariosAlterados: number;
  placaresAlterados: number;
  falhou: boolean;
  /** Outra execução tinha o lock; esta não fez nada. */
  ignorada: boolean;
}

const VAZIO: ResultadoSync = {
  jogosVerificados: 0,
  horariosAlterados: 0,
  placaresAlterados: 0,
  falhou: false,
  ignorada: false,
};

/** Documento de lock. Um por competição, para uma não bloquear a outra. */
const caminhoDoLock = (competitionCode: string) =>
  `sincronizacoes/${competitionCode}`;

/** Tempo depois do qual um lock é considerado abandonado. */
const VALIDADE_DO_LOCK_MS = 10 * 60 * 1000;

/**
 * Sincroniza, **uma execução por vez**.
 *
 * No NestJS a trava era um `Map` em memória, e ela resolvia o defeito real de
 * 07/08: duas sincronizações simultâneas 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.
 *
 * **Em Functions aquela trava deixa de existir:** cada invocação é um processo
 * novo, e duas invocações podem estar em máquinas diferentes. O lugar da trava
 * passa a ser um documento, tomado em transação — que é o único jeito de duas
 * máquinas concordarem sobre quem chegou primeiro.
 *
 * O lock tem validade: Function que morre no meio (timeout, erro fatal) deixaria o
 * lock preso e a sincronização pararia **em silêncio**, que é exatamente o modo de
 * falha que esta story existe para eliminar.
 */
export async function sincronizar(
  fonte: FonteDeDados,
  competitionCode = 'BSA',
  // A instância entra por parâmetro em vez de `getFirestore()` no corpo: assim a
  // função é exercitável sem depender do app **global** do Admin SDK, que só
  // existe quando o `index.ts` inicializa. Foi o teste que expôs isso.
  db: Firestore = getFirestore(),
): Promise<ResultadoSync> {
  const lock = db.doc(caminhoDoLock(competitionCode));

  const peguei = await db.runTransaction(async (tx: Transaction) => {
    const atual = await tx.get(lock);
    const desde = atual.data()?.desde?.toMillis?.() ?? 0;
    const emAndamento =
      atual.exists &&
      atual.data()?.emAndamento === true &&
      Date.now() - desde < VALIDADE_DO_LOCK_MS;

    if (emAndamento) return false;
    tx.set(lock, { emAndamento: true, desde: new Date() });
    return true;
  });

  if (!peguei) {
    logger.warn(
      `sincronização de ${competitionCode} já em andamento — pedido ignorado`,
    );
    // Não devolve o resultado da outra: quem chamou não fez esse trabalho, e somar
    // os números duas vezes mentiria no relatório do job.
    return { ...VAZIO, ignorada: true };
  }

  try {
    return await executar(fonte, competitionCode, db);
  } finally {
    await lock.set({ emAndamento: false, desde: new Date() });
  }
}

async function executar(
  fonte: FonteDeDados,
  competitionCode: string,
  db: Firestore,
): Promise<ResultadoSync> {
  const resultado: ResultadoSync = { ...VAZIO };

  const guardados = await db
    .collection('jogos')
    .where('competitionCode', '==', competitionCode)
    .get();
  if (guardados.empty) return resultado;

  const rodadas = guardados.docs.map((d) => d.data().matchday as number);
  const de = Math.min(...rodadas);
  const ate = Math.max(...rodadas);

  let externos;
  try {
    externos = await fonte.listarJogos(competitionCode, de, ate);
  } catch (erro) {
    // **Fonte fora do ar não sobrescreve dado bom.** É a regra de `PP-10 BE-5`: o
    // que está guardado vale mais que uma resposta vazia, porque apagar horário e
    // placar levaria embora o prazo e a apuração de todos.
    if (erro instanceof FonteIndisponivelError) {
      logger.error(`sincronização abortada: ${erro.message}`);
      return { ...resultado, falhou: true };
    }
    throw erro;
  }

  const porExternalId = new Map(externos.map((j) => [j.externalId, j]));
  const lote = db.bulkWriter();

  for (const doc of guardados.docs) {
    const atual = doc.data();
    const externo = porExternalId.get(atual.externalId);
    // Jogo que a fonte deixou de conhecer **permanece**: apagar levaria embora os
    // palpites de todos por causa de uma falha da fonte (`PP-10-08`).
    if (!externo) continue;

    resultado.jogosVerificados++;

    const horarioMudou =
      atual.kickoffAt?.toMillis?.() !== externo.inicioEm.getTime();
    const placarMudou =
      atual.homeScore !== externo.golsCasa || atual.awayScore !== externo.golsFora;
    const statusMudou = atual.status !== externo.status;

    if (!horarioMudou && !placarMudou && !statusMudou) continue;

    if (horarioMudou) resultado.horariosAlterados++;
    if (placarMudou) resultado.placaresAlterados++;

    // Uma escrita só, com tudo que mudou. Os gatilhos de apuração (`MG-5`) e de
    // sigilo (`MG-3`) reagem a ela — e ambos ignoram escrita que não muda
    // resultado, então horário mudando não reapura o bolão à toa.
    lote.update(doc.ref, {
      kickoffAt: externo.inicioEm,
      status: externo.status,
      homeScore: externo.golsCasa,
      awayScore: externo.golsFora,
      sourceUpdatedAt: externo.atualizadoEm,
      // Marca a origem desta alteração. Sem isso, um placar corrigido à mão
      // deixaria o jogo marcado como `MANUAL` para sempre, e a correção oficial
      // seguinte sairia no aviso como "corrigido pelo administrador".
      lastCorrectionSource: 'OFFICIAL',
    });

    // Trilha de auditoria da correção **oficial**, igual à da manual: placar que
    // muda depois de já ter existido é o que o extrato precisa explicar (`PR-6`).
    if (placarMudou && atual.homeScore != null) {
      lote.set(db.collection(`jogos/${doc.id}/correcoes`).doc(), {
        matchId: doc.id,
        previousHome: atual.homeScore,
        previousAway: atual.awayScore,
        newHome: externo.golsCasa,
        newAway: externo.golsFora,
        source: 'OFFICIAL',
        correctedBy: null,
        createdAt: new Date(),
      });
    }
  }

  await lote.close();

  logger.info(
    `sync ${competitionCode}: ${resultado.jogosVerificados} verificado(s), ` +
      `${resultado.horariosAlterados} horário(s), ${resultado.placaresAlterados} placar(es)`,
  );
  return resultado;
}
