# Arquitetura

## Princípios

1. **O isolamento entre clientes não pode depender de disciplina.** Está na
   camada de modelo, não nos controllers. Um controller distraído não vaza dados.
2. **A TV nunca pode ficar preta.** Toda falha degrada para o último conteúdo
   conhecido, nunca para tela vazia.
3. **Sem framework, sem Composer.** Roda em hospedagem compartilhada, faz deploy
   por FTP, não quebra em atualização de dependência transitiva.
4. **CRUD uniforme por scaffold.** Um caminho só para validação, CSRF, permissão
   e escopo. Corrigir ali corrige em todo o sistema.

---

## Camadas

```
  Request
     │
     ▼
  public/index.php ─── Router ─── middleware (auth · CSRF · RBAC)
                          │
                          ▼
                     Controller
                          │
                          ▼
                   Model (escopo de tenant)  ──▶  Database (PDO)
                          │
                          ▼
                    View (escape obrigatório)
```

`src/Core/` não conhece o domínio. `src/Models/` não conhece HTTP.
`src/Controllers/` liga os dois.

---

## Modelo de dados

```
tenants ──┬── users
          ├── devices ──┬── device_group_members ── device_groups
          │             ├── device_commands
          │             ├── device_events
          │             └── playback_logs
          ├── media ──── stream_settings (1:1, câmeras RTSP)
          ├── playlists ── playlist_items ──▶ media
          └── schedules ──┬── schedule_targets ──▶ devices | device_groups | all
                          └──▶ playlists
```

### Decisões que merecem explicação

**Câmeras e YouTube vivem em `media`.** A alternativa — tabelas separadas com
`playlist_items` polimórfico — complicaria toda consulta de playlist para
economizar poucas colunas nulas. Detalhes de RTSP ficam em `stream_settings`,
uma extensão 1:1.

**`days_mask` é bitmask, não `SET`.** Bit 0 = domingo … bit 6 = sábado.
Consultável com `mask & (1 << weekday)`, cabe em um `TINYINT`, e o PHP manipula
sem parsing.

**`playback_logs` é desnormalizado com `tenant_id`.** Redundante em relação a
`device_id`, mas transforma o relatório mais comum em um índice só. É a tabela
que mais cresce; vale o byte extra.

**Soft delete com `deleted_at`.** Excluir uma playlist não pode quebrar
relatórios históricos. O expurgo real é do cron, após 7 dias.

---

## Resolução de agendamento

O ponto mais delicado do sistema: dado um dispositivo e um instante, qual
playlist toca?

```php
Schedule::resolveForDevice(array $device, ?int $now = null): ?array
```

**No fuso da TV, não do servidor.** Uma rede com telas em Manaus e São Paulo
precisa que "às 8h" signifique 8h local em cada uma.

Ordem de decisão:

1. Agendamentos ativos que atingem a TV (direto, por grupo ou "todas"), dentro
   da faixa de datas
2. Filtra por dia da semana e faixa de horário
3. Empate → maior `priority`; persistindo → o mais recente (`id DESC`)
4. Sem agendamento aplicável → `devices.default_playlist_id`
5. Nada disso → `null`, e a TV mostra "sem conteúdo programado"

### Janelas que cruzam a meia-noite

Quando `start_time > end_time` (ex.: 22:00 → 02:00), a janela atravessa o dia:

```php
if ($start <= $end) {
    $matches = ($mask & (1 << $weekday)) && $time >= $start && $time <= $end;
} else {
    // Antes da virada conta hoje; depois da virada, o dia anterior.
    $matches = (($mask & (1 << $weekday)) && $time >= $start)
            || (($mask & (1 << $prevDay)) && $time <= $end);
}
```

Um agendamento "sexta, 22:00–02:00" toca na sexta à noite **e** na madrugada de
sábado. É o que o operador espera ao escrever isso.

---

## Ciclo de vida do dispositivo

```
  cadastro no painel
        │
        ▼
   status=pending, pairing_code=123456 (TTL 30 min)
        │
        │  TV: POST /api/v1/pair
        ▼
   status=active, token_hash=sha256(token), pairing_code=NULL
        │
        ├──▶ GET  /api/v1/playlist    programação resolvida + version
        ├──▶ POST /api/v1/heartbeat   a cada 60 s; devolve comandos pendentes
        └──▶ POST /api/v1/logs        comprovação de veiculação, em lote
```

**Por que código de 6 dígitos?** O instalador está em pé, em frente à TV, com um
controle remoto. Digitar uma URL com token é inviável. Seis dígitos são
digitáveis; a força bruta é contida por rate limit por IP e expiração.

**Por que o token não expira?** Uma TV pode ficar meses sem manutenção. Token
com expiração exigiria refresh — mais uma coisa para falhar às 3h da manhã.
A revogação é explícita: "Reparear" no painel invalida o token na hora.

---

## Versionamento de conteúdo

Cada resposta de `/playlist` carrega uma `version` — SHA-1 do conjunto de itens,
ordem e durações:

```php
$version = substr(sha1(json_encode([
    $playlist['id'], $playlist['updated_at'], $playlist['play_mode'],
    array_map(fn($i) => [$i['media_id'], $i['duration'], $i['url'] ? 1 : 0], $items),
])), 0, 40);
```

O heartbeat compara a versão que a TV tem com a do servidor e devolve
`content_stale: true` quando mudou. A TV só rebaixa a programação quando há
motivo — 100 telas não repuxam a playlist inteira a cada minuto.

---

## Entrega de mídia

As TVs não têm sessão. Arquivos locais são entregues por URL assinada:

```
/m/{media_id}?d={device_id}&e={expires}&s={hmac_sha256}
```

O HMAC (chave `URL_KEY`) cobre os três parâmetros. O controller ainda confirma
que mídia e dispositivo pertencem ao mesmo cliente — assinatura válida com IDs
cruzados de clientes diferentes não passa.

O `MediaController::streamFile()` implementa **HTTP Range**. Sem isso a TV não
consegue buscar posição nem retomar após queda de rede — o vídeo reinicia do
zero. Com `mod_xsendfile` disponível, o PHP delega ao servidor.

---

## Resiliência do player

| Falha | Comportamento |
|---|---|
| Servidor fora do ar | Toca a programação do `localStorage` |
| Uma mídia falha | Pula para a próxima e registra `outcome=error` |
| Todas as mídias falham | Após N tentativas, recarrega a programação em 5 s |
| `ended` nunca dispara | Timer de segurança (duração + 15 s) força o avanço |
| Autoplay com som bloqueado | Repete no mudo em vez de travar |
| Erro de JavaScript | `window.onerror` captura e mantém o loop vivo |
| Token revogado | Volta ao pareamento em vez de tentar em looping |

Duas camadas alternadas (`layerA`/`layerB`) montam a peça seguinte escondida
antes de revelar — é o que elimina o flash preto entre conteúdos.

---

## O scaffold de CRUD

`CrudController` é dirigido por definição de campos:

```php
protected function fields(?array $row = null): array
{
    return [
        ['name' => 'name', 'label' => 'Nome', 'type' => 'text', 'required' => true, 'col' => 8],
        ['name' => 'status', 'label' => 'Situação', 'type' => 'select', 'col' => 4,
         'options' => ['active' => 'Ativa', 'draft' => 'Rascunho']],
    ];
}
```

Tipos disponíveis: `text`, `email`, `url`, `number`, `date`, `time`,
`datetime-local`, `password`, `textarea`, `select`, `checkbox`, e os
específicos do domínio — `days` (dias da semana), `targets` (alvos de
agendamento), `devices` (seleção de TVs).

Ganchos para quando o padrão não basta: `beforeSave()`, `afterCreate()`,
`afterUpdate()`, `beforeDelete()` (devolver string bloqueia a exclusão com
mensagem), `viewData()`, `filters()`.

`MediaController` e `ScheduleController` sobrescrevem `store()`/`edit()` porque
têm fluxo próprio; ainda assim herdam listagem, exclusão e auditoria.

---

## Desempenho

Números de referência para 100 TVs com heartbeat de 60 s:

| Métrica | Estimativa |
|---|---|
| Requisições de API | ~1,7/s (heartbeat) + picos de `/playlist` |
| Linhas em `playback_logs` | ~17 milhões/mês (peças de 15 s) |
| Banda de mídia | Depende do cache; a TV rebaixa só quando a versão muda |

Onde a coisa quebra primeiro, em ordem:

1. **`playback_logs`** — sem o cron de expurgo, os relatórios ficam lentos em
   poucos meses. Acima de 500 TVs, considere particionar por mês.
2. **Entrega de arquivos pelo PHP** — com muitas TVs, use `X-Sendfile`
   (Apache) ou `X-Accel-Redirect` (Nginx), ou sirva a mídia por CDN.
3. **OPcache desligado** — dobra o custo de CPU de cada requisição de API.
