import { createHash, randomBytes } from 'node:crypto';
import { getFirestore, type Firestore } from 'firebase-admin/firestore';
import { HttpsError, onCall } from 'firebase-functions/v2/https';
import {
  bolaoOuErro,
  exigirAdmin,
  exigirBolaoAberto,
  exigirEntradaAberta,
  primeiroInicio,
  uidDe,
} from './acesso';
import { FootballDataAdapter } from './fonte/football-data';
import { FonteIndisponivelError, type FonteDeDados } from './fonte/porta';

/**
 * Criação de bolão, convite e entrada — **portados** de `pools.service.ts` e
 * `convites.service.ts`.
 *
 * Por que callable e não security rule: cada uma destas operações escreve em
 * mais de um lugar de uma vez (bolão + vínculo + índice do usuário) e depende de
 * dado que o cliente não pode ver (o hash do convite, os jogos da fonte
 * oficial). Rule não faz transação nem sai buscando na internet.
 *
 * O que **não** virou callable: palpitar. É o caminho quente do produto, e uma
 * Function no meio somaria cold start ao momento em que todo mundo palpita junto
 * — por isso o palpite é escrita direta com rule (`MG-3`).
 */

/** Decidido em 03/08: o convite vale 1 dia. */
const VALIDADE_DO_CONVITE_H = 24;

/** Padrão do PRD quando o admin não mexe nas regras. */
const REGRAS_PADRAO = {
  pointsExact: 10,
  pointsWinnerDiff: 5,
  pointsWinner: 3,
  tiebreakOrder: ['EXACT_SCORES', 'WINNERS'],
};

export interface DadosDaCriacao {
  name: string;
  competitionCode: string;
  matchdayFrom: number;
  matchdayTo: number;
  rules?: {
    pointsExact?: number;
    pointsWinnerDiff?: number;
    pointsWinner?: number;
    tiebreakOrder?: string[];
  };
}

export interface BolaoCriado {
  id: string;
  name: string;
  competitionCode: string;
  matchdayFrom: number;
  matchdayTo: number;
  totalMatches: number;
  openMatches: number;
  createdAt: string;
}

export const criarBolao = onCall(
  { secrets: ['FOOTBALL_DATA_TOKEN'] },
  async (request) =>
    criar(
      uidDe(request),
      request.data as DadosDaCriacao,
      new FootballDataAdapter(process.env.FOOTBALL_DATA_TOKEN!),
    ),
);

export async function criar(
  uid: string,
  dados: DadosDaCriacao,
  fonte: FonteDeDados,
  db: Firestore = getFirestore(),
): Promise<BolaoCriado> {
  const name = (dados?.name ?? '').trim();
  if (name.length < 3 || name.length > 60) {
    throw new HttpsError('invalid-argument', 'dê um nome ao bolão');
  }
  if (!/^[A-Z]{2,4}$/.test(dados.competitionCode ?? '')) {
    throw new HttpsError('invalid-argument', 'código de campeonato inválido');
  }
  const de = dados.matchdayFrom;
  const ate = dados.matchdayTo;
  if (!Number.isInteger(de) || !Number.isInteger(ate) || de < 1 || ate > 38) {
    throw new HttpsError('invalid-argument', 'rodada fora do campeonato');
  }
  if (ate < de) {
    throw new HttpsError(
      'invalid-argument',
      'a rodada final não pode ser antes da inicial',
    );
  }

  let jogos;
  try {
    jogos = await fonte.listarJogos(dados.competitionCode, de, ate);
  } catch (erro) {
    // Sem jogos não se cria bolão pela metade. **Bolão sem jogo é o pior estado
    // possível:** o admin acha que criou, convida o grupo, e não há o que
    // palpitar.
    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',
    );
  }

  // Quantos ainda dão para palpitar. Bolão inteiro em rodadas já começadas é
  // criado com sucesso e é **inútil** — sem este número o admin só descobre pelo
  // grupo reclamando (`CC-1`).
  const agora = Date.now();
  const openMatches = jogos.filter((j) => j.inicioEm.getTime() > agora).length;

  // O calendário é global: o mesmo jogo serve a todos os bolões do campeonato.
  // Por isso a escrita é **só do que falta** — sobrescrever apagaria o placar
  // que a sincronização já trouxe, e com ele a apuração de outro bolão.
  const refs = jogos.map((j) => db.doc(`jogos/${j.externalId}`));
  const existentes = await db.getAll(...refs);
  const lote = db.batch();
  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,
    });
  });

  const bolao = db.collection('boloes').doc();
  const criadoEm = new Date();
  lote.set(bolao, {
    name,
    competitionCode: dados.competitionCode,
    matchdayFrom: de,
    matchdayTo: ate,
    createdBy: uid,
    createdAt: criadoEm,
    pointsExact: dados.rules?.pointsExact ?? REGRAS_PADRAO.pointsExact,
    pointsWinnerDiff:
      dados.rules?.pointsWinnerDiff ?? REGRAS_PADRAO.pointsWinnerDiff,
    pointsWinner: dados.rules?.pointsWinner ?? REGRAS_PADRAO.pointsWinner,
    tiebreakOrder: dados.rules?.tiebreakOrder ?? REGRAS_PADRAO.tiebreakOrder,
    rulesLockedAt: null,
    closedAt: null,
    closedEarly: false,
    archivedAt: null,
    activeMembers: 1,
  });

  // Quem cria é admin **e** participante, na mesma escrita: bolão que nasce sem
  // o criador dentro é um bolão que ninguém administra.
  lote.set(db.doc(`boloes/${bolao.id}/membros/${uid}`), {
    userId: uid,
    displayName: await nomeDe(db, uid),
    role: 'ADMIN',
    joinedAt: criadoEm,
    leftAt: null,
  });

  await lote.commit();

  return {
    id: bolao.id,
    name,
    competitionCode: dados.competitionCode,
    matchdayFrom: de,
    matchdayTo: ate,
    totalMatches: jogos.length,
    openMatches,
    createdAt: criadoEm.toISOString(),
  };
}

export const listarCompeticoes = onCall(
  { secrets: ['FOOTBALL_DATA_TOKEN'] },
  async (request) => {
    uidDe(request);
    return competicoes(new FootballDataAdapter(process.env.FOOTBALL_DATA_TOKEN!));
  },
);

export async function competicoes(fonte: FonteDeDados) {
  try {
    const lista = await fonte.listarCompeticoes();
    return lista.map((c) => ({
      code: c.code,
      nome: c.nome,
      rodadaAtual: c.rodadaAtual,
    }));
  } catch (erro) {
    if (erro instanceof FonteIndisponivelError) {
      throw new HttpsError(
        'unavailable',
        'não foi possível listar os campeonatos agora',
      );
    }
    throw erro;
  }
}

// ---- convite --------------------------------------------------------------

const hashDoToken = (token: string) =>
  createHash('sha256').update(token).digest('hex');

/**
 * O convite fica em `convites/{hash}`, fora do bolão e **fora do alcance do
 * cliente** (o `match` genérico fechado nega tudo que não foi liberado).
 *
 * O id ser o hash do token faz a entrada custar **uma leitura por id**, em vez
 * de uma consulta — e guarda o token do jeito que se guarda senha: quem lesse o
 * banco não conseguiria entrar em bolão alheio.
 */
export const gerarConvite = onCall(async (request) =>
  gerar(uidDe(request), (request.data?.poolId ?? '') as string),
);

export async function gerar(
  uid: string,
  poolId: string,
  db: Firestore = getFirestore(),
): Promise<{ token: string; expiresAt: string }> {
  const bolao = await bolaoOuErro(db, poolId);
  await exigirAdmin(db, poolId, uid);
  exigirBolaoAberto(bolao);
  exigirEntradaAberta(await primeiroInicio(db, bolao));

  const token = randomBytes(32).toString('base64url');
  const expiresAt = new Date(Date.now() + VALIDADE_DO_CONVITE_H * 3600 * 1000);

  const lote = db.batch();
  // Gerar revoga o anterior — é o que dá sentido a "invalidar e gerar outro": o
  // link antigo para de funcionar na hora, e não em 24 h.
  const anterior = await db
    .collection('convites')
    .where('poolId', '==', poolId)
    .get();
  anterior.docs.forEach((d) => lote.delete(d.ref));

  lote.set(db.doc(`convites/${hashDoToken(token)}`), {
    poolId,
    expiresAt,
    createdBy: uid,
    createdAt: new Date(),
  });
  await lote.commit();

  return { token, expiresAt: expiresAt.toISOString() };
}

export const invalidarConvite = onCall(async (request) =>
  invalidar(uidDe(request), (request.data?.poolId ?? '') as string),
);

export async function invalidar(
  uid: string,
  poolId: string,
  db: Firestore = getFirestore(),
): Promise<void> {
  await bolaoOuErro(db, poolId);
  await exigirAdmin(db, poolId, uid);

  const atuais = await db
    .collection('convites')
    .where('poolId', '==', poolId)
    .get();
  const lote = db.batch();
  atuais.docs.forEach((d) => lote.delete(d.ref));
  await lote.commit();
}

export interface Entrada {
  poolId: string;
  poolName: string;
  jaParticipava: boolean;
}

/**
 * Entrada pelo convite.
 *
 * Cada recusa tem motivo distinguível — no `message`, que é escrito para o
 * convidado ler, e em `details.motivo`, para o app decidir o que oferecer sem
 * depender do texto. "Erro" genérico aqui é o que faz o convidado desistir e
 * voltar para o WhatsApp.
 */
export const entrarPorConvite = onCall(async (request) =>
  entrar(uidDe(request), (request.data?.token ?? '') as string),
);

export async function entrar(
  uid: string,
  token: string,
  db: Firestore = getFirestore(),
): Promise<Entrada> {
  const convite = await db.doc(`convites/${hashDoToken(token)}`).get();
  if (!convite.exists) {
    throw new HttpsError(
      'not-found',
      'convite inválido ou já substituído',
      { motivo: 'INVALIDO' },
    );
  }

  const expira = (
    convite.data()!.expiresAt as FirebaseFirestore.Timestamp
  ).toMillis();
  if (expira < Date.now()) {
    throw new HttpsError(
      'failed-precondition',
      'este convite expirou. Peça um novo ao administrador',
      { motivo: 'EXPIRADO' },
    );
  }

  const poolId = convite.data()!.poolId as string;
  const bolao = await bolaoOuErro(db, poolId);
  exigirBolaoAberto(bolao);
  exigirEntradaAberta(await primeiroInicio(db, bolao));

  const ref = db.doc(`boloes/${poolId}/membros/${uid}`);
  const existente = await ref.get();
  if (existente.exists && existente.data()!.leftAt != null) {
    // Sair é definitivo (`CV-4`). Reentrada não existe.
    throw new HttpsError(
      'permission-denied',
      'você saiu deste bolão e a saída é definitiva — não é possível voltar',
      { motivo: 'SAIU' },
    );
  }

  // Idempotente: abrir o link de novo não duplica participação nem dá erro —
  // link de convite é reenviado no grupo o tempo todo.
  if (!existente.exists) {
    await ref.set({
      userId: uid,
      displayName: await nomeDe(db, uid),
      role: 'MEMBER',
      joinedAt: new Date(),
      leftAt: null,
    });
  }

  return {
    poolId,
    poolName: bolao.name,
    jaParticipava: existente.exists,
  };
}

/**
 * Nome de exibição copiado do perfil para dentro do bolão.
 *
 * A cópia existe porque a lista de participantes precisa do nome e ninguém pode
 * ler `usuarios/{uid}` alheio — ler o perfil de cada um seria leitura extra por
 * participante **e** um jeito de enumerar as contas do app.
 */
async function nomeDe(db: Firestore, uid: string): Promise<string> {
  const perfil = await db.doc(`usuarios/${uid}`).get();
  return (perfil.data()?.displayName as string | undefined) ?? 'participante';
}
