Iniciativa Cidadã Independente · Transparência Parlamentar
TS771 linhas26.138 bytes

apps/worker/src/services/premiacao-edicoes.ts

SHA-256

3782e8f0126b2fdd7f76eaf4798a691c9fb3a87bb582b91f2e50e7af8a7a72f3

Somente leiturafonte-0531042d9f3e
import type { Env, IdsScore } from '../types'
import { getSenadorList, LEG57_PERIODO } from './legis'
import type { Periodo } from './legis'
import { computeRanking, getRankingSnapshot, listSnapshots } from './ranking'
import {
  apurarScores,
  REGUA_ANO,
  REGUA_LEGISLATURA,
  NOMES_UF,
} from './premiacao'
import type { CategoriaApurada, EixoId, IndicadorBruto, Premiacao, Regua } from './premiacao'
import { assinarCertificado, sha256Hex } from './assinatura'

/**
 * Edições do Prêmio Observatório do Senado.
 *
 * Duas famílias, ambas com as mesmas 39 categorias:
 *  - `leg57`: a legislatura inteira, sobre um snapshot do ranking semanal;
 *  - `leg57-2025`: um ano legislativo, com o IDS recalculado só naquele ano.
 *
 * Fluxo: rascunho → (apurando) → apurada → aprovada → divulgada.
 * Até aprovar, dá para reapurar quantas vezes for preciso. Aprovar trava o
 * resultado (guardado com hash) e emite um certificado por vencedor. Divulgar
 * torna o resultado público; antes disso, só o admin e quem tem o código de um
 * certificado veem alguma coisa.
 */

export type TipoEdicao = 'legislatura' | 'ano'
export type StatusEdicao = 'rascunho' | 'apurando' | 'apurada' | 'aprovada' | 'divulgada'

/** Legislaturas que a camada de dados sabe medir hoje. A 58 entra na transição. */
const LEGISLATURAS_SUPORTADAS = [57]
const ANOS_LEG57 = [2023, 2024, 2025, 2026]

export interface DefinicaoEdicao {
  id: string
  tipo: TipoEdicao
  legislatura: number
  ano?: number
  /** "57ª Legislatura (2023–2027)", "Ano Legislativo 2025" */
  titulo: string
  /** Linha de apoio do certificado. */
  subtitulo: string
  /** Completa a descrição do Grande Prêmio. */
  recorte: string
  periodo: Periodo
  regua: Regua
  /** Prefixo dos códigos de certificado: "L57", "A25". */
  prefixo: string
}

export function definirEdicao(
  tipo: TipoEdicao,
  legislatura: number,
  ano?: number,
): DefinicaoEdicao {
  if (!LEGISLATURAS_SUPORTADAS.includes(legislatura)) {
    throw new EdicaoInvalida(
      `a ${legislatura}ª Legislatura ainda não é medida pelo IDS; ` +
        'ela entra junto com a transição de legislatura',
    )
  }
  if (tipo === 'legislatura') {
    return {
      id: `leg${legislatura}`,
      tipo,
      legislatura,
      titulo: '57ª Legislatura (2023–2027)',
      subtitulo: 'Edição da Legislatura · 1º de fevereiro de 2023 a 31 de janeiro de 2027',
      recorte: 'da 57ª Legislatura',
      periodo: LEG57_PERIODO,
      regua: REGUA_LEGISLATURA,
      prefixo: `L${legislatura}`,
    }
  }
  if (!ano || !ANOS_LEG57.includes(ano)) {
    throw new EdicaoInvalida(`ano legislativo inválido para a 57ª Legislatura: ${ano}`)
  }
  const ordinal = ano - 2022
  // O ano civil, aparado no início da legislatura: janeiro de 2023 ainda era a 56ª.
  const inicio = `${ano}-01-01` < LEG57_PERIODO.inicio ? LEG57_PERIODO.inicio : `${ano}-01-01`
  return {
    id: `leg${legislatura}-${ano}`,
    tipo,
    legislatura,
    ano,
    titulo: `Ano Legislativo ${ano}`,
    subtitulo: `${ordinal}ª Sessão Legislativa da 57ª Legislatura`,
    recorte: `do ano legislativo de ${ano}`,
    periodo: { inicio, fim: `${ano}-12-31` },
    regua: REGUA_ANO,
    prefixo: `A${String(ano).slice(2)}`,
  }
}

/** Todas as edições que podem ser criadas hoje, para o seletor do admin. */
export function edicoesPossiveis(): DefinicaoEdicao[] {
  return [
    definirEdicao('legislatura', 57),
    ...ANOS_LEG57.map((ano) => definirEdicao('ano', 57, ano)),
  ]
}

export function definicaoPorId(id: string): DefinicaoEdicao {
  const def = edicoesPossiveis().find((d) => d.id === id)
  if (!def) throw new EdicaoInvalida(`edição desconhecida: ${id}`)
  return def
}

export class EdicaoInvalida extends Error {
  constructor(msg: string) {
    super(msg)
    this.name = 'EdicaoInvalida'
  }
}

// ─── Persistência ───────────────────────────────────────────────────────────

interface EdicaoRow {
  id: string
  tipo: TipoEdicao
  legislatura: number
  ano: number | null
  titulo: string
  periodo_inicio: string
  periodo_fim: string
  status: StatusEdicao
  fonte: string | null
  apuracao_json: string | null
  apuracao_sha256: string | null
  scores_json?: string | null
  erro: string | null
  criado_em: string
  apurado_em: string | null
  aprovado_em: string | null
  divulgado_em: string | null
}

export interface Edicao {
  id: string
  tipo: TipoEdicao
  legislatura: number
  ano?: number
  titulo: string
  subtitulo: string
  periodo: Periodo
  /** O período ainda não acabou: o resultado mede um ano (ou legislatura) incompleto. */
  periodoEmCurso: boolean
  status: StatusEdicao
  fonte: string | null
  apuracaoSha256: string | null
  erro: string | null
  criadoEm: string
  apuradoEm: string | null
  aprovadoEm: string | null
  divulgadoEm: string | null
  regua: Regua
}

function hoje(): string {
  return new Date().toISOString().slice(0, 10)
}

function toEdicao(r: EdicaoRow): Edicao {
  const def = definicaoPorId(r.id)
  return {
    id: r.id,
    tipo: r.tipo,
    legislatura: r.legislatura,
    ano: r.ano ?? undefined,
    titulo: r.titulo,
    subtitulo: def.subtitulo,
    periodo: { inicio: r.periodo_inicio, fim: r.periodo_fim },
    periodoEmCurso: hoje() <= r.periodo_fim,
    // Um 'apurando' sem job vivo é resto de reinício do serviço.
    status: r.status === 'apurando' && !emAndamento.has(r.id) ? 'rascunho' : r.status,
    fonte: r.fonte,
    apuracaoSha256: r.apuracao_sha256,
    erro:
      r.status === 'apurando' && !emAndamento.has(r.id)
        ? 'a apuração foi interrompida (serviço reiniciado); apure de novo'
        : r.erro,
    criadoEm: r.criado_em,
    apuradoEm: r.apurado_em,
    aprovadoEm: r.aprovado_em,
    divulgadoEm: r.divulgado_em,
    regua: def.regua,
  }
}

const COLUNAS_EDICAO = `id, tipo, legislatura, ano, titulo, periodo_inicio, periodo_fim,
  status, fonte, apuracao_json, apuracao_sha256, erro, criado_em, apurado_em,
  aprovado_em, divulgado_em`

async function lerEdicao(env: Env, id: string): Promise<EdicaoRow | null> {
  return env.SENADO_DB.prepare(
    `SELECT ${COLUNAS_EDICAO} FROM premiacao_edicoes WHERE id = ?`,
  )
    .bind(id)
    .first<EdicaoRow>()
}

export async function listarEdicoes(env: Env): Promise<Edicao[]> {
  const { results } = await env.SENADO_DB.prepare(
    `SELECT ${COLUNAS_EDICAO} FROM premiacao_edicoes ORDER BY periodo_fim DESC, tipo`,
  ).all<EdicaoRow>()
  return (results ?? []).map(toEdicao)
}

export async function obterEdicao(
  env: Env,
  id: string,
): Promise<{ edicao: Edicao; apuracao: Premiacao | null } | null> {
  const row = await lerEdicao(env, id)
  if (!row) return null
  return {
    edicao: toEdicao(row),
    apuracao: row.apuracao_json ? (JSON.parse(row.apuracao_json) as Premiacao) : null,
  }
}

/** Scores usados na apuração — o ranking completo do período, para auditoria. */
export async function obterScores(env: Env, id: string): Promise<IdsScore[] | null> {
  const row = await env.SENADO_DB.prepare(
    `SELECT scores_json FROM premiacao_edicoes WHERE id = ?`,
  )
    .bind(id)
    .first<{ scores_json: string | null }>()
  return row?.scores_json ? (JSON.parse(row.scores_json) as IdsScore[]) : null
}

export async function criarEdicao(env: Env, id: string): Promise<Edicao> {
  const def = definicaoPorId(id)
  await env.SENADO_DB.prepare(
    `INSERT OR IGNORE INTO premiacao_edicoes
       (id, tipo, legislatura, ano, titulo, periodo_inicio, periodo_fim, status)
     VALUES (?, ?, ?, ?, ?, ?, ?, 'rascunho')`,
  )
    .bind(def.id, def.tipo, def.legislatura, def.ano ?? null, def.titulo,
      def.periodo.inicio, def.periodo.fim)
    .run()
  return toEdicao((await lerEdicao(env, id))!)
}

// ─── Apuração ───────────────────────────────────────────────────────────────

/** Jobs vivos neste processo. A apuração anual leva minutos (cache frio). */
const emAndamento = new Set<string>()

/**
 * Dispara a apuração em segundo plano e volta na hora.
 *
 * O túnel corta requisições longas, e a edição anual recalcula o IDS do zero.
 * O admin acompanha pelo status da edição (`apurando` → `apurada`).
 */
export async function iniciarApuracao(
  env: Env,
  id: string,
  opts: { snapshot?: string } = {},
): Promise<Edicao> {
  const row = await lerEdicao(env, id)
  if (!row) throw new EdicaoInvalida(`edição ${id} não existe`)
  if (row.status === 'aprovada' || row.status === 'divulgada') {
    throw new EdicaoInvalida('edição já aprovada; cancele a aprovação para reapurar')
  }
  if (emAndamento.has(id)) throw new EdicaoInvalida('apuração já em andamento')

  const statusAnterior: StatusEdicao = row.apuracao_json ? 'apurada' : 'rascunho'
  emAndamento.add(id)
  await env.SENADO_DB.prepare(
    `UPDATE premiacao_edicoes SET status = 'apurando', erro = NULL WHERE id = ?`,
  )
    .bind(id)
    .run()

  void (async () => {
    try {
      const { apuracao, scores, fonte } = await apurar(env, definicaoPorId(id), opts)
      const json = JSON.stringify(apuracao)
      await env.SENADO_DB.prepare(
        `UPDATE premiacao_edicoes
            SET status = 'apurada', fonte = ?, apuracao_json = ?, apuracao_sha256 = ?,
                scores_json = ?, erro = NULL, apurado_em = ?
          WHERE id = ?`,
      )
        .bind(fonte, json, await sha256Hex(json), JSON.stringify(scores),
          new Date().toISOString(), id)
        .run()
      console.log(`[premiacao] ${id} apurada (${apuracao.categorias.length} categorias)`)
    } catch (err) {
      console.error(`[premiacao] apuração de ${id} falhou:`, err)
      // Reapuração que falha não apaga a apuração anterior.
      await env.SENADO_DB.prepare(
        `UPDATE premiacao_edicoes SET status = ?, erro = ? WHERE id = ?`,
      )
        .bind(statusAnterior, String((err as Error).message ?? err).slice(0, 500), id)
        .run()
    } finally {
      emAndamento.delete(id)
    }
  })()

  return toEdicao((await lerEdicao(env, id))!)
}

async function apurar(
  env: Env,
  def: DefinicaoEdicao,
  opts: { snapshot?: string },
): Promise<{ apuracao: Premiacao; scores: IdsScore[]; fonte: string }> {
  if (def.tipo === 'legislatura') {
    // Sem snapshot pedido: o mais recente que passe no piso de sanidade.
    let alvo = opts.snapshot
    if (!alvo) {
      const aptos = (await listSnapshots(env)).filter((s) => s.total >= def.regua.minLinhas)
      if (aptos.length === 0) throw new Error('nenhum snapshot do ranking passa no piso de linhas')
      alvo = aptos[0].computedAt
    }
    const snap = await getRankingSnapshot(env, alvo)
    if (!snap) throw new Error(`snapshot ${alvo} não existe`)
    const apuracao = apurarScores(snap.scores, {
      edicao: def.id,
      legislatura: def.legislatura,
      recorte: def.recorte,
      snapshot: snap.computedAt,
      congelado: true,
      regua: def.regua,
    })
    return { apuracao, scores: snap.scores, fonte: `snapshot:${snap.computedAt}` }
  }

  const calculadoEm = new Date().toISOString()
  const scores = await computeRanking(env, {
    ...def.periodo,
    mesesMinReferencia: def.regua.mesesMinReferencia,
  })
  const apuracao = apurarScores(scores, {
    edicao: def.id,
    legislatura: def.legislatura,
    ano: def.ano,
    recorte: def.recorte,
    snapshot: calculadoEm,
    congelado: true,
    regua: def.regua,
  })
  return { apuracao, scores, fonte: `calculo:${calculadoEm}` }
}

// ─── Aprovação e certificados ───────────────────────────────────────────────

/** Conteúdo impresso no certificado. Imutável depois de emitido. */
export interface DadosCertificado {
  codigo: string
  edicao: {
    id: string
    tipo: TipoEdicao
    titulo: string
    subtitulo: string
    periodo: Periodo
    apuracaoSha256: string
    metodologiaVersao?: string
  }
  categoria: {
    id: string
    eixo: EixoId
    titulo: string
    descricao: string
    /** "Brasil", "Região Nordeste", "Bahia", "Tema: CEAP". */
    ambito: string
    concorrentes: number
  }
  senador: {
    codigo: string
    nome: string
    /** Forma oficial do Senado: "Senador", "Senadora". */
    tratamento: string
    partido: string
    uf: string
  }
  idsTotal: number
  valor: number
  indicador: IndicadorBruto
  houveDesempate: boolean
  /** YYYY-MM-DD, data da aprovação. */
  emitidoEm: string
}

export interface Certificado {
  codigo: string
  edicaoId: string
  categoriaId: string
  senadorCod: string
  dados: DadosCertificado
  pdfSha256: string | null
  pdfGerado: boolean
  emitidoEm: string
  email: string | null
  emailOficial: string | null
  enviadoEm: string | null
  envios: number
  envioErro: string | null
  revogadoEm: string | null
  motivoRevogacao: string | null
}

interface CertRow {
  codigo: string
  edicao_id: string
  categoria_id: string
  senador_cod: string
  dados_json: string
  assinatura: string
  pdf_sha256: string | null
  tem_pdf: number
  emitido_em: string
  email: string | null
  email_oficial: string | null
  enviado_em: string | null
  envios: number
  envio_erro: string | null
  revogado_em: string | null
  motivo_revogacao: string | null
}

const COLUNAS_CERT = `codigo, edicao_id, categoria_id, senador_cod, dados_json, assinatura,
  pdf_sha256, (pdf_base64 IS NOT NULL) AS tem_pdf, emitido_em, email, email_oficial,
  enviado_em, envios, envio_erro, revogado_em, motivo_revogacao`

function toCert(r: CertRow): Certificado {
  return {
    codigo: r.codigo,
    edicaoId: r.edicao_id,
    categoriaId: r.categoria_id,
    senadorCod: r.senador_cod,
    dados: JSON.parse(r.dados_json) as DadosCertificado,
    pdfSha256: r.pdf_sha256,
    pdfGerado: Boolean(r.tem_pdf),
    emitidoEm: r.emitido_em,
    email: r.email,
    emailOficial: r.email_oficial,
    enviadoEm: r.enviado_em,
    envios: r.envios ?? 0,
    envioErro: r.envio_erro,
    revogadoEm: r.revogado_em,
    motivoRevogacao: r.motivo_revogacao,
  }
}

/** Crockford base32 sem I, L, O, U: legível em voz alta e no papel. */
const ALFABETO = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'

function blocoAleatorio(n: number): string {
  const bytes = new Uint8Array(n)
  crypto.getRandomValues(bytes)
  return Array.from(bytes, (b) => ALFABETO[b & 31]).join('')
}

/** Ex.: OS-L57-7K3M-Q9XA. 40 bits aleatórios: inviável de adivinhar. */
function novoCodigo(prefixo: string): string {
  return `OS-${prefixo}-${blocoAleatorio(4)}-${blocoAleatorio(4)}`
}

/** Normaliza o que a pessoa digitou: caixa, espaços, O→0, I/L→1. */
export function normalizarCodigo(bruto: string): string {
  const partes = bruto.toUpperCase().replace(/\s+/g, '').split('-')
  if (partes.length !== 4) return partes.join('-')
  // Só os blocos aleatórios: o prefixo (L57, A25) tem letras de verdade.
  for (const i of [2, 3]) {
    partes[i] = partes[i].replace(/O/g, '0').replace(/[IL]/g, '1')
  }
  return partes.join('-')
}

function ambito(cat: CategoriaApurada): string {
  if (cat.eixo === 'grande_premio') return 'Nacional'
  if (cat.eixo === 'regional') return `Região ${cat.titulo.replace('Destaque ', '')}`
  if (cat.eixo === 'estadual') return cat.uf ? NOMES_UF[cat.uf] ?? cat.uf : cat.titulo
  return 'Temático'
}

/**
 * Aprova a apuração: trava o resultado e emite um certificado por vencedor.
 *
 * O e-mail pré-preenchido é o institucional publicado pelo Senado; o admin
 * pode trocá-lo antes de enviar. O tratamento ("Senador"/"Senadora") também
 * vem do cadastro oficial, nunca do nome.
 */
export async function aprovarEdicao(env: Env, id: string): Promise<Edicao> {
  const atual = await obterEdicao(env, id)
  if (!atual) throw new EdicaoInvalida(`edição ${id} não existe`)
  if (atual.edicao.status !== 'apurada' || !atual.apuracao) {
    throw new EdicaoInvalida(`só uma edição apurada pode ser aprovada (status: ${atual.edicao.status})`)
  }
  const def = definicaoPorId(id)
  const apuracao = atual.apuracao
  const sha = atual.edicao.apuracaoSha256!

  // Cadastro oficial: e-mail e forma de tratamento. A lista do período não
  // importa aqui; quem venceu está na coorte da legislatura.
  const cadastro = new Map((await getSenadorList(env)).map((s) => [s.codigo, s]))

  const emitidoEm = hoje()
  const stmts = []
  for (const cat of apuracao.categorias) {
    const v = cat.vencedor
    const oficial = cadastro.get(v.senadorCod)
    const codigo = novoCodigo(def.prefixo)
    const dados: DadosCertificado = {
      codigo,
      edicao: {
        id,
        tipo: def.tipo,
        titulo: def.titulo,
        subtitulo: def.subtitulo,
        periodo: def.periodo,
        apuracaoSha256: sha,
        metodologiaVersao: apuracao.metodologiaVersao,
      },
      categoria: {
        id: cat.id,
        eixo: cat.eixo,
        titulo: cat.titulo,
        descricao: cat.descricao,
        ambito: ambito(cat),
        concorrentes: cat.elegiveis,
      },
      senador: {
        codigo: v.senadorCod,
        nome: v.nome,
        tratamento: oficial?.formaTratamento ?? 'Senador(a)',
        partido: v.partido,
        uf: v.uf,
      },
      idsTotal: v.idsTotal,
      valor: v.valor,
      indicador: v.indicador,
      houveDesempate: cat.houveDesempate,
      emitidoEm,
    }
    const json = JSON.stringify(dados)
    const email = oficial?.email || null
    stmts.push(
      env.SENADO_DB.prepare(
        `INSERT INTO premiacao_certificados
           (codigo, edicao_id, categoria_id, senador_cod, dados_json, assinatura,
            emitido_em, email, email_oficial)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      ).bind(codigo, id, cat.id, v.senadorCod, json,
        await assinarCertificado(env.ADMIN_SECRET, codigo, json), emitidoEm, email, email),
    )
  }
  stmts.push(
    env.SENADO_DB.prepare(
      `UPDATE premiacao_edicoes SET status = 'aprovada', aprovado_em = ? WHERE id = ?`,
    ).bind(new Date().toISOString(), id),
  )
  for (const s of stmts) await s.run()
  return toEdicao((await lerEdicao(env, id))!)
}

/**
 * Desfaz a aprovação, apagando os certificados. Só enquanto nenhum saiu por
 * e-mail: certificado entregue não some, se revoga.
 */
export async function cancelarAprovacao(env: Env, id: string): Promise<Edicao> {
  const row = await lerEdicao(env, id)
  if (!row || row.status !== 'aprovada') {
    throw new EdicaoInvalida('só uma edição aprovada e não divulgada pode ter a aprovação cancelada')
  }
  const enviados = await env.SENADO_DB.prepare(
    `SELECT COUNT(*) AS n FROM premiacao_certificados WHERE edicao_id = ? AND enviado_em IS NOT NULL`,
  )
    .bind(id)
    .first<{ n: number }>()
  if ((enviados?.n ?? 0) > 0) {
    throw new EdicaoInvalida(
      `${enviados!.n} certificado(s) já enviados; revogue-os individualmente em vez de cancelar`,
    )
  }
  await env.SENADO_DB.prepare(`DELETE FROM premiacao_certificados WHERE edicao_id = ?`).bind(id).run()
  await env.SENADO_DB.prepare(
    `UPDATE premiacao_edicoes SET status = 'apurada', aprovado_em = NULL WHERE id = ?`,
  )
    .bind(id)
    .run()
  return toEdicao((await lerEdicao(env, id))!)
}

/** Divulga (ou recolhe) o resultado público de uma edição aprovada. */
export async function definirDivulgacao(env: Env, id: string, divulgar: boolean): Promise<Edicao> {
  const row = await lerEdicao(env, id)
  if (!row) throw new EdicaoInvalida(`edição ${id} não existe`)
  const esperado: StatusEdicao = divulgar ? 'aprovada' : 'divulgada'
  if (row.status !== esperado) {
    throw new EdicaoInvalida(
      divulgar ? 'só uma edição aprovada pode ser divulgada' : 'a edição não está divulgada',
    )
  }
  await env.SENADO_DB.prepare(
    `UPDATE premiacao_edicoes SET status = ?, divulgado_em = ? WHERE id = ?`,
  )
    .bind(divulgar ? 'divulgada' : 'aprovada', divulgar ? new Date().toISOString() : null, id)
    .run()
  return toEdicao((await lerEdicao(env, id))!)
}

export async function listarCertificados(env: Env, edicaoId: string): Promise<Certificado[]> {
  const { results } = await env.SENADO_DB.prepare(
    `SELECT ${COLUNAS_CERT} FROM premiacao_certificados WHERE edicao_id = ? ORDER BY rowid`,
  )
    .bind(edicaoId)
    .all<CertRow>()
  return (results ?? []).map(toCert)
}

export async function obterCertificado(env: Env, codigo: string): Promise<Certificado | null> {
  const row = await env.SENADO_DB.prepare(
    `SELECT ${COLUNAS_CERT} FROM premiacao_certificados WHERE codigo = ?`,
  )
    .bind(codigo)
    .first<CertRow>()
  return row ? toCert(row) : null
}

const EMAIL_VALIDO = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/

/** Define o destinatário de todos os certificados do senador na edição. */
export async function definirEmail(
  env: Env,
  edicaoId: string,
  senadorCod: string,
  email: string,
): Promise<number> {
  const limpo = email.trim()
  if (limpo && (!EMAIL_VALIDO.test(limpo) || limpo.length > 200)) {
    throw new EdicaoInvalida(`e-mail inválido: ${limpo}`)
  }
  const r = await env.SENADO_DB.prepare(
    `UPDATE premiacao_certificados SET email = ? WHERE edicao_id = ? AND senador_cod = ?`,
  )
    .bind(limpo || null, edicaoId, senadorCod)
    .run()
  return r.meta?.changes ?? 0
}

export async function revogarCertificado(env: Env, codigo: string, motivo: string): Promise<void> {
  if (!motivo.trim()) throw new EdicaoInvalida('informe o motivo da revogação')
  const r = await env.SENADO_DB.prepare(
    `UPDATE premiacao_certificados SET revogado_em = ?, motivo_revogacao = ?
      WHERE codigo = ? AND revogado_em IS NULL`,
  )
    .bind(new Date().toISOString(), motivo.trim().slice(0, 300), codigo)
    .run()
  if (!r.meta?.changes) throw new EdicaoInvalida('certificado inexistente ou já revogado')
}

export async function registrarEnvio(
  env: Env,
  codigos: string[],
  resultado: { ok: boolean; erro?: string },
): Promise<void> {
  for (const codigo of codigos) {
    await env.SENADO_DB.prepare(
      resultado.ok
        ? `UPDATE premiacao_certificados
              SET enviado_em = ?, envios = envios + 1, envio_erro = NULL WHERE codigo = ?`
        : `UPDATE premiacao_certificados SET envio_erro = ? WHERE codigo = ?`,
    )
      .bind(resultado.ok ? new Date().toISOString() : (resultado.erro ?? 'falha'), codigo)
      .run()
  }
}

// ─── PDF guardado ───────────────────────────────────────────────────────────

/** PDF já emitido, se houver. O hash publicado na verificação é o deste arquivo. */
export async function lerPdf(env: Env, codigo: string): Promise<Uint8Array | null> {
  const row = await env.SENADO_DB.prepare(
    `SELECT pdf_base64 FROM premiacao_certificados WHERE codigo = ?`,
  )
    .bind(codigo)
    .first<{ pdf_base64: string | null }>()
  return row?.pdf_base64 ? new Uint8Array(Buffer.from(row.pdf_base64, 'base64')) : null
}

export async function guardarPdf(env: Env, codigo: string, pdf: Uint8Array): Promise<string> {
  const sha = await sha256Hex(pdf)
  await env.SENADO_DB.prepare(
    `UPDATE premiacao_certificados SET pdf_base64 = ?, pdf_sha256 = ?
      WHERE codigo = ? AND pdf_base64 IS NULL`,
  )
    .bind(Buffer.from(pdf).toString('base64'), sha, codigo)
    .run()
  return sha
}

// ─── Verificação pública ────────────────────────────────────────────────────

export interface Verificacao {
  codigo: string
  situacao: 'valido' | 'revogado' | 'adulterado'
  motivoRevogacao?: string
  revogadoEm?: string
  emitidoEm: string
  edicao: DadosCertificado['edicao'] & { divulgada: boolean }
  categoria: DadosCertificado['categoria']
  senador: Omit<DadosCertificado['senador'], 'codigo'>
  idsTotal: number
  indicador: IndicadorBruto
  pdfSha256: string | null
}

/**
 * O que qualquer pessoa vê ao conferir um código. Só os dados impressos no
 * próprio certificado: nada de e-mail, pódio ou outros vencedores.
 */
export async function verificarCertificado(env: Env, bruto: string): Promise<Verificacao | null> {
  const codigo = normalizarCodigo(bruto)
  const row = await env.SENADO_DB.prepare(
    `SELECT c.codigo, c.dados_json, c.assinatura, c.pdf_sha256, c.emitido_em,
            c.revogado_em, c.motivo_revogacao, e.status AS status_edicao
       FROM premiacao_certificados c
       JOIN premiacao_edicoes e ON e.id = c.edicao_id
      WHERE c.codigo = ?`,
  )
    .bind(codigo)
    .first<{
      codigo: string
      dados_json: string
      assinatura: string
      pdf_sha256: string | null
      emitido_em: string
      revogado_em: string | null
      motivo_revogacao: string | null
      status_edicao: StatusEdicao
    }>()
  if (!row) return null

  const integro =
    (await assinarCertificado(env.ADMIN_SECRET, row.codigo, row.dados_json)) === row.assinatura
  const d = JSON.parse(row.dados_json) as DadosCertificado
  return {
    codigo: row.codigo,
    situacao: !integro ? 'adulterado' : row.revogado_em ? 'revogado' : 'valido',
    motivoRevogacao: row.motivo_revogacao ?? undefined,
    revogadoEm: row.revogado_em ?? undefined,
    emitidoEm: row.emitido_em,
    edicao: { ...d.edicao, divulgada: row.status_edicao === 'divulgada' },
    categoria: d.categoria,
    senador: {
      nome: d.senador.nome,
      tratamento: d.senador.tratamento,
      partido: d.senador.partido,
      uf: d.senador.uf,
    },
    idsTotal: d.idsTotal,
    indicador: d.indicador,
    pdfSha256: row.pdf_sha256,
  }
}

/** Resultado público: só de edições divulgadas. */
export async function edicaoDivulgada(
  env: Env,
  id: string,
): Promise<{ edicao: Edicao; apuracao: Premiacao } | null> {
  const r = await obterEdicao(env, id)
  if (!r || r.edicao.status !== 'divulgada' || !r.apuracao) return null
  return { edicao: r.edicao, apuracao: r.apuracao }
}