Iniciativa Cidadã Independente · Transparência Parlamentar
TS906 linhas32.903 bytes

apps/worker/src/services/legis.ts

SHA-256

a185799ebb936956261768b9b3130a5c70b0bf73f4ef0a76acc5044c8663b92b

Somente leiturafonte-acf635076da2
import type {
  Env,
  Senador,
  RawDimensions,
  SenadorStatus,
  Confianca,
  SenadorAfastado,
} from '../types'

const CACHE_TTL_SECONDS = 6 * 60 * 60 // 6h

// Legislatura 57 — 01/02/2023 a 31/01/2027
const LEG57_INICIO = '2023-02-01'
const LEG57_FIM = '2027-01-31'
const LEG57_NUMERO = '57'
// Anos a iterar para CEAP/discursos (somar todos)
const LEG57_ANOS = [2023, 2024, 2025, 2026]

/**
 * Mínimo de meses em exercício na Leg 57 para concorrer ao prêmio da
 * legislatura. Abaixo disso o parlamentar continua no ranking (com score
 * calculado contra a distribuição dos elegíveis), mas fora de competição.
 *
 * 12 meses = um ano legislativo completo. Com o valor atual são 82 elegíveis
 * de 104 que exerceram o mandato; deixa de fora suplentes de passagem
 * relâmpago (3–4 meses) e os recém-empossados de 2026.
 */
export const MESES_MIN_PREMIO = 12

// Pesos por tipo de proposição (IDS v2 — Produtividade Legislativa)
// Fonte da escala: complexidade típica do trâmite + impacto normativo
const PESO_PROPOSICAO: Record<string, number> = {
  PEC: 3.0,    // Proposta de Emenda à Constituição
  PLP: 2.0,    // Projeto de Lei Complementar
  PL: 1.5,     // Projeto de Lei Ordinário
  PLN: 1.5,    // Projeto de Lei do Congresso Nacional
  PLS: 1.5,    // (legado) Projeto de Lei do Senado
  PDL: 1.2,    // Projeto de Decreto Legislativo
  PDS: 1.2,    // Projeto de Decreto do Senado
  PRS: 1.0,    // Projeto de Resolução do Senado
  MPV: 1.0,    // Apreciação de Medida Provisória
  RQS: 0.4,    // Requerimento
  RQN: 0.4,    // Requerimento (numerado)
  REQ: 0.4,    // Requerimento (genérico)
  INC: 0.2,    // Indicação
  IND: 0.2,    // Indicação (legado)
  // fallback: 0.5
}

// Pesos por status final (IDS v2 — Efetividade)
const PESO_STATUS = {
  LEI: 3.0,                // Sancionada / promulgada
  APROVADO_SF: 1.5,        // Aprovado no Senado (mesmo se ainda em outra casa)
  TRAMITACAO_AVANCADA: 0.5, // Em comissão de mérito ou plenário
}

async function cachedFetch(
  env: Env,
  cacheKey: string,
  url: string,
): Promise<unknown> {
  const cached = await env.SENADO_CACHE.get(cacheKey, 'json')
  if (cached !== null) return cached

  const RETRY_DELAYS_MS = [0, 500, 1500]
  let lastErr: unknown = null
  for (const delay of RETRY_DELAYS_MS) {
    if (delay > 0) await new Promise((r) => setTimeout(r, delay))
    try {
      const res = await fetch(url, {
        headers: { Accept: 'application/json' },
        signal: AbortSignal.timeout(30_000),
      })
      if (res.status >= 400 && res.status < 500 && res.status !== 429) {
        throw new Error(`LEGIS fetch failed: ${url} → ${res.status}`)
      }
      if (!res.ok) {
        lastErr = new Error(`LEGIS fetch transient: ${url} → ${res.status}`)
        continue
      }
      const data = await res.json()
      await env.SENADO_CACHE.put(cacheKey, JSON.stringify(data), {
        expirationTtl: CACHE_TTL_SECONDS,
      })
      return data
    } catch (err) {
      lastErr = err
    }
  }
  throw lastErr ?? new Error(`LEGIS fetch failed: ${url}`)
}

function exerciciosArray(mand: Record<string, unknown>): Record<string, unknown>[] {
  const exercicios = mand?.Exercicios as Record<string, unknown> | undefined
  if (!exercicios) return []
  const lst = exercicios?.Exercicio
  return (Array.isArray(lst) ? lst : lst ? [lst] : []) as Record<string, unknown>[]
}

/**
 * Normaliza as duas formas em que a API entrega mandatos e devolve só os que
 * tocam a Leg 57:
 *   - `lista/atual.json` → `Mandato` (objeto único, o mandato corrente)
 *   - `lista/legislatura/57.json` → `Mandatos.Mandato` (array com o histórico
 *     inteiro do parlamentar, inclusive mandatos de legislaturas antigas)
 *
 * Filtrar por número de legislatura aqui é o que impede a regressão do commit
 * 11e9e8e: Carlos Fávaro carrega um mandato 56→57 cujo exercício terminou em
 * 2020, e sem esse corte o mandato velho contamina o cálculo do atual.
 */
function mandatosLeg57(
  parlamentar: Record<string, unknown>,
): Record<string, unknown>[] {
  const single = parlamentar?.Mandato as Record<string, unknown> | undefined
  const multi = (parlamentar?.Mandatos as Record<string, unknown>)?.Mandato
  const brutos = (
    Array.isArray(multi) ? multi : multi ? [multi] : single ? [single] : []
  ) as Record<string, unknown>[]

  return brutos.filter((m) => {
    // Mandato de 8 anos aparece como Primeira+Segunda legislatura; basta uma bater
    const legs = [
      'PrimeiraLegislaturaDoMandato',
      'SegundaLegislaturaDoMandato',
    ].map((k) => String((m?.[k] as Record<string, unknown>)?.NumeroLegislatura ?? ''))
    if (legs.some((n) => n === LEG57_NUMERO)) return true
    // Sem número de legislatura: só o `Mandato` corrente do atual.json cai aqui
    return legs.every((n) => n === '')
  })
}

export interface ResumoExercicio {
  /** Meses efetivamente em exercício dentro da janela da Leg 57 (mín. 1). */
  meses: number
  dataInicio: string
  /** Última DataFim de exercício — só preenchida para quem já saiu. */
  dataFim?: string
  emExercicio: boolean
  /** Houve ao menos um exercício real dentro da janela da Leg 57. */
  temExercicio: boolean
  participacao: string
}

/**
 * Soma os meses efetivamente em exercício durante a Leg 57 e diz se o mandato
 * segue aberto. Trata uniforme titulares afastados/retornados (Renan Filho:
 * licenciado virou ministro e voltou), suplentes alternantes (Jussara Lima:
 * 4 exercícios desde 2023-02-06) e mandatos encerrados (Jorge Seif).
 *
 * A janela fecha em LEG57_FIM: depois de 31/01/2027 ninguém acumula mais
 * meses, de modo que o ranking do prêmio congela sozinho no fim da legislatura.
 */
function resumoExercicioLeg57(
  parlamentar: Record<string, unknown>,
): ResumoExercicio {
  const hoje = todayIso()
  const limite = hoje < LEG57_FIM ? hoje : LEG57_FIM
  const mandatos = mandatosLeg57(parlamentar)

  let meses = 0
  let temExercicio = false
  let emExercicio = false
  let menorInicio = ''
  let maiorFim = ''
  let participacao = ''

  for (const mand of mandatos) {
    const desc = String(mand?.DescricaoParticipacao ?? '')
    const arr = exerciciosArray(mand)

    if (arr.length === 0) {
      // Titular sem exercícios listados → assume Leg57 inteira (dado incompleto
      // da API, comportamento herdado do IDS v3)
      if (desc === 'Titular') {
        meses += monthsBetween(LEG57_INICIO, limite)
        temExercicio = true
        emExercicio = true
        participacao = participacao || desc
      }
      continue
    }

    for (const e of arr) {
      const iniBruto = String(e?.DataInicio ?? '')
      if (!iniBruto) continue
      const fimBruto = String(e?.DataFim ?? '')
      // Intervalo inteiramente fora da janela da Leg 57 (mandato anterior ou
      // exercício futuro): ignora sem marcar temExercicio
      if ((fimBruto && fimBruto < LEG57_INICIO) || iniBruto > limite) continue

      const ini = iniBruto < LEG57_INICIO ? LEG57_INICIO : iniBruto
      const fim = !fimBruto || fimBruto > limite ? limite : fimBruto
      if (fim < ini) continue

      temExercicio = true
      meses += monthsBetween(ini, fim)
      participacao = desc || participacao
      if (!menorInicio || ini < menorInicio) menorInicio = ini

      if (!fimBruto || fimBruto > limite) {
        emExercicio = true
      } else if (fimBruto > maiorFim) {
        maiorFim = fimBruto
      }
    }
  }

  // Titular sempre datado do início da legislatura (parity com o IDS v3)
  const dataInicio =
    participacao === 'Titular' ? LEG57_INICIO : menorInicio || LEG57_INICIO

  return {
    meses: Math.max(meses, 1),
    dataInicio,
    dataFim: emExercicio ? undefined : maiorFim || undefined,
    emExercicio,
    temExercicio,
    participacao,
  }
}

function monthsBetween(startIso: string, endIso: string): number {
  const start = new Date(startIso + 'T00:00:00Z')
  const end = new Date(endIso + 'T00:00:00Z')
  const diff =
    (end.getUTCFullYear() - start.getUTCFullYear()) * 12 +
    (end.getUTCMonth() - start.getUTCMonth())
  return Math.max(diff, 0)
}

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

/** Compatibilidade: usado em /admin/warmup. */
export function mesesAtivos(dataInicio: string): number {
  return monthsBetween(dataInicio, todayIso()) || 1
}

/**
 * Retorna o conjunto de códigos de senadores afastados (ex.: licenciados como
 * ministros). São excluídos do ranking principal — não exercem o mandato hoje.
 *
 * Cruza com lista/atual.json para evitar excluir senadores que aparecem em
 * /lista/afastados por mandato anterior mas estão em exercício atualmente
 * (ex.: cod 6295 — Carlos Fávaro, término da 2ª legislatura mas reeleito).
 */
async function getAfastadosCods(env: Env): Promise<Set<string>> {
  const [lista, emExData] = await Promise.all([
    getAfastadosFull(env),
    cachedFetch(
      env,
      'legis:senadores:lista:atual',
      `${env.LEGIS_BASE_URL}/senador/lista/atual.json`,
    ) as Promise<Record<string, unknown>>,
  ])

  const parlamentaresAtivos =
    (
      (emExData?.ListaParlamentarEmExercicio as Record<string, unknown>)
        ?.Parlamentares as Record<string, unknown>
    )?.Parlamentar ?? []
  const arr = Array.isArray(parlamentaresAtivos)
    ? parlamentaresAtivos
    : [parlamentaresAtivos]
  const emExercicioCods = new Set(
    (arr as Record<string, unknown>[])
      .map((p) =>
        String(
          (p?.IdentificacaoParlamentar as Record<string, unknown>)
            ?.CodigoParlamentar ?? '',
        ),
      )
      .filter(Boolean),
  )

  // lista/atual.json é autoritativo: quem está em exercício nunca deve ser
  // excluído mesmo que apareça em /lista/afastados por mandato anterior
  return new Set(
    lista.map((s) => s.codigo).filter((cod) => !emExercicioCods.has(cod)),
  )
}

/**
 * Lista detalhada dos senadores afastados (com motivo, datas) para a aba
 * "Afastados" do ranking. Não há score IDS aqui — só estado atual.
 */
export async function getAfastadosFull(env: Env): Promise<SenadorAfastado[]> {
  try {
    const data = (await cachedFetch(
      env,
      'legis:senadores:afastados:atual',
      `${env.LEGIS_BASE_URL}/senador/lista/afastados`,
    )) as Record<string, unknown>
    const lista =
      (
        (data?.AfastamentoAtual as Record<string, unknown>)
          ?.Parlamentares as Record<string, unknown>
      )?.Parlamentar ?? []
    const arr = Array.isArray(lista) ? lista : [lista]
    return (arr as Record<string, unknown>[])
      .map((p) => {
        const id = p?.IdentificacaoParlamentar as Record<string, unknown>
        const mand = (p?.Mandato as Record<string, unknown>) ?? {}
        const participacao = String(mand?.DescricaoParticipacao ?? '')
        // Lista oficial mistura titulares licenciados (ministros) com suplentes
        // sem exercício corrente. Para suplentes, motivo é o do último exercício
        // encerrado. Para titulares licenciados, vem de Mandato.Afastamentos.
        const af = (mand?.Afastamentos as Record<string, unknown>) ?? {}
        const afItens = af?.Afastamento
        const afArr = (Array.isArray(afItens) ? afItens : afItens ? [afItens] : []) as Record<string, unknown>[]
        const afAtual = afArr.find((a) => !a?.DataFimAfastamento)

        const ex = (mand?.Exercicios as Record<string, unknown>) ?? {}
        const exItens = ex?.Exercicio
        const exArr = (Array.isArray(exItens) ? exItens : exItens ? [exItens] : []) as Record<string, unknown>[]
        const exUltimo = exArr.length > 0 ? exArr[exArr.length - 1] : undefined

        let motivo = ''
        let dataInicio = ''
        let dataFim: string | undefined
        if (afAtual) {
          motivo = String(afAtual.DescricaoCausaAfastamento ?? 'Licenciado')
          dataInicio = String(afAtual.DataInicioAfastamento ?? '')
        } else if (participacao !== 'Titular') {
          motivo =
            exUltimo
              ? String(exUltimo.DescricaoCausaAfastamento ?? 'Não está em exercício')
              : 'Aguardando convocação'
          dataInicio = String(exUltimo?.DataFim ?? '')
          dataFim = undefined
        } else if (exUltimo?.DataFim) {
          motivo = String(exUltimo.DescricaoCausaAfastamento ?? 'Fora de exercício')
          dataInicio = String(exUltimo.DataFim ?? '')
        } else {
          motivo = 'Afastado'
        }

        return {
          codigo: String(id?.CodigoParlamentar ?? ''),
          nome: String(id?.NomeParlamentar ?? ''),
          partido: String(id?.SiglaPartidoParlamentar ?? ''),
          uf: String(mand?.UfParlamentar ?? id?.UfParlamentar ?? ''),
          fotoUrl: String(id?.UrlFotoParlamentar ?? ''),
          motivo,
          dataInicio,
          dataFim,
        }
      })
      .filter((s) => s.codigo !== '')
  } catch {
    return []
  }
}

/**
 * Determina o status taxonômico do parlamentar para fins de ranking/filtro.
 * Não considera afastamento (esse caso é tratado fora do ranking principal).
 */
export function computeStatus(
  participacao: string,
  mesesAtivosLeg57: number,
): SenadorStatus {
  if (mesesAtivosLeg57 < 6) return 'recente'
  const isTitular = participacao === 'Titular'
  if (isTitular) {
    return mesesAtivosLeg57 >= 30 ? 'titular_pleno' : 'titular_voltou'
  }
  return mesesAtivosLeg57 >= 12 ? 'suplente_efetivo' : 'suplente_recente'
}

/**
 * Confiança estatística do score: alta ≥24m, média 12-23m, baixa <12m.
 * Reflete o quanto o histórico permite avaliar com segurança.
 */
export function computeConfianca(mesesAtivosLeg57: number): Confianca {
  if (mesesAtivosLeg57 >= 24) return 'alta'
  if (mesesAtivosLeg57 >= 12) return 'media'
  return 'baixa'
}

/**
 * Coorte da Leg 57 que já deixou o exercício: suplente que devolveu a cadeira
 * ao titular, titular licenciado, renúncia, morte. Continuam no ranking porque
 * o prêmio é da legislatura inteira — quem trabalhou 3 anos e saiu em abril
 * não pode simplesmente desaparecer da avaliação.
 *
 * `exercicio=S` reduz os 245 registros da legislatura aos 123 que chegaram a
 * tomar posse em algum mandato; o filtro por exercício real dentro da janela
 * corta o resto (Major Olimpio e Arolde de Oliveira, por exemplo, morreram na
 * Leg 56 mas carregam mandato que formalmente alcança a 57).
 *
 * Aferido em 2026-08-08: 104 com exercício real, dos quais 23 já encerrados.
 */
async function getEncerradosLeg57(
  env: Env,
  emExercicioCods: Set<string>,
): Promise<Senador[]> {
  let data: Record<string, unknown>
  try {
    data = (await cachedFetch(
      env,
      'legis:senadores:lista:leg57',
      `${env.LEGIS_BASE_URL}/senador/lista/legislatura/${LEG57_NUMERO}.json?exercicio=S`,
    )) as Record<string, unknown>
  } catch (err) {
    // Degrada para o ranking só com quem está em exercício em vez de derrubar
    // o cálculo inteiro — melhor um ranking incompleto que nenhum.
    console.error('[legis] lista da legislatura indisponível:', err)
    return []
  }

  const lista =
    (
      (data?.ListaParlamentarLegislatura as Record<string, unknown>)
        ?.Parlamentares as Record<string, unknown>
    )?.Parlamentar ?? []
  const arr = (Array.isArray(lista) ? lista : [lista]) as Record<string, unknown>[]

  const encerrados: Senador[] = []
  for (const p of arr) {
    const id = (p?.IdentificacaoParlamentar as Record<string, unknown>) ?? {}
    const codigo = String(id?.CodigoParlamentar ?? '')
    if (!codigo || emExercicioCods.has(codigo)) continue

    const resumo = resumoExercicioLeg57(p)
    if (!resumo.temExercicio || resumo.emExercicio) continue

    // A lista da legislatura não traz Bloco e nem sempre traz UF na
    // identificação — o mandato é a fonte confiável da UF.
    const uf =
      String(id?.UfParlamentar ?? '') ||
      String(mandatosLeg57(p)[0]?.UfParlamentar ?? '')
    const partido = String(id?.SiglaPartidoParlamentar ?? '')

    encerrados.push({
      codigo,
      nome: String(id?.NomeParlamentar ?? id?.NomeCompletoParlamentar ?? ''),
      partido,
      uf,
      bloco: partido,
      fotoUrl: String(id?.UrlFotoParlamentar ?? ''),
      email: String(id?.EmailParlamentar ?? ''),
      dataInicioExercicio: resumo.dataInicio,
      mesesAtivos: resumo.meses,
      // Mantém a taxonomia existente (titular/suplente) e sinaliza o mandato
      // encerrado só por `emExercicio`: campo novo, que clientes antigos
      // ignoram. Emitir um `status` inédito aqui quebrou o site publicado.
      status: computeStatus(resumo.participacao, resumo.meses),
      confianca: computeConfianca(resumo.meses),
      emExercicio: false,
      dataFimExercicio: resumo.dataFim,
    })
  }
  return encerrados
}

/**
 * Universo do ranking: a coorte da Legislatura 57, não o retrato de hoje.
 * Quem está em exercício vem de `lista/atual.json` (única fonte com bloco e
 * e-mail); quem já saiu entra por `getEncerradosLeg57`.
 */
export async function getSenadorList(env: Env): Promise<Senador[]> {
  const [data, afastadosCods] = await Promise.all([
    cachedFetch(
      env,
      'legis:senadores:lista:atual',
      `${env.LEGIS_BASE_URL}/senador/lista/atual.json`,
    ) as Promise<Record<string, unknown>>,
    getAfastadosCods(env),
  ])

  const lista =
    (
      (data?.ListaParlamentarEmExercicio as Record<string, unknown>)
        ?.Parlamentares as Record<string, unknown>
    )?.Parlamentar ?? []

  const ativos = (lista as Record<string, unknown>[])
    .map((p) => {
      const id = p?.IdentificacaoParlamentar as Record<string, unknown>
      const resumo = resumoExercicioLeg57(p)
      return {
        codigo: String(id?.CodigoParlamentar ?? ''),
        nome: String(id?.NomeParlamentar ?? id?.NomeCompletoParlamentar ?? ''),
        partido: String(id?.SiglaPartidoParlamentar ?? ''),
        uf: String(id?.UfParlamentar ?? ''),
        bloco: String(
          (id?.Bloco as Record<string, unknown>)?.NomeBloco ??
            id?.SiglaPartidoParlamentar ??
            '',
        ),
        fotoUrl: String(id?.UrlFotoParlamentar ?? ''),
        email: String(id?.EmailParlamentar ?? ''),
        dataInicioExercicio: resumo.dataInicio,
        mesesAtivos: resumo.meses,
        status: computeStatus(resumo.participacao, resumo.meses),
        confianca: computeConfianca(resumo.meses),
        emExercicio: true,
      }
    })
    .filter((s) => s.codigo !== '' && !afastadosCods.has(s.codigo))

  // Exclui TODOS que aparecem no atual.json, inclusive os cortados como
  // afastados: quem já foi avaliado por lá não pode voltar como "encerrado".
  const naListaAtual = new Set(
    (lista as Record<string, unknown>[])
      .map((p) =>
        String(
          (p?.IdentificacaoParlamentar as Record<string, unknown>)
            ?.CodigoParlamentar ?? '',
        ),
      )
      .filter(Boolean),
  )
  const encerrados = await getEncerradosLeg57(env, naListaAtual)
  console.log(
    `[legis] coorte Leg57: ${ativos.length} em exercício + ${encerrados.length} encerrados`,
  )

  return [...ativos, ...encerrados]
}

/**
 * Classifica o status de uma autoria a partir da resposta do endpoint /processo.
 * Retorna o peso da proposição (LEI/APROVADO_SF/TRAMITACAO/0) para Efetividade.
 *
 * O sinal mais confiável é `siglaTipoDeliberacao` (APROVADA_NO_PLENARIO,
 * APROVADA_EM_COMISSAO_TERMINATIVA, PREJUDICADO, RETIRADO_PELO_AUTOR, …).
 * Fallbacks usam descricaoSituacao/objetivo/tramitando para casos antigos.
 */
function classificarStatus(p: Record<string, unknown>): number {
  const delib = String(p?.siglaTipoDeliberacao ?? '').toUpperCase()
  const tram = String(p?.tramitando ?? '').toLowerCase()
  const obj = String(p?.objetivo ?? '').toLowerCase()
  const sit = String(
    (p?.identificacaoMateria as Record<string, unknown>)?.descricaoSituacao ??
      p?.descricaoSituacao ??
      p?.situacao ??
      '',
  ).toLowerCase()

  // Virou lei / promulgada / norma jurídica
  if (
    delib.includes('TRANSFORMAD') ||
    delib.includes('SANCIONAD') ||
    delib.includes('PROMULGAD') ||
    sit.includes('transformad') ||
    sit.includes('sancionad') ||
    sit.includes('promulgad') ||
    sit.includes('norma jur')
  ) {
    return PESO_STATUS.LEI
  }

  // Aprovado no Senado (plenário ou comissão terminativa)
  if (
    delib.startsWith('APROVAD') ||
    delib === 'DEFERIDO_CDIR' ||
    delib === 'APROVADO_CDIR' ||
    sit.includes('aprovad') ||
    sit.includes('remetid') ||
    obj.includes('aprovad')
  ) {
    return PESO_STATUS.APROVADO_SF
  }

  // Tramitando: vale pouco (ainda pode evoluir)
  if (tram === 'sim' || tram === 'true') {
    return PESO_STATUS.TRAMITACAO_AVANCADA
  }

  // Arquivado, prejudicado, retirado, vetado: 0
  return 0
}

/**
 * Extrai a sigla do tipo de proposição (PEC, PL, RQS, etc.) do item /processo.
 * Na listagem `/processo?codigoParlamentarAutor=...` o campo `identificacao`
 * vem no formato "PL 1678/2023" — primeira palavra é a sigla.
 */
function extrairSigla(p: Record<string, unknown>): string {
  // 1) Detalhe: campo `sigla` direto
  const direta = String(p?.sigla ?? '').toUpperCase()
  if (direta) return direta

  // 2) Listagem: parse de `identificacao` ("PL 1678/2023")
  const ident = String(p?.identificacao ?? '')
  const match = ident.match(/^([A-Z]+)\s/)
  if (match) return match[1]

  // 3) Legado
  const legacy = p?.identificacaoMateria as Record<string, unknown> | undefined
  return String(
    legacy?.siglaSubtipoMateria ??
      legacy?.siglaTipoMateria ??
      p?.siglaSubtipoMateria ??
      p?.siglaTipoMateria ??
      '',
  ).toUpperCase()
}

export async function getRawDimensions(
  env: Env,
  codigo: string,
): Promise<RawDimensions> {
  const base = env.LEGIS_BASE_URL

  // Discursos: iterar por ano (API quebra com janelas > 1 ano)
  const discursosPromises = LEG57_ANOS.map((ano) =>
    cachedFetch(
      env,
      `legis:${codigo}:discursos:${ano}`,
      `${base}/senador/${codigo}/discursos.json?dataIni=${ano}0101&dataFim=${ano}1231`,
    ).catch(() => null),
  )
  // Apartes: também por ano
  const apartesPromises = LEG57_ANOS.map((ano) =>
    cachedFetch(
      env,
      `legis:${codigo}:apartes:${ano}`,
      `${base}/senador/${codigo}/apartes.json?dataIni=${ano}0101&dataFim=${ano}1231`,
    ).catch(() => null),
  )

  const [
    relatoriasResult,
    votacoesResult,
    votacoesComissaoResult,
    liderancasResult,
    cargosResult,
    ...discursosEApartes
  ] = await Promise.allSettled([
    cachedFetch(
      env,
      `legis:${codigo}:relatorias:leg57`,
      `${base}/senador/${codigo}/relatorias.json`,
    ),
    cachedFetch(
      env,
      `legis:${codigo}:votacoes:leg57`,
      `${base}/senador/${codigo}/votacoes.json`,
    ),
    cachedFetch(
      env,
      `legis:${codigo}:votacaoComissao`,
      `${base}/votacaoComissao/parlamentar/${codigo}.json`,
    ).catch(() => null),
    cachedFetch(
      env,
      `legis:${codigo}:liderancas`,
      `${base}/senador/${codigo}/liderancas.json`,
    ).catch(() => null),
    cachedFetch(
      env,
      `legis:${codigo}:cargos`,
      `${base}/senador/${codigo}/cargos.json`,
    ).catch(() => null),
    ...discursosPromises,
    ...apartesPromises,
  ])

  const discursosPorAno = discursosEApartes.slice(0, LEG57_ANOS.length)
  const apartesPorAno = discursosEApartes.slice(LEG57_ANOS.length)

  // ----- Autorias com peso por tipo + status detalhado -----
  let autoriasTotal = 0
  let autoriasAprovadas = 0
  let produtividadePonderada = 0 // soma de pesos × proposições
  let efetividadePonderada = 0 // soma de status × peso_tipo
  let efetividadeBase = 0 // soma de peso_tipo (denominador)

  // /processo é load-bearing: falha aqui propaga e warmup classifica como
  // rejected, evitando cache poisoning com autoriasTotal=0.
  const processos = await cachedFetch(
    env,
    `legis:${codigo}:processos:leg57`,
    `${base}/processo?codigoParlamentarAutor=${codigo}&tramitouLegislaturaAtual=S`,
  )
  if (Array.isArray(processos)) {
    autoriasTotal = processos.length
    for (const p of processos as Record<string, unknown>[]) {
      const sigla = extrairSigla(p)
      const peso = PESO_PROPOSICAO[sigla] ?? 0.5
      produtividadePonderada += peso

      const statusPeso = classificarStatus(p)
      if (statusPeso > 0) {
        efetividadePonderada += statusPeso * peso
        if (statusPeso >= PESO_STATUS.APROVADO_SF) autoriasAprovadas += 1
      }
      efetividadeBase += peso
    }
  }

  // ----- Relatorias (Leg 57) -----
  let relatoriasTotal = 0
  if (relatoriasResult.status === 'fulfilled' && relatoriasResult.value) {
    const val = relatoriasResult.value as Record<string, unknown>
    const mat = val?.MateriasRelatoriaParlamentar as Record<string, unknown>
    const parl = mat?.Parlamentar as Record<string, unknown>
    const itens = extractList(parl ?? mat ?? val, ['Relatorias', 'Relatoria'])
    relatoriasTotal = itens.filter((r) => {
      const dt = String((r as Record<string, unknown>)?.DataDesignacao ?? '')
      return dt >= LEG57_INICIO
    }).length
  }

  // ----- Votações (Leg 57) -----
  let votacoesPresentes = 0
  let votacoesTotal = 0
  if (votacoesResult.status === 'fulfilled' && votacoesResult.value) {
    const val = votacoesResult.value as Record<string, unknown>
    const parl = (val?.VotacaoParlamentar as Record<string, unknown>)
      ?.Parlamentar as Record<string, unknown>
    const itens = extractList(parl ?? val, ['Votacoes', 'Votacao'])
    const leg57 = itens.filter((v) => {
      const dt = String(
        ((v as Record<string, unknown>)?.SessaoPlenaria as Record<string, unknown>)
          ?.DataSessao ?? '',
      )
      return dt >= LEG57_INICIO
    })
    votacoesTotal = leg57.length
    votacoesPresentes = leg57.filter((v) => {
      const voto = String((v as Record<string, unknown>)?.SiglaDescricaoVoto ?? '').toLowerCase()
      return voto === 'votou' || voto === 'sim' || voto === 'não' || voto === 'abstencao'
    }).length
  }

  // ----- Votações em Comissão (Leg 57) -----
  let votacoesComissaoPresentes = 0
  let votacoesComissaoTotal = 0
  if (votacoesComissaoResult.status === 'fulfilled' && votacoesComissaoResult.value) {
    const val = votacoesComissaoResult.value as Record<string, unknown>
    const vc = val?.VotacoesComissao as Record<string, unknown>
    const itens = extractList(vc ?? val, ['Votacoes', 'Votacao'])
    for (const v of itens as Record<string, unknown>[]) {
      const dt = String(v?.DataHoraInicioReuniao ?? '').slice(0, 10)
      if (dt < LEG57_INICIO) continue
      votacoesComissaoTotal += 1
      // Procurar voto do parlamentar dentro da lista
      const votos = v?.Votos as Record<string, unknown>
      const lista = votos ? extractList(votos, ['Voto']) : []
      for (const vt of lista as Record<string, unknown>[]) {
        if (String(vt?.CodigoParlamentar ?? '') === codigo) {
          const qv = String(vt?.QualidadeVoto ?? '').toUpperCase()
          // S=Sim, N=Não, A=Abst, P=Presente não votou
          if (qv === 'S' || qv === 'N' || qv === 'A' || qv === 'P') {
            votacoesComissaoPresentes += 1
          }
          break
        }
      }
    }
  }

  // ----- Discursos -----
  let discursosTotal = 0
  for (const result of discursosPorAno) {
    if (result.status !== 'fulfilled' || !result.value) continue
    const val = result.value as Record<string, unknown>
    const disc = val?.DiscursosParlamentar as Record<string, unknown>
    const parl = disc?.Parlamentar as Record<string, unknown>
    const pron = parl?.Pronunciamentos as Record<string, unknown>
    const itens = pron ? extractList(pron, ['Pronunciamento']) : []
    discursosTotal += itens.length
  }

  // ----- Apartes -----
  let apartesTotal = 0
  for (const result of apartesPorAno) {
    if (result.status !== 'fulfilled' || !result.value) continue
    const val = result.value as Record<string, unknown>
    const apa = val?.ApartesParlamentar as Record<string, unknown>
    const parl = apa?.Parlamentar as Record<string, unknown>
    const apartes = parl?.Apartes as Record<string, unknown>
    const itens = apartes ? extractList(apartes, ['Aparte']) : []
    apartesTotal += itens.length
  }

  // ----- Cargos de liderança (ajuste estrutural Fiscalização) -----
  // Conta lideranças e cargos exercidos na Legislatura 57 — ativos contam
  // peso 1.0; cargos já encerrados (mas dentro da legislatura) contam 0.4,
  // pois ainda evidenciam acesso estrutural a relatorias durante o período.
  const cargosTitulos: string[] = []
  let cargosPeso = 0 // soma contínua para o ajuste
  const hoje = new Date().toISOString().slice(0, 10)

  function classificarPeriodo(
    inicio: string,
    fim: string,
  ): 'ativo' | 'leg57_passado' | 'fora' {
    if (!inicio || inicio < LEG57_INICIO) {
      // Pode ainda estar ativo embora tenha começado antes — vale como ativo
      if (!fim || fim > hoje) return 'ativo'
      // Terminável antes ou durante a leg
      if (fim >= LEG57_INICIO) return 'leg57_passado'
      return 'fora'
    }
    // Iniciado na leg 57
    if (!fim || fim > hoje) return 'ativo'
    return 'leg57_passado'
  }

  if (liderancasResult.status === 'fulfilled' && liderancasResult.value) {
    const val = liderancasResult.value as Record<string, unknown>
    const lid = val?.LiderancaParlamentar as Record<string, unknown>
    const parl = lid?.Parlamentar as Record<string, unknown>
    const itens = extractList(parl ?? lid ?? val, ['Liderancas', 'Lideranca'])
    const titulosVistos = new Set<string>()
    for (const it of itens as Record<string, unknown>[]) {
      const dataIni = String(it?.DataInicio ?? it?.DataDesignacao ?? '')
      const dataFim = String(it?.DataFim ?? it?.DataTermino ?? '')
      const periodo = classificarPeriodo(dataIni, dataFim)
      if (periodo === 'fora') continue
      const desc = String(
        it?.DescricaoTipoLideranca ?? it?.DescricaoCargo ?? 'Liderança',
      )
      const partidoSigla = String(
        (it?.Partido as Record<string, unknown>)?.SiglaPartido ?? '',
      )
      const blocoNome = String(
        (it?.Bloco as Record<string, unknown>)?.ApelidoBloco ??
          (it?.Bloco as Record<string, unknown>)?.NomeBloco ?? '',
      )
      const entidade = partidoSigla || blocoNome
      const sufixo = periodo === 'ativo' ? '' : ' (encerrado)'
      const titulo = entidade ? `${desc} — ${entidade}${sufixo}` : `${desc}${sufixo}`
      if (titulosVistos.has(titulo)) continue
      titulosVistos.add(titulo)
      cargosTitulos.push(titulo)
      cargosPeso += periodo === 'ativo' ? 1.0 : 0.4
    }
  }

  if (cargosResult.status === 'fulfilled' && cargosResult.value) {
    const val = cargosResult.value as Record<string, unknown>
    const car = val?.CargoParlamentar as Record<string, unknown>
    const parl = car?.Parlamentar as Record<string, unknown>
    const itens = extractList(parl ?? car ?? val, ['Cargos', 'Cargo'])
    for (const it of itens as Record<string, unknown>[]) {
      const desc = String(it?.DescricaoCargo ?? '')
      const dl = desc.toLowerCase()
      // Só contar cargos com poder estrutural (presidência, vice, mesa, secretaria)
      const relevante =
        dl.includes('presidente') ||
        dl.includes('mesa') ||
        dl.includes('vice') ||
        dl.includes('secret')
      if (!relevante) continue
      const dataIni = String(it?.DataInicio ?? '')
      const dataFim = String(it?.DataFim ?? '')
      const periodo = classificarPeriodo(dataIni, dataFim)
      if (periodo === 'fora') continue
      const orgao = String(
        (it?.IdentificacaoComissao as Record<string, unknown>)
          ?.NomeComissao ??
          it?.NomeOrgao ??
          '',
      )
      const titulo = orgao ? `${desc} — ${orgao}` : desc
      const sufixo = periodo === 'ativo' ? '' : ' (encerrado)'
      cargosTitulos.push(titulo + sufixo)
      cargosPeso += periodo === 'ativo' ? 1.0 : 0.4
    }
  }

  const cargosLideranca = Math.round(cargosPeso * 10) / 10

  return {
    autoriasTotal,
    autoriasAprovadas,
    produtividadePonderada,
    efetividadePonderada,
    efetividadeBase,
    votacoesPresentes,
    votacoesTotal: Math.max(votacoesTotal, 1),
    votacoesComissaoPresentes,
    votacoesComissaoTotal,
    relatoriasTotal,
    discursosTotal,
    apartesTotal,
    cargosLideranca,
    cargosTitulos: cargosTitulos.slice(0, 10), // máx 10 (inclui histórico recente)
    ceapTotalAno: 0,
  }
}

function extractList(
  obj: Record<string, unknown> | undefined,
  path: string[],
): unknown[] {
  if (!obj) return []
  let cur: unknown = obj
  for (const key of path) {
    if (cur && typeof cur === 'object') {
      cur = (cur as Record<string, unknown>)[key]
    } else {
      return []
    }
  }
  if (!cur) return []
  return Array.isArray(cur) ? cur : [cur]
}