# Módulo Estatísticas (`Infraestrutura/Estatisticas`)

> Contrato de leitura do módulo (ADR-004/ADR-014) — mantenha sincronizado com o código.

## Propósito

Apresentar os **relatórios de uso do portal** sobre os agregados privacy-first do Core
(ADR-047): visão geral (cards, série diária, top rotas, top módulos e status HTTP), ranking de
rotas nomeadas, uso por módulo com participação/tendência e contadores de conteúdo. O módulo só
**lê** agregados — não ingere métrica, não toca a trilha de auditoria e não guarda nem exibe IP,
user-agent, path, query string, e-mail ou qualquer identificador.

- **Grupo:** `Infraestrutura` (organizacional — não entra no token, ADR-014).
- **Área:** CMS (domínio próprio, ADR-037), rotas em `/estatisticas[...]`.
- **Ativação:** dado em `core_module_discovery_settings`; `php artisan core:module:activate Estatisticas [--disable]` (ADR-034).

## Estrutura

```
src/modules/Infraestrutura/Estatisticas/
├── ModuleProvider.php
├── MODULE.md
├── Logic/
│   ├── Actions/{EstatisticasOverviewReport,EstatisticasRouteRanking,EstatisticasModuleTrends,EstatisticasContentCards,EstatisticasCsvExport}.php
│   ├── Controllers/{EstatisticasIndex,EstatisticasPages,EstatisticasModules,EstatisticasContent,EstatisticasExport}Controller.php
│   ├── DTOs/EstatisticasFilter.php
│   └── Support/{EstatisticasFilterParser,EstatisticasCache,EstatisticasSortLinks,EstatisticasModuleLabels,EstatisticasCsv}.php
├── Routes/cms.php
├── Layout/Views/cms/{estatisticas-index,estatisticas-paginas,estatisticas-modulos,estatisticas-conteudo}.blade.php
├── Layout/Views/cms/partials/estatisticas-filter.blade.php
├── Lang/{pt_BR,en,es,fr}/{menu,cms,validation,modules}.php
└── Test/{EstatisticasCmsTest,EstatisticasExportTest,EstatisticasI18nTest}.php
```

## Token canônico

**`estatisticas`** — rotas `estatisticas.cms.*`, classes `Estatisticas*`, views `estatisticas::cms.*`
e chaves no namespace `estatisticas`. Verificação: `tools/name-check.sh --grep estatisticas`.

## Rotas

| Nome | Método | URI | Controller | Acesso |
|---|---|---|---|---|
| `estatisticas.cms.index` | GET | `/estatisticas` | `EstatisticasIndexController` | `estatisticas.ver` |
| `estatisticas.cms.paginas` | GET | `/estatisticas/paginas` | `EstatisticasPagesController` | `estatisticas.ver` |
| `estatisticas.cms.modulos` | GET | `/estatisticas/modulos` | `EstatisticasModulesController` | `estatisticas.ver` |
| `estatisticas.cms.conteudo` | GET | `/estatisticas/conteudo` | `EstatisticasContentController` | `estatisticas.ver` |
| `estatisticas.cms.export` | GET | `/estatisticas/exportar` | `EstatisticasExportController` | `estatisticas.ver` |

O caminho é relativo ao domínio do painel (`cms.<base>`); `auth` vem do grupo da área (ADR-037) e a
autorização é `Permission::authorize('Estatisticas', Permission::VIEW)` no controller, **antes** de
qualquer consulta (ADR-033). Módulo inativo responde 404 pelo guard da área (ADR-034). Nenhuma rota
muta estado — o export é `GET` de leitura e casa `*export*` em `config('audit.sensitive')`, então a
leitura entra na trilha (`audit.access`, ADR-035) sem código extra no controller.

## Filtros fechados

- **Período:** presets `7`/`30`/`90` (via `MetricsPeriod::lastDays`, com o `Clock` injetado) e
  `custom` com `de`/`ate` em `Y-m-d`; a janela é inclusiva, `from <= to` e ≤365 dias — a regra final
  é do `Core\Metrics\MetricsPeriod`, não do módulo.
- **Locale** e **módulo:** allowlist (`Locales::all()` e token `[A-Za-z][A-Za-z0-9_]*`) aplicada
  pelo `Core\Metrics\MetricsFilter`; nada de filtro livre por SQL.
- **Entrada inválida nunca vira SQL:** o parser valida a forma, e qualquer falha (preset, data,
  data não escalar — `de[]`/`ate[]` —, ordem invertida, janela >365, locale/módulo inválidos) cai
  num **fallback determinístico** — últimos 7 dias, sem módulo/locale — com mensagem i18n no topo
  da tela (HTTP 200). `de`/`ate` são recusados **antes** das regras: só texto (ou ausente) chega ao
  validador, então a regra cruzada `after_or_equal:de` nunca recebe array. A tela de conteúdo é
  estática (sem filtro) e ignora a query.
- Parâmetros desconhecidos são ignorados; só os cinco campos do filtro entram na validação.

## Consultas, ordenação e cache

- `Core\Contracts\Metrics`: `dailySeries` (dia ASC), `topRoutes(..., 100)`, `byModule` e `byStatus` —
  todas agregadas por `core.core_metrics_daily`, com binding de `day`/`module`/`locale`.
- **Páginas:** até 100 rotas nomeadas no período, 20 por página; ordenação explícita por
  `requests`, `duration` (média), `errors` (4xx+5xx) ou `route`, com desempate único
  `route_name` + `module`. Página fora da faixa é fixada na última; links de coluna carregam
  `aria-sort` (`none`/`ascending`/`descending`).
- **Módulos:** participação inteira no total e tendência percentual contra a janela imediatamente
  anterior de mesmo tamanho (sem base anterior = `null`, exibido como “sem base anterior”).
- **Cache:** 60 s por relatório + período/módulo/locale (+ ordenação/página), chave versionada
  `estatisticas.report.v1.{sha256}`; invalidação só por TTL. A permissão **não** é cacheada: cada
  request autoriza antes de consultar. O cache guarda apenas arrays de escalares (política do
  projeto).
- **Conteúdo:** reusa `ResolveDashboardCards` (permissão do módulo dono + ativação) e mantém apenas
  os cards de conteúdo (não os de estado do Core); valores já vêm com o cache curto do Core.
- **Únicos por dia (HLL):** a visão geral e o CSV `serie` mostram a coluna “únicos (estimativa)”.
  É uma estimativa **do portal inteiro naquele dia** — a ADR-047 não tem dimensão de rota, então
  não existe “únicos por página” no ranking. Dia fora do TTL do HLL (`metrics.uniques.ttl_days`,
  padrão 90) ou sem `pepper` configurado aparece como **indisponível** (célula vazia no CSV), nunca
  `0`.
- **Parâmetros fora da allowlist:** `ordenar`/`direcao`/`relatorio` são lidos como **string**
  escalar; entrada em array (`?ordenar[]=x`) cai no padrão e a tela responde 200 (nunca 500).

## Export CSV

`GET /estatisticas/exportar?relatorio=paginas|modulos|serie` (allowlist; desconhecido cai em
`paginas`) usa **o mesmo** `EstatisticasFilterParser` das telas e responde como anexo
(`Content-Type: text/csv; charset=UTF-8`, `Content-Disposition: attachment`).

- **Relatórios:** `paginas` (ranking completo, até 100 rotas — a mesma lista que a tela pagina,
  via `EstatisticasRouteRanking::rows()`), `modulos` (participação/tendência) e `serie` (série
  diária). As telas de período linkam o próprio relatório; a visão geral oferece os três
  (`data-export-report` é o contrato do link).
- **Streaming:** `response()->streamDownload()` + `EstatisticasCsv::lines()` (`Generator`), uma
  linha por vez no `fputcsv` — o arquivo não é montado em memória.
- **CSV injection:** toda célula de **texto** iniciada por `=`, `+`, `-`, `@`, TAB ou CR recebe o
  prefixo `'` (OWASP) — a mesma regra do export de Dados Abertos. Número (`-20`, tendência) é dado e preserva o valor — mesma regra do export de Dados
  Abertos. Testado em `EstatisticasExportTest`.
- **Sem PII:** só nome de rota, token de módulo, dia UTC e contadores; nenhum path, query, IP,
  user-agent ou locale por linha. O nome do arquivo é `estatisticas-{relatorio}-{de}-{ate}.csv`
  (datas do período validado, nunca a query string).
- **Observabilidade:** span + `log_event` `estatisticas.export` com `report`/`days`/`rows` (escalares).

## Permissões

`estatisticas.ver` declarada em `ModuleProvider::config()` e aplicada com `Permission::authorize()`
(deny-by-default, ADR-033). Os cards de conteúdo continuam protegidos pelas permissões dos módulos
donos (`noticias.ver`, `dados_abertos.ver`, `lgpd.ver`).

## Eventos e observabilidade

- Spans + `log_event` com o mesmo nome por operação de relatório (ADR-024), só quando o relatório é
  calculado (cache miss): `estatisticas.report` (visão geral), `estatisticas.pages`,
  `estatisticas.modules`, `estatisticas.content`, `estatisticas.export`. Atributos/contexto apenas escalares
  (`report`, `days`, `module_filter`, `locale`, `sort`, `direction`, `page`, `total`, `cards`) —
  sem PII, sem path/query.
- Leituras do painel continuam na trilha de auditoria da área (`audit.access`, ADR-035); a métrica
  agregada não é auditoria. O export é uma delas (`*export*`), sem código dedicado.
- **Retenção:** o módulo só lê; `metrics:expunge` (diário, 400 dias) e o alvo `metrics-daily` do
  `retention:expunge` mensal vivem no Core. Desativar um módulo de conteúdo faz o histórico
  permanecer no relatório e parar de crescer (o guard da área roda antes da coleta).

## Layout

- As quatro views usam **um** `<x-layout::cms>` e preenchem só o `<main>`; o `<h1>` é do shell
  (`title` = título do relatório), com breadcrumb começando no painel (ADR-009/ADR-016).
- Tabelas semânticas com `caption`, `th scope="col"` no cabeçalho e `th scope="row"` na primeira
  célula; estado vazio textual (`x-cms::empty-state`), ordenação por link com `aria-sort`,
  paginação nativa com `rel`/`aria-current` do componente da casa.
- Nenhum gráfico é obrigatório — a tabela é a leitura canônica. Cores nunca são o único indicador
  (status e tendência sempre em texto).
- Filtro em `GET` (`x-cms::filter-card`), com datas em `<input type="date">` e selects de locale
  (nomes endônimos) e módulo (rótulos do namespace `estatisticas`).
- 4 locales no namespace `estatisticas`; nenhuma string visível fixa no Blade/PHP.

## Convenções

- [x] `declare(strict_types=1)`; `final readonly`; DI; sem traits/abstract/generics/`Result`.
- [x] Actions focadas (uma por arquivo) com `Telemetry`; controllers finos (autorização → filtro →
      Action → view).
- [x] Sem `new`/`resolve`/`app()` em corpo de domínio; `Clock` e cache injetados; datas nunca de
      `now()`.
- [x] Nenhum segredo/PII em log, URL, cache ou fixture; dimensão é a rota **nomeada** e o módulo.
- [x] Relatórios versionados por chave de cache; nenhuma permissão cacheada.
