import { getFirestore, type Firestore } from 'firebase-admin/firestore';
import { logger } from 'firebase-functions';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import { bolaoOuErro, exigirAdmin, exigirMembroAtivo, uidDe } from './acesso';
import { campeoes, type LinhaDaClassificacao } from './regra/classificacao';
import { FootballDataAdapter } from './fonte/football-data';
import { FonteIndisponivelError, type FonteDeDados } from './fonte/porta';

/**
 * Encerramento, arquivamento e recriação — **portados** de
 * `ciclo-vida.service.ts`.
 *
 * O que **não** foi portado é a leitura: campeão e classificação final saem da
 * `classificacao` materializada, que todo participante já lê. Continuam
 * derivados na leitura e nunca congelados — correção de placar depois do
 * encerramento muda o campeão, e é assim que tem de ser (decisão de 03/08).
 */

export interface Encerramento {
  poolId: string;
  closedAt: string;
  closedEarly: boolean;
  champions: { userId: string; displayName: string; points: number }[];
  /** Classificação no encerramento. **Não é congelada:** ela sai da mesma
   * `classificacao` materializada, então correção de placar posterior muda o
   * ranking final e o campeão junto (decisão de 03/08). */
  finalRanking: { position: number; displayName: string; points: number }[];
}

async function classificacaoDe(
  db: Firestore,
  poolId: string,
): Promise<LinhaDaClassificacao[]> {
  const docs = await db
    .collection(`boloes/${poolId}/classificacao`)
    .orderBy('position')
    .get();
  return docs.docs.map((d) => d.data() as LinhaDaClassificacao);
}

/**
 * Encerra quando a última rodada foi apurada.
 *
 * Só encerra sem jogo pendente: anunciar campeão e trocar depois é pior que
 * anunciar um dia mais tarde.
 */
export const encerrarBolao = onCall(async (request) =>
  encerrarSePronto(uidDe(request), (request.data?.poolId ?? '') as string),
);

export async function encerrarSePronto(
  uid: string,
  poolId: string,
  db: Firestore = getFirestore(),
): Promise<Encerramento> {
  const bolao = await bolaoOuErro(db, poolId);
  await exigirMembroAtivo(db, poolId, uid);

  if (bolao.closedAt != null) {
    return montarEncerramento(db, poolId, bolao.closedAt.toDate(), false);
  }

  const jogos = await db
    .collection('jogos')
    .where('competitionCode', '==', bolao.competitionCode)
    .where('matchday', '>=', bolao.matchdayFrom)
    .where('matchday', '<=', bolao.matchdayTo)
    .get();

  const pendentes = jogos.docs.filter((d) => {
    const j = d.data();
    return j.status !== 'FINISHED' || j.homeScore == null;
  }).length;

  if (pendentes > 0) {
    throw new HttpsError(
      'failed-precondition',
      `ainda há ${pendentes} jogo(s) sem resultado apurado — o bolão não encerra`,
      { motivo: 'JOGO_PENDENTE', pendentes },
    );
  }

  const closedAt = new Date();
  await db.doc(`boloes/${poolId}`).update({ closedAt, closedEarly: false });
  logger.info(`bolão ${poolId} encerrado`);

  return montarEncerramento(db, poolId, closedAt, false);
}

/**
 * Encerramento antecipado pelo admin (`CV-3`). É a **única** saída para bolão
 * travado: jogo adiado sem data nunca encerra sozinho.
 */
export const encerrarBolaoAntecipadamente = onCall(async (request) =>
  encerrarAntes(uidDe(request), (request.data?.poolId ?? '') as string),
);

export async function encerrarAntes(
  uid: string,
  poolId: string,
  db: Firestore = getFirestore(),
): Promise<Encerramento> {
  const bolao = await bolaoOuErro(db, poolId);
  await exigirAdmin(db, poolId, uid);
  if (bolao.closedAt != null) {
    throw new HttpsError(
      'failed-precondition',
      'este bolão já está encerrado',
      { motivo: 'JA_ENCERRADO' },
    );
  }

  const closedAt = new Date();
  await db.doc(`boloes/${poolId}`).update({ closedAt, closedEarly: true });

  // **O NestJS também empurrava o `kickoffAt` dos jogos ainda abertos para a
  // hora do encerramento**, e isso não foi portado de propósito: o jogo é
  // global, compartilhado por todos os bolões do campeonato. Mexer no horário
  // dele fecharia o prazo dos **outros** bolões junto. Não é preciso: a rule do
  // palpite já recusa bolão com `closedAt` preenchido.
  logger.warn(`bolão ${poolId} encerrado ANTECIPADAMENTE por ${uid}`);

  return montarEncerramento(db, poolId, closedAt, true);
}

async function montarEncerramento(
  db: Firestore,
  poolId: string,
  closedAt: Date,
  closedEarly: boolean,
): Promise<Encerramento> {
  const linhas = await classificacaoDe(db, poolId);
  return {
    poolId,
    closedAt: closedAt.toISOString(),
    closedEarly,
    // `campeoes()` exige `points > 0`: bolão em que ninguém acertou nada não
    // declara todo mundo co-campeão (`CV-1-09`).
    champions: campeoes(linhas).map((l) => ({
      userId: l.userId,
      displayName: l.displayName,
      points: l.points,
    })),
    finalRanking: linhas.map((l) => ({
      position: l.position,
      displayName: l.displayName,
      points: l.points,
    })),
  };
}

export const arquivarBolao = onCall(async (request) =>
  arquivar(
    uidDe(request),
    (request.data?.poolId ?? '') as string,
    request.data?.arquivar !== false,
  ),
);

export async function arquivar(
  uid: string,
  poolId: string,
  arquivar: boolean,
  db: Firestore = getFirestore(),
): Promise<{ archived: boolean }> {
  const bolao = await bolaoOuErro(db, poolId);
  await exigirAdmin(db, poolId, uid);

  if (arquivar && bolao.closedAt == null) {
    throw new HttpsError(
      'failed-precondition',
      'encerre o bolão antes de arquivar — bolão em andamento não é arquivável',
      { motivo: 'NAO_ENCERRADO' },
    );
  }

  await db
    .doc(`boloes/${poolId}`)
    .update({ archivedAt: arquivar ? new Date() : null });
  return { archived: arquivar };
}

export interface TemporadaNova {
  id: string;
  name: string;
  inheritedMembers: number;
  totalMatches: number;
  recreatedFrom: string;
}

/**
 * Recria o bolão para a temporada seguinte (`CV-7`).
 *
 * Herda regras e participantes, **zera a pontuação** e volta a destravar as
 * regras. Herdar palpite ou ponto corromperia a competição nova — é o critério
 * mais importante desta story.
 */
export const recriarTemporada = onCall(
  { secrets: ['FOOTBALL_DATA_TOKEN'] },
  async (request) =>
    recriar(
      uidDe(request),
      (request.data?.poolId ?? '') as string,
      request.data?.matchdayFrom as number,
      request.data?.matchdayTo as number,
      request.data?.name as string | undefined,
      new FootballDataAdapter(process.env.FOOTBALL_DATA_TOKEN!),
    ),
);

export async function recriar(
  uid: string,
  poolId: string,
  matchdayFrom: number,
  matchdayTo: number,
  nome: string | undefined,
  fonte: FonteDeDados,
  db: Firestore = getFirestore(),
): Promise<TemporadaNova> {
  const anterior = await db.doc(`boloes/${poolId}`).get();
  if (!anterior.exists) {
    throw new HttpsError('not-found', 'bolão não encontrado');
  }
  await exigirAdmin(db, poolId, uid);
  const a = anterior.data()!;

  if (a.closedAt == null) {
    throw new HttpsError(
      'failed-precondition',
      'encerre a temporada atual antes de recriar',
      { motivo: 'NAO_ENCERRADO' },
    );
  }
  if (matchdayTo < matchdayFrom) {
    throw new HttpsError(
      'invalid-argument',
      'a rodada final não pode ser antes da inicial',
    );
  }

  // **Divergência consciente com o NestJS:** lá a recriação só ligava os jogos
  // que já estivessem no banco, e temporada nova quase nunca está — o bolão
  // nascia sem jogo nenhum, que é o pior estado possível (`CC-1`). Recriar é
  // criar; passa pela fonte igual.
  let jogos;
  try {
    jogos = await fonte.listarJogos(a.competitionCode, matchdayFrom, matchdayTo);
  } catch (erro) {
    if (erro instanceof FonteIndisponivelError) {
      throw new HttpsError(
        'unavailable',
        'não foi possível carregar os jogos do campeonato agora. Tente de novo em alguns minutos',
      );
    }
    throw erro;
  }
  if (jogos.length === 0) {
    throw new HttpsError(
      'invalid-argument',
      'não há jogos nessas rodadas para este campeonato',
    );
  }

  const membros = await db
    .collection(`boloes/${poolId}/membros`)
    .where('leftAt', '==', null)
    .get();

  const novo = db.collection('boloes').doc();
  const agora = new Date();
  const lote = db.batch();

  const refs = jogos.map((j) => db.doc(`jogos/${j.externalId}`));
  const existentes = await db.getAll(...refs);
  jogos.forEach((j, i) => {
    if (existentes[i]!.exists) return;
    lote.set(refs[i]!, {
      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: j.atualizadoEm,
    });
  });

  lote.set(novo, {
    name: nome?.trim() || `${a.name} — nova temporada`,
    competitionCode: a.competitionCode,
    matchdayFrom,
    matchdayTo,
    createdBy: uid,
    createdAt: agora,
    // Herda as regras...
    pointsExact: a.pointsExact,
    pointsWinnerDiff: a.pointsWinnerDiff,
    pointsWinner: a.pointsWinner,
    tiebreakOrder: a.tiebreakOrder,
    // ...e nasce destravado, sem encerramento e **sem palpite nenhum copiado**.
    rulesLockedAt: null,
    closedAt: null,
    closedEarly: false,
    archivedAt: null,
    activeMembers: membros.size,
    recreatedFrom: poolId,
  });

  for (const m of membros.docs) {
    const dados = m.data();
    lote.set(db.doc(`boloes/${novo.id}/membros/${m.id}`), {
      userId: dados.userId,
      displayName: dados.displayName,
      role: m.id === uid ? 'ADMIN' : dados.role,
      joinedAt: agora,
      leftAt: null,
    });

    if (m.id === uid) continue;
    // Avisa quem foi reconvidado. Entrar num bolão sem saber tem o mesmo efeito
    // de descobrir tarde que perdeu ponto: a pessoa só percebe quando o prazo da
    // rodada de abertura já passou, e culpa o app (`CV-7 FE-3`).
    lote.set(db.collection(`usuarios/${m.id}/avisos`).doc(), {
      kind: 'BOLAO_RECRIADO',
      poolId: novo.id,
      title: 'Você está num bolão de temporada nova',
      body:
        `"${nome?.trim() || `${a.name} — nova temporada`}" começou, herdando a ` +
        `galera de "${a.name}". A pontuação de todos começa em zero. Confira o ` +
        'primeiro prazo para não perder a rodada de abertura.',
      createdAt: agora,
      readAt: null,
    });
  }

  await lote.commit();

  return {
    id: novo.id,
    name: (await novo.get()).data()!.name as string,
    inheritedMembers: membros.size,
    totalMatches: jogos.length,
    recreatedFrom: poolId,
  };
}

/**
 * Histórico de bolões encerrados (`CV-5`).
 *
 * Sai do índice do usuário — que é o que a tela inicial já lê — mais uma leitura
 * da classificação de cada bolão encerrado, para a posição final. Quem saiu
 * aparece **sem posição**, e isso precisa ficar explícito: bolão abandonado com
 * um "12º lugar" ao lado seria mentira.
 */
export const historicoDeBoloes = onCall(async (request) =>
  historico(uidDe(request)),
);

export async function historico(
  uid: string,
  db: Firestore = getFirestore(),
): Promise<unknown[]> {
  const meus = await db
    .collection(`usuarios/${uid}/boloes`)
    .where('closed', '==', true)
    .get();

  const saida = [];
  for (const doc of meus.docs) {
    const indice = doc.data();
    const bolao = await db.doc(`boloes/${doc.id}`).get();
    if (!bolao.exists) continue;
    const b = bolao.data()!;

    const linhas = await classificacaoDe(db, doc.id);
    const minha = linhas.find((l) => l.userId === uid);
    const vencedores = campeoes(linhas);

    let outcome = 'PARTICIPOU';
    if (vencedores.some((c) => c.userId === uid)) {
      outcome = vencedores.length > 1 ? 'CO_CAMPEAO' : 'CAMPEAO';
    }

    saida.push({
      poolId: doc.id,
      name: b.name,
      closedAt: b.closedAt.toDate().toISOString(),
      closedEarly: b.closedEarly === true,
      archived: b.archivedAt != null,
      outcome,
      position: minha?.position ?? null,
      points: minha?.points ?? null,
      isAdmin: indice.isAdmin === true,
    });
  }
  return saida;
}
