/**
 * Campanhas — kanban (ecrã 2A de docs/MODULOS-RICARDO.md).
 *
 * Tinha **dados inventados** desde a Fase 0: o kanban funcionava, arrastava-se
 * entre colunas, e nada disso saa do browser. Desde 22 Set tem
 * `api/campanhas.js` por baixo.
 *
 * ## A máquina de estados vive no servidor
 *
 * Este ecrã tem uma cópia das transições permitidas, e isso é de propósito:
 * serve para **não oferecer** um botão que ia dar erro. Mas quem decide é o
 * servidor, e por isso nada muda no ecrã antes de ele responder 200 — um
 * kanban que mexe o cartão primeiro e desfaz depois é pior do que um que
 * espera meio segundo.
 *
 * Deixou de haver "Anular" no toast pela mesma razão: anular uma transição
 * nem sempre é possível — `encerrada` não volta atrás — e um botão que
 * funciona em quatro casos de cinco ensina a confiar nele.
 */

// Os nomes do histórico e dos avisos. O estado à vista (cartão, cabeçalho,
// Início, Resultados) vem de `estadoVisivel` (utils/estados.js, 28 Set): três,
// Por aprovar, No ar e Terminada.
const ESTADOS_CAMPANHA = [
  { chave: 'draft',      rotulo: 'Criada', cor: 'hsl(var(--muted-foreground))' },
  { chave: 'em_revisao', rotulo: 'A editar',  cor: 'hsl(var(--accent))' },
  { chave: 'aprovada',   rotulo: 'Aprovada',    cor: 'hsl(var(--foreground))' },
  { chave: 'ativa',      rotulo: 'Aprovada e publicação pedida', cor: 'hsl(var(--primary))' },
  { chave: 'encerrada',  rotulo: 'Terminada',   cor: 'hsl(var(--unknown))' },
];

const ROTULO_ESTADO = Object.fromEntries(ESTADOS_CAMPANHA.map(e => [e.chave, e.rotulo]));

const VARIANTE_ESTADO = {
  draft: 'contorno',
  em_revisao: 'accent',
  aprovada: 'neutro',
  ativa: 'primario',
  encerrada: 'contorno',
};

const CANAIS_CAMPANHA = [
  { chave: 'social', rotulo: 'Redes sociais', curto: 'Redes', Icone: IconSocial },
  { chave: 'dooh',   rotulo: 'Painéis',       curto: 'Painéis', Icone: IconDooh },
  { chave: 'pdv',    rotulo: 'Assistente na loja', curto: 'Loja', Icone: IconPdv },
];

// Na língua de quem vende, e não de marketing (parecer do Codex, 24 Set:
// "Awareness" não diz nada a um padeiro).
const OBJECTIVOS = {
  promocao: 'Vender uma oferta',
  lancamento: 'Lançar uma novidade',
  awareness: 'Dar a conhecer o negócio',
  evento: 'Anunciar um evento',
};

/**
 * O objectivo que a mensagem sugere, sem IA: um desconto é uma oferta, "novo"
 * é uma novidade, uma data com "dia" ou "festa" é um evento. Só sugere; quem
 * escolhe é a pessoa, e uma escolha dela não se muda.
 */
function sugerirObjectivo(mensagem) {
  const m = String(mensagem || '').toLowerCase();
  if (!m.trim()) return '';
  if (/\d+\s*%|€|desconto|promo|oferta|preço|leve \d|pague \d|saldos/.test(m)) return 'promocao';
  if (/\bnov[oa]s?\b|lança|chegou|chegaram|estreia|acabad[oa] de chegar/.test(m)) return 'lancamento';
  if (/\bevento\b|\bfesta\b|inaugura|workshop|degusta|\bfeira\b|concerto|aniversário/.test(m)) return 'evento';
  return '';
}

/**
 * Um nome para a campanha a partir da mensagem: a primeira frase, até umas
 * seis palavras, sem preços nem datas no fim. É só para o cliente a encontrar.
 */
function tituloDaMensagem(mensagem) {
  const frase = String(mensagem || '').split(/[.!?\n:]/)[0].trim();
  const palavras = frase.split(/\s+/).filter(Boolean);
  // Até ao primeiro número: o preço e as datas mudam, o nome fica.
  const ate = palavras.findIndex((p) => /\d/.test(p));
  let t = palavras.slice(0, Math.min(6, ate > 0 ? ate : 6)).join(' ');
  // O que vinha antes do preço também sai: "a partir de", "por apenas", "desde"
  // (ensaio de 3 Out: «Cabazes de Natal a partir»).
  for (let i = 0; i < 3; i++) {
    t = t.replace(/\s+(?:a\s+partir(?:\s+de)?|por\s+apenas|apenas|desde|s[óo]|a|de|com|por|em|até|para|do|da|dos|das|no|na|e|o|os|as)$/i, '');
  }
  t = t.replace(/[,;–-]+$/, '').trim();
  return t ? t.charAt(0).toUpperCase() + t.slice(1) : '';
}

const MESES = ['janeiro', 'fevereiro', 'marco', 'abril', 'maio', 'junho', 'julho', 'agosto', 'setembro', 'outubro', 'novembro', 'dezembro'];

/**
 * As datas que a mensagem diz ("de 12 a 25 de outubro", "de 1 a 7 de Outubro
 * de 2026", "até 31 de outubro"), em AAAA-MM-DD, ou null. Sem ano, o deste, ou
 * o do ano que vem se o mês já passou. O cliente simulado de 28 Set escreveu as
 * datas na mensagem e a campanha nasceu sem elas: não se podia aprovar, e
 * acrescentá-las depois contava como "a oferta mudou".
 */
function datasDaMensagem(mensagem) {
  // Dias com nome passam a data, e «até ao dia 6» a «até 6»: «do dia 1 de Dezembro até ao Dia de Reis» ficava
  // sem datas e a campanha saía em Novembro (utilizador virgem, 3 Nov).
  const t = String(mensagem || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
    // Só depois de «até»: «Pastelaria Reis», «Ementa de Ano Novo» e «Bolo-rei de Natal» não são datas (revisor R-6).
    .replace(/\bate\s+(?:ao\s+|o\s+)?(?:dia\s+de\s+)?reis\b/g, 'ate 6 de janeiro')
    .replace(/\bate\s+(?:ao\s+|o\s+)?(?:dia\s+de\s+)?natal\b/g, 'ate 25 de dezembro')
    .replace(/\bate\s+(?:ao\s+|o\s+)?(?:dia\s+de\s+)?ano\s+novo\b/g, 'ate 1 de janeiro')
    .replace(/\b(a|ate|desde|de|do)\s+(?:ao\s+|o\s+)?dia\s+(\d)/g, '$1 $2');
  const hoje = new Date();
  const ano = (mes, dito) => (dito ? Number(dito) : (mes < hoje.getMonth() ? hoje.getFullYear() + 1 : hoje.getFullYear()));
  const iso = (a, m, d) => `${a}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
  const meses = MESES.join('|');
  // "de 12 a 25 de outubro [de 2026]" ou "12 a 25 de outubro"
  let m = t.match(new RegExp(`(\\d{1,2})\\s*(?:a|ate|-|–)\\s*(\\d{1,2})\\s+de\\s+(${meses})(?:\\s+de\\s+(\\d{4}))?`));
  if (m) {
    const mes = MESES.indexOf(m[3]);
    const a = ano(mes, m[4]);
    const r = { inicio: iso(a, mes, Number(m[1])), fim: iso(a, mes, Number(m[2])) };
    return r.fim >= r.inicio ? r : null;
  }
  // "de 28 de setembro a 4 de outubro"
  m = t.match(new RegExp(`(\\d{1,2})\\s+de\\s+(${meses})\\s*(?:a|ate|-|–)\\s*(\\d{1,2})\\s+de\\s+(${meses})(?:\\s+de\\s+(\\d{4}))?`));
  if (m) {
    const m1 = MESES.indexOf(m[2]); const m2 = MESES.indexOf(m[4]);
    const a2 = ano(m2, m[5]); const a1 = m1 > m2 ? a2 - 1 : a2;
    // Um intervalo ao contrário não é uma data a sugerir (o servidor recusava-o; revisor R-6).
    const r = { inicio: iso(a1, m1, Number(m[1])), fim: iso(a2, m2, Number(m[3])) };
    return r.fim >= r.inicio ? r : null;
  }
  // "até 25 de outubro" (ensaio, 2 Out: o comentário dizia-o e o código não):
  // começa amanhã. Uma data que já passou não serve.
  m = t.match(new RegExp(`\\bate\\s+(?:(?:ao\\s+)?dia\\s+)?(\\d{1,2})\\s+de\\s+(${meses})(?:\\s+de\\s+(\\d{4}))?`));
  if (m) {
    const mes = MESES.indexOf(m[2]);
    const fim = iso(ano(mes, m[3]), mes, Number(m[1]));
    const amanha = new Date(hoje.getFullYear(), hoje.getMonth(), hoje.getDate() + 1);
    const inicio = iso(amanha.getFullYear(), amanha.getMonth(), amanha.getDate());
    return fim >= inicio ? { inicio, fim } : null;
  }
  return null;
}

/** Os números com % ou € de um texto: o que muda de uma campanha para a seguinte. */
function valoresDaMensagem(mensagem) {
  return [...new Set(String(mensagem || '').match(/\d+(?:[.,]\d+)?\s*(?:%|€|euros?)/gi) || [])];
}

/**
 * Acções disponíveis no drawer, por estado de origem.
 *
 * "Aprovar campanha" não muda o estado directamente: abre o conjunto para rever
 * (ModalAprovarCampanha), e é lá que se aprova (sessão de 24 Set). "Pedir
 * publicação" é o que a activação faz: cria o trabalho de envio para a
 * NetScreen, e não põe nada a passar sozinho (parecer do Codex).
 *
 * "Submeter para revisão" saiu do rascunho (seguimento do Codex, 24 Set):
 * ninguém recebia o pedido. Volta quando houver uma tarefa com dono e motivo.
 */
const TRANSICOES = {
  draft: [
    { para: 'aprovada', rotulo: 'Aprovar e publicar', principal: true },
  ],
  em_revisao: [
    { para: 'aprovada', rotulo: 'Aprovar e publicar', principal: true },
  ],
  // "Aprovar e publicar" é um gesto só (28 Set). Uma campanha fica "Aprovada"
  // quando a publicação não pôde ir (o limite do plano, um painel sem
  // ficheiro): "Publicar" tenta outra vez, sem aprovar de novo.
  aprovada: [
    { para: 'ativa', rotulo: 'Publicar', principal: true },
    { para: 'em_revisao', rotulo: 'Voltar a editar' },
  ],
  ativa: [{ para: 'encerrada', rotulo: 'Encerrar campanha', destrutiva: true }],
  encerrada: [],
};

/** A regra dos botões de estado. */
function transicaoPermitida(de, para) {
  return (TRANSICOES[de] || []).some(t => t.para === para);
}

const ESTADO_PUBLICACAO = {
  publicado: { rotulo: 'Publicado', variante: 'sucesso', Icone: IconCheck },
  pendente:  { rotulo: 'Pendente',  variante: 'neutro',  Icone: IconRelogio },
  erro:      { rotulo: 'Erro',      variante: 'erro',    Icone: IconAlerta },
};

// ─── Helpers ─────────────────────────────────────────────────────────

/** Texto de datas do card. `urgente` segue a regra dos alertas do dashboard (≤ 3 dias). */
function descreverDatas(campanha) {
  const { estado, data_inicio, data_fim } = campanha;

  // Sem datas dizia "Terminou a —" (28 Set).
  if (!data_inicio || !data_fim) return { texto: 'Sem datas escolhidas' };
  // Encerrada antes da data de fim: «Terminou a 15 nov» era uma data que ainda não chegou (agentes, 20 Out).
  if (estado === 'encerrada' && diasAte(data_fim) > 0) {
    return { texto: diasAte(data_inicio) > 0 ? 'Encerrada antes de começar' : `Encerrada antes do fim (ia até ${formatDataCurta(data_fim)})` };
  }
  if (estado === 'encerrada') return { texto: `Terminou a ${formatDataCurta(data_fim)}` };

  // Também publicada: uma "Agendada" diz quando arranca, e não só quando acaba
  // (ensaio de 7 Out, P5).
  if (diasAte(data_inicio) > 0) {
    return { texto: `Arranca a ${formatDataCurta(data_inicio)} · termina a ${formatDataCurta(data_fim)}` };
  }

  const dias = diasAte(data_fim);
  if (dias < 0) return { texto: `Terminou a ${formatDataCurta(data_fim)}`, urgente: true };
  if (dias === 0) return { texto: 'Termina hoje', urgente: true };
  if (dias <= 3) return { texto: `Termina em ${dias} ${dias === 1 ? 'dia' : 'dias'}`, urgente: true };
  return { texto: `Termina a ${formatDataCurta(data_fim)}` };
}

function temErroPublicacao(campanha) {
  return Object.values(campanha.publicacoes || {}).some(p => p.estado === 'erro');
}

function formatMomento(iso) {
  const d = new Date(iso);
  const hora = d.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' });
  const dias = diasAte(iso);
  if (dias === 0) return `Hoje, ${hora}`;
  if (dias === -1) return `Ontem, ${hora}`;
  return `${formatDataCurta(iso)}, ${hora}`;
}

// ─── Componentes ─────────────────────────────────────────────────────

/** Os 3 canais sempre na mesma posição; os inactivos ficam apagados. */
function CanaisCampanha({ canais }) {
  const activos = CANAIS_CAMPANHA.filter(c => canais.includes(c.chave));
  return (
    <span className="flex items-center gap-2" aria-label={`Canais: ${activos.map(c => c.curto).join(', ')}`}>
      {CANAIS_CAMPANHA.map(({ chave, curto, Icone }) => (
        <span
          key={chave}
          title={canais.includes(chave) ? curto : `${curto} não incluído`}
          className={canais.includes(chave) ? 'text-foreground' : 'text-muted-foreground opacity-40'}
          aria-hidden="true"
        >
          <Icone size={16} />
        </span>
      ))}
    </span>
  );
}

/**
 * O estado que o cartão mostra. Com a publicação pedida, o que se sabe vem dos
 * destinos: "No ar" só quando todos chegaram (D3, 28 Set).
 */
function rotuloDoCartao(c) {
  return estadoVisivel(c);
}

function CartaoCampanha({ campanha, onAbrir, slug }) {
  const estado = rotuloDoCartao(campanha);
  const datas = descreverDatas(campanha);
  const erro = temErroPublicacao(campanha);
  const nLojas = campanha.lojas.length;
  // A campanha que nasceu de um vídeo do Criar mostra a capa dele (crítica visual, 3 Nov). Sem capa
  // (outro formato, ficheiro em falta), a imagem esconde-se e o cartão fica como era.
  const videoId = campanha.briefing && campanha.briefing.criacao_video_id;
  const [semCapa, setSemCapa] = React.useState(false);

  return (
    <button
      type="button"
      onClick={() => onAbrir(campanha.id)}
      className="sem-moldura block w-full border border-border bg-card p-4 text-left transition-[border-color,background-color] hover:border-[rgba(245,242,237,0.3)] hover:bg-secondary"
    >
      <div className="flex items-start justify-between gap-3">
        {videoId && slug && !semCapa && (
          <img
            src={`/api/${slug}/criar/video/${encodeURIComponent(videoId)}/ficheiros/poster-1080x1920.png`}
            alt=""
            loading="lazy"
            onError={() => setSemCapa(true)}
            className="aspect-[9/16] w-12 shrink-0 rounded-sm border border-border object-cover"
          />
        )}
        <p className="min-w-0 flex-1 text-sm font-medium leading-snug">{campanha.titulo}</p>
        <Badge variante={estado.variante || 'neutro'}>{estado.rotulo}</Badge>
      </div>

      <div className="mt-4 flex items-center gap-3">
        <CanaisCampanha canais={campanha.canais} />
        <Badge variante="contorno" className="ml-auto">
          {nLojas} {nLojas === 1 ? 'loja' : 'lojas'}
        </Badge>
      </div>

      <div className="mt-3 space-y-1.5 border-t border-border pt-3 text-xs">
        <p className={datas.urgente ? 'text-accent' : 'text-muted-foreground'}>{datas.texto}</p>
        {erro && (
          <p className="flex items-center gap-1.5 text-perigo-texto">
            <IconAlerta size={12} />
            Erro de publicação
          </p>
        )}
      </div>
    </button>
  );
}

/**
 * As campanhas em três grupos, pelo que o cliente tem de fazer (28 Set).
 *
 * Até aqui era um quadro de arrastar com cinco colunas. Os quatro agentes que
 * avaliaram a app convergiram: o arrasto mostrava a máquina de estados em vez
 * do próximo passo, a coluna "Em revisão" estava sempre vazia, e mudar de
 * estado a arrastar saltava a revisão do conjunto. Decisão do Ricardo: lista
 * por estado. Os estados de antes continuam no cartão, como etiqueta.
 */
const GRUPOS_CAMPANHAS = [
  { chave: 'por_aprovar', rotulo: 'Por aprovar', estados: ['draft', 'em_revisao', 'aprovada'], vazio: 'Nada à espera da tua aprovação.' },
  { chave: 'publicadas',  rotulo: 'No ar',  estados: ['ativa'],     vazio: 'Nenhuma campanha no ar.' },
  { chave: 'terminadas',  rotulo: 'Terminadas',  estados: ['encerrada'], vazio: 'Ainda nenhuma terminou.' },
];

function ListaCampanhas({ campanhas, onAbrir, slug }) {
  return (
    <div className="space-y-8">
      {GRUPOS_CAMPANHAS.map(g => {
        const doGrupo = campanhas.filter(c => g.estados.includes(c.estado));
        // Terminadas vazias não dizem nada a quem começa: só aparecem quando há.
        if (g.chave === 'terminadas' && !doGrupo.length) return null;
        // Uma agendada ainda não está no ar: o grupo diz que as tem (agentes, 20 Out).
        const comAgendadas = g.chave === 'publicadas' && doGrupo.some(c => rotuloDoCartao(c).rotulo === 'Agendada');
        return (
          <section key={g.chave} aria-labelledby={`grupo-${g.chave}`}>
            <div className="flex items-center gap-2 border-b border-border pb-2">
              <h2 id={`grupo-${g.chave}`} className="label-eyebrow text-foreground">{comAgendadas ? 'No ar e agendadas' : g.rotulo}</h2>
              <span className="text-xs tabular-nums text-muted-foreground">{doGrupo.length}</span>
            </div>
            {doGrupo.length ? (
              <ul className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
                {doGrupo.map(c => (
                  <li key={c.id}><CartaoCampanha campanha={c} onAbrir={onAbrir} slug={slug} /></li>
                ))}
              </ul>
            ) : (
              <p className="mt-3 text-xs text-muted-foreground">{g.vazio}</p>
            )}
          </section>
        );
      })}
    </div>
  );
}

function KanbanSkeleton() {
  return (
    <div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3" aria-hidden="true">
      <div className="loading-skeleton h-32" />
      <div className="loading-skeleton h-32" />
      <div className="loading-skeleton h-32" />
    </div>
  );
}

function SeccaoCampanha({ titulo, id, children }) {
  return (
    <section id={id} className="border-b border-border py-5 first:pt-0 last:border-b-0">
      <h3 className="ns-seccao-titulo">{titulo}</h3>
      <div className="mt-3">{children}</div>
    </section>
  );
}

/** Os passos que contam: um por rótulo, e o "Aprovada" de "Aprovar e publicar" junto do pedido. */
function passosDoHistorico(todos) {
  return (todos || []).filter((p, i, t) => (i === 0 || ROTULO_ESTADO[p.estado] !== ROTULO_ESTADO[t[i - 1].estado])
    && !(p.estado === 'aprovada' && t[i + 1] && t[i + 1].estado === 'ativa'));
}

function TimelineEstados({ historico: todos }) {
  const historico = passosDoHistorico(todos);
  return (
    <ol className="relative space-y-4 border-l border-border pl-5">
      {historico.map((passo, i) => {
        const actual = i === historico.length - 1;
        return (
          <li key={`${passo.estado}-${i}`} className="relative">
            <span
              className={`absolute -left-[1.4rem] top-1.5 h-2 w-2 ${actual ? 'bg-primary' : 'bg-muted-foreground'}`}
              aria-hidden="true"
            />
            <p className={`text-sm ${actual ? 'text-foreground' : 'text-muted-foreground'}`}>
              {ROTULO_ESTADO[passo.estado]}
              {actual && <span className="sr-only"> (estado actual)</span>}
            </p>
            <p className="text-xs text-muted-foreground">{formatMomento(passo.em)}</p>
          </li>
        );
      })}
    </ol>
  );
}

function RodapeTransicoes({ campanha, faltaAprovar, onPedirTransicao, onEditar, falta = null }) {
  // Uma "Aprovada" de antes de 24 Set sem o conjunto aprovado: pedir a
  // publicação ia falhar; o que falta é aprovar.
  const transicoes = campanha.estado === 'aprovada' && faltaAprovar && faltaAprovar.length
    ? [{ para: 'aprovada', rotulo: 'Aprovar e publicar', principal: true }, ...TRANSICOES.aprovada.slice(1)]
    : TRANSICOES[campanha.estado] || [];
  // O servidor recusa editar uma campanha no ar ou encerrada: o que está
  // publicado deixaria de corresponder ao que está escrito.
  const editavel = !['ativa', 'encerrada'].includes(campanha.estado);

  const editar = editavel && (
    <button
      key="editar"
      type="button"
      onClick={() => onEditar(campanha)}
      className="border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-secondary"
    >
      Editar briefing
    </button>
  );

  // Apagar (ensaio de 3 Out: a rota existia e nenhum ecrã a chamava). Uma campanha
  // no ar encerra-se primeiro: o servidor recusa apagá-la.
  const apagar = campanha.estado !== 'ativa' && (
    <button
      key="apagar"
      type="button"
      onClick={() => onPedirTransicao(campanha.id, 'apagar')}
      className="mr-auto px-2 py-2 text-sm text-muted-foreground underline underline-offset-2 hover:text-perigo-texto"
    >
      Apagar a campanha
    </button>
  );

  if (!transicoes.length) {
    return (
      <p className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
        {apagar}
        Campanha encerrada. Fica disponível só para consulta.
      </p>
    );
  }

  // O botão principal fica sempre no fim, à direita: é onde o olho o procura
  // (28 Set). A ordem das restantes mantém-se.
  const ordenadas = [...transicoes.filter(t => !t.principal), ...transicoes.filter(t => t.principal)];
  return [apagar, editar, ...ordenadas.map(t => {
    const classe = t.principal
      ? 'bg-primary text-primary-foreground hover:bg-[hsl(var(--primary)/0.85)]'
      : t.destrutiva
        ? 'border border-[hsl(var(--offline)/0.5)] text-perigo-texto hover:bg-[hsl(var(--offline)/0.1)]'
        : 'border border-border text-foreground hover:bg-secondary';

    // Enquanto há um bloqueio, "Aprovar e publicar" diz o que falta e fica
    // desligado; o bloco "Antes de aprovar" diz onde se resolve (28 Set).
    const bloqueado = Boolean(falta) && t.para === 'aprovada';
    // «A ler a campanha…», «A carregar o vídeo», «A escrever os anúncios…»: é trabalho em curso,
    // e mostra-se como tal (8 Out), e não como um botão só desligado.
    const aTrabalhar = bloqueado && /^A (ler|carregar|escrever)/.test(falta);
    // O que falta lê-se: o laranja apagado com o texto a 55% tinha pouco contraste (agentes, 20 Out).
    const classeFinal = bloqueado && !aTrabalhar ? 'border border-[hsl(var(--accent)/0.6)] text-foreground' : classe;
    return (
      <button
        key={t.para}
        type="button"
        disabled={bloqueado}
        aria-busy={aTrabalhar ? 'true' : undefined}
        title={bloqueado ? 'O que falta está por cima dos anúncios' : undefined}
        className={`px-4 py-2 text-sm transition-colors ${classeFinal}`}
        onClick={() => onPedirTransicao(campanha.id, t.para)}
      >
        {bloqueado ? falta : t.rotulo}
      </button>
    );
  })];
}

// ─── Ecrã ────────────────────────────────────────────────────────────

/**
 * O briefing, numa página (28 Set, advogado da simplicidade): /:slug/campanhas/nova
 * e /:slug/campanhas/:id/briefing. Era um modal, contra docs/padroes-interface.md
 * (demora, e guarda estado que não se pode perder), e fechava-se por engano.
 *
 * O que se escreve é a mensagem. O resto tem bons valores por omissão e diz-se
 * numa linha, "Vai sair: …" (os canais, a rede, as datas, as lojas e a
 * fotografia), com "mudar" para os abrir. As datas lêem-se da mensagem; sem
 * elas, sugere-se de amanhã a duas semanas, e diz-se que é sugestão. Nenhuma
 * campanha nasce por aprovar sem se poder aprovar por falta de datas.
 *
 * As lojas estão aqui porque são **condição para pôr no ar**: o servidor recusa
 * activar uma campanha sem lojas, e o sítio de resolver isso é onde se escreve
 * o resto.
 */
function isoDoDia(d) {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

/** De amanhã a amanhã + 14 dias: a sugestão quando a mensagem não diz datas. */
function datasSugeridas() {
  const amanha = new Date();
  amanha.setDate(amanha.getDate() + 1);
  const fim = new Date(amanha);
  fim.setDate(fim.getDate() + 14);
  return { inicio: isoDoDia(amanha), fim: isoDoDia(fim) };
}

/** A fotografia que o servidor pré-escolhe (api/campanhas.js, pre_escolher_foto). */
function fotosCandidatas(materiais) {
  return (materiais || [])
    .filter((m) => m.kind === 'image' && !m.campanha_id && m.papel === 'imagem' && !m.com_texto
      && FOTO_MIME.test(m.detected_mime || '') && !(m.size_bytes > 10485760))
    .sort((a, b) => String(b.uploaded_at || '').localeCompare(String(a.uploaded_at || '')));
}

/**
 * A fotografia que se escolhe sozinha: a mais recente cujo nome (ou legenda) tem uma palavra da
 * mensagem. Sem nenhuma assim, nenhuma: a mais recente era uma banca de fruta numa campanha da
 * vacinação da gripe (agentes, 20 Out). Pede-se ao cliente que escolha.
 */
function fotoDaMensagem(fotos, mensagem) {
  const palavras = (t) => (String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
    .match(/[a-z]{4,}/g) || []).filter((p) => !['para', 'como', 'todas', 'todos', 'desde', 'mais', 'image', 'imagem', 'foto', 'final'].includes(p));
  const daMensagem = new Set(palavras(mensagem));
  if (!daMensagem.size) return null;
  return (fotos || []).find((f) => palavras(`${f.original_name} ${f.legenda || ''}`).some((p) => daMensagem.has(p))) || null;
}

/**
 * Os destinos de "Onde sai" (28 Set). Cada um diz se está ligado nos campos do
 * briefing; `alternarDestino` traduz a escolha para os campos que se gravam
 * (canais, redes, rede, paineis, video), como antes.
 */
function destinosDoBriefing({ canaisDoPlano, lojas, canais = [], semVideo = false }) {
  const pode = (canal) => !canaisDoPlano || canaisDoPlano[canal];
  const temPaineis = (lojas || []).some((l) => Number(l.paineis) > 0);
  return [
    pode('social') && { chave: 'instagram', rotulo: 'Instagram', ligado: (c) => c.canais.includes('social') && (c.redes || []).includes('instagram') },
    pode('social') && { chave: 'facebook', rotulo: 'Facebook', ligado: (c) => c.canais.includes('social') && (c.redes || []).includes('facebook') },
    pode('dooh') && temPaineis && { chave: 'lojas', rotulo: 'Painéis nas tuas lojas', frase: 'painéis nas tuas lojas', ligado: (c) => c.canais.includes('dooh') && c.paineis !== false },
    // A rede pede spots; os painéis das lojas não (7 Out). Uma resposta antiga sem `rede` segue o dooh.
    (canaisDoPlano && canaisDoPlano.rede !== undefined ? canaisDoPlano.rede : pode('dooh')) && {
      chave: 'rede', rotulo: 'Rede Brand Digital', frase: 'a rede Brand Digital', ajuda: 'Painéis da NetScreen fora das tuas lojas (por exemplo, em gasolineiras). Vem com o teu plano.',
      ligado: (c) => c.canais.includes('dooh') && c.rede !== false,
    },
    // O vídeo parte dos painéis ou, sem eles, do post (V0, 2 Out). Com o Criar vídeo ligado, o vídeo faz-se
    // lá (um caminho só, ronda 3 e 4 dos utilizadores virgens, 4 Nov); fica só para tirar de uma campanha
    // que já o tinha.
    (pode('dooh') || pode('social')) && !semVideo && {
      chave: 'video', rotulo: 'Vídeo', ajuda: 'Um vídeo curto a partir da imagem dos painéis ou do post. Vês as cenas antes de se fazer.',
      ligado: (c) => temOrigemDoVideo(c) && Boolean(c.video),
    },
    // O assistente na loja ainda não tem por onde ser entregue (não há módulo
    // PdV): não se oferece. Numa campanha que já o tem, aparece para se tirar
    // (parecer do Codex, 28 Set).
    canais.includes('pdv') && {
      chave: 'pdv', rotulo: 'Assistente na loja', frase: 'o assistente na loja',
      ajuda: 'Ainda não disponível: tira-o para a campanha sair sem ele.', ligado: (c) => c.canais.includes('pdv'),
    },
  ].filter(Boolean);
}

// `temPaineis`: sem painéis nas lojas, esse destino não existe, e não segura os painéis ligados.
function alternarDestino(c, chave, temPaineis = true) {
  const com = (lista, v, sim) => (sim ? [...new Set([...lista, v])] : lista.filter((x) => x !== v));
  if (chave === 'instagram' || chave === 'facebook') {
    const antes = c.canais.includes('social') ? (c.redes || []) : [];
    const redes = com(antes, chave, !antes.includes(chave));
    return { ...c, redes: redes.length ? redes : ['instagram', 'facebook'], canais: com(c.canais, 'social', redes.length > 0) };
  }
  if (chave === 'lojas' || chave === 'rede') {
    const campo = chave === 'lojas' ? 'paineis' : 'rede';
    const outro = chave === 'lojas' ? 'rede' : 'paineis';
    const ligadoAntes = c.canais.includes('dooh') && c[campo] !== false;
    const outroLigado = c.canais.includes('dooh') && c[outro] !== false && (outro !== 'paineis' || temPaineis);
    const agora = !ligadoAntes;
    const temDooh = agora || outroLigado;
    // Só o destino carregado muda: gravar o outro a "false" por arrasto tirava
    // os painéis das lojas numa conta cujas lojas ainda não tinham chegado (revisor, 28 Set).
    const novo = { ...c, [campo]: agora, canais: com(c.canais, 'dooh', temDooh) };
    return { ...novo, video: temOrigemDoVideo(novo) ? c.video : false };
  }
  if (chave === 'video') return { ...c, video: !c.video };
  const novo = { ...c, canais: com(c.canais, chave, !c.canais.includes(chave)) };
  return { ...novo, video: temOrigemDoVideo(novo) ? c.video : false };
}

/** O vídeo parte dos painéis ou do post: sem nenhum dos dois, não há de onde (V0, 2 Out). */
function temOrigemDoVideo(c) {
  return c.canais.includes('dooh') || c.canais.includes('social');
}

function PaginaBriefing({ campanha, slug, canaisDoPlano, ocupado, repetidaDe, onFechar, onGuardado, criarActivo = false }) {
  const soLeitura = Boolean(campanha && ['ativa', 'encerrada'].includes(campanha.estado));
  const aberto = true;
  const vazio = {
    titulo: '', objectivo: '', canais: [], data_inicio: '', data_fim: '', orcamento_eur: '',
    mensagem: '', link_produto: '', rede: true, paineis: true, video: false, redes: ['instagram', 'facebook'],
  };
  const [campos, setCampos] = React.useState(vazio);
  const [lojas, setLojas] = React.useState([]);
  // Enquanto as lojas não chegam, não se diz «sem lojas» (inventário de 8 Out: era um falso vazio).
  const [lojasLidas, setLojasLidas] = React.useState(false);
  const [perfis, setPerfis] = React.useState(null);
  const [escolhidas, setEscolhidas] = React.useState([]);
  const [aGuardar, setAGuardar] = React.useState(false);
  const [erro, setErro] = React.useState(null);
  // O objectivo escolhido à mão não se muda; o sugerido segue a mensagem.
  const [objectivoSugerido, setObjectivoSugerido] = React.useState(false);
  // As datas lidas da mensagem (28 Set): preenchem "Começa" e "Acaba" enquanto
  // estiverem vazias, sugeridas, ou forem as que se leram (não as escritas à mão).
  const [datasLidas, setDatasLidas] = React.useState(false);
  const [sugeridas, setSugeridas] = React.useState(false);
  // "mudar": os canais, a rede, o vídeo, as datas, as lojas e a fotografia.
  const [verDetalhes, setVerDetalhes] = React.useState(false);
  // A fotografia, numa campanha nova: a que o servidor escolheria, à vista, e trocável.
  const [fotos, setFotos] = React.useState(null);
  const [fotoId, setFotoId] = React.useState(undefined); // undefined = a automática
  // A campanha que o POST já criou: se a fotografia ou as lojas falharem a
  // seguir, tentar outra vez não cria outra (revisor, 28 Set).
  const criadaRef = React.useRef(null);
  const mudarMensagem = (valor) => {
    const d = datasDaMensagem(valor);
    const podeDatas = d && ((!campos.data_inicio && !campos.data_fim) || datasLidas || sugeridas);
    if (podeDatas) { setDatasLidas(true); setSugeridas(false); }
    const datas = podeDatas ? { data_inicio: d.inicio, data_fim: d.fim } : {};
    if (campos.objectivo && !objectivoSugerido) {
      setCampos((c) => ({ ...c, mensagem: valor, ...datas }));
      return;
    }
    const sugerido = sugerirObjectivo(valor);
    setObjectivoSugerido(Boolean(sugerido));
    setCampos((c) => ({ ...c, mensagem: valor, objectivo: sugerido, ...datas }));
  };
  const repetida = Boolean(repetidaDe);
  const valores = repetida ? valoresDaMensagem(campos.mensagem) : [];

  React.useEffect(() => {
    api.get(`/api/${slug}/lojas`).then((r) => {
      setLojas(r.lojas);
      setLojasLidas(true);
      // Uma campanha nova sai em todas as lojas, por omissão (28 Set): o
      // cliente tira as que não quer, em vez de ter de as escolher todas.
      if (!campanha) {
        inicialLojasRef.current = r.lojas.map((l) => l.id);
        setEscolhidas(inicialLojasRef.current);
      }
    }).catch(() => { setLojas([]); setLojasLidas(true); });
    // Os perfis das redes guardados em Conta: o post vai para eles (agentes, 20 Out).
    api.get(`/api/${slug}/config/redes-sociais`).then((r) => setPerfis(r && Array.isArray(r.redes) ? r.redes : [])).catch(() => setPerfis(null));
    if (!campanha) {
      api.get(`/api/${slug}/materiais`).then((l) => setFotos(fotosCandidatas(l))).catch(() => setFotos([]));
    }
  }, [slug, campanha && campanha.id]);

  React.useEffect(() => {
    setObjectivoSugerido(false);
    inicialLojasRef.current = null;
    if (!campanha) {
      // Os canais que o plano dá, por omissão: as redes e os painéis. O
      // assistente da loja fica de fora até haver um na loja (Fase 5).
      const s = datasSugeridas();
      const base = {
        ...vazio,
        canais: ['social', 'dooh'].filter((c) => !canaisDoPlano || canaisDoPlano[c]),
        data_inicio: s.inicio,
        data_fim: s.fim,
      };
      setSugeridas(true);
      inicialRef.current = base;
      setCampos(base);
      // As redes que o manual diz ("só publicamos no Instagram"), por omissão
      // (cliente simulado, 28 Set). Sem nada lá, as duas; muda-se em "Onde sai".
      api.get(`/api/${slug}/marca/voz`)
        .then((v) => {
          const redes = v && v.proposta && Array.isArray(v.proposta.redes) && v.proposta.redes.length ? v.proposta.redes : null;
          if (!redes) return;
          inicialRef.current = { ...inicialRef.current, redes };
          setCampos((c) => ({ ...c, redes }));
        })
        .catch(() => {});
      return;
    }
    const semDatas = !campanha.data_inicio && !campanha.data_fim;
    // Sem datas, primeiro as que a mensagem diz; só depois a sugestão (revisor,
    // 28 Set: "de 5 a 12 de Outubro" na mensagem, e propunha-se outra coisa).
    const lidas = semDatas ? datasDaMensagem(campanha.briefing && campanha.briefing.mensagem) : null;
    const s = lidas || (semDatas ? datasSugeridas() : null);
    if (lidas) setDatasLidas(true);
    const base = {
      titulo: campanha.titulo || '',
      objectivo: campanha.objectivo || '',
      canais: campanha.canais || [],
      data_inicio: s ? s.inicio : (campanha.data_inicio || '').slice(0, 10),
      data_fim: s ? s.fim : (campanha.data_fim || '').slice(0, 10),
      orcamento_eur: campanha.orcamento_eur ?? '',
      mensagem: (campanha.briefing && campanha.briefing.mensagem) || '',
      link_produto: (campanha.briefing && campanha.briefing.link_produto) || '',
      rede: !(campanha.briefing && campanha.briefing.rede === false),
      paineis: !(campanha.briefing && campanha.briefing.paineis === false),
      video: Boolean(campanha.briefing && campanha.briefing.video),
      redes: (campanha.briefing && Array.isArray(campanha.briefing.redes) && campanha.briefing.redes.length)
        ? campanha.briefing.redes : ['instagram', 'facebook'],
    };
    setSugeridas(Boolean(s) && !lidas);
    inicialRef.current = base;
    setCampos(base);
    if (s) setVerDetalhes(true);
    api.get(`/api/${slug}/campanhas/${campanha.id}`)
      .then((d) => { inicialLojasRef.current = d.lojas.map((l) => l.id); setEscolhidas(inicialLojasRef.current); })
      .catch(() => setEscolhidas([]));
  }, [campanha && campanha.id, slug]);

  const alternar = (lista, valor) =>
    (lista.includes(valor) ? lista.filter((x) => x !== valor) : [...lista, valor]);

  // Mudar o início para depois do fim (sessão de 24 Set: começar em Agosto com
  // o fim ainda em Julho). Não se escolhe o fim em silêncio, que é uma data
  // comercial: diz-se junto do campo e propõe-se manter a duração que havia.
  const [duracao, setDuracao] = React.useState(null);
  const mudarInicio = (valor) => {
    if (campos.data_inicio && campos.data_fim && campos.data_fim >= campos.data_inicio) {
      setDuracao(Math.round((new Date(campos.data_fim) - new Date(campos.data_inicio)) / 86400000));
    }
    setCampos((c) => ({ ...c, data_inicio: valor }));
  };
  const datasTrocadas = Boolean(campos.data_inicio && campos.data_fim && campos.data_fim < campos.data_inicio);
  const fimProposto = datasTrocadas && duracao !== null
    ? new Date(new Date(campos.data_inicio).getTime() + duracao * 86400000).toISOString().slice(0, 10)
    : null;

  // O nome é só para o cliente: sem ele, vem das primeiras palavras da
  // mensagem (28 Set: era um campo obrigatório a mais antes de ver resultado).
  const titulo = campos.titulo.trim() || tituloDaMensagem(campos.mensagem);

  const fotoAuto = fotoDaMensagem(fotos, campos.mensagem);
  const fotoEscolhida = !fotos ? null : fotoId === undefined ? fotoAuto : (fotos.find((f) => f.id === fotoId) || null);

  const submeter = async (e) => {
    e.preventDefault();
    if (!titulo) return;
    setAGuardar(true);
    setErro(null);
    try {
      const corpo = {
        titulo,
        objectivo: campos.objectivo || null,
        canais: campos.canais,
        data_inicio: campos.data_inicio || null,
        data_fim: campos.data_fim || null,
        orcamento_eur: campos.orcamento_eur === '' ? null : Number(campos.orcamento_eur),
        // O que o Agente PM lê. Sem isto o `briefing` ficava sempre vazio e o
        // agente escrevia a partir do título e das datas. Os nomes são os do
        // contrato de briefing (plano-semana-2026-09-21.md, secção 5); o que lá
        // estiver e este ecrã não conheça mantém-se.
        briefing: {
          ...((campanha && campanha.briefing) || {}),
          mensagem: campos.mensagem.trim() || null,
          link_produto: campos.link_produto.trim() || null,
          // Só se guarda quando o cliente a tira: sem o campo, vai (28 Set).
          ...(campos.rede === false ? { rede: false } : { rede: null }),
          // Sem painéis nas lojas, a campanha não tem esse destino: guarda-se, para não pedir lojas por causa
          // da Rede Brand Digital, que está fora delas (utilizador virgem, Óptica, 4 Nov).
          ...(campos.paineis === false || (lojasLidas && !destinos.some((d) => d.chave === 'lojas')) ? { paineis: false } : { paineis: null }),
          // O vídeo só quando é pedido (28 Set): sem o campo, não há storyboard.
          ...(campos.video && temOrigemDoVideo(campos) ? { video: true } : { video: null }),
          // Só se guarda quando não são as duas: sem o campo, vão as duas (28 Set).
          redes: campos.redes && campos.redes.length === 1 ? campos.redes : null,
        },
      };
      // Uma nova leva a fotografia que se vê aqui: a escolhida, ou a da marca que bate com a
      // mensagem. A «mais recente» do servidor (pre_escolher_foto) já não se pede: escolhia uma
      // sem relação com a campanha (agentes, 20 Out).
      const existente = campanha || criadaRef.current;
      const guardada = existente
        ? await api.patch(`/api/${slug}/campanhas/${existente.id}`, corpo)
        : await api.post(`/api/${slug}/campanhas`, { ...corpo, pre_escolher_foto: false });
      if (!campanha) criadaRef.current = guardada;
      if (!campanha && fotoEscolhida) {
        await api.put(`/api/${slug}/campanhas/${guardada.id}/materiais`, {
          materiais: [{ material_id: fotoEscolhida.id, legenda: null, foco: 'centro', ordem: 0 }],
        });
      }
      await api.put(`/api/${slug}/campanhas/${guardada.id}/lojas`, { lojas: escolhidas });
      // Uma campanha nova abre e começa logo a escrever (28 Set): voltar à
      // lista obrigava a procurar o cartão e a carregar noutro botão.
      window.__porGuardar = null;
      onGuardado(campanha ? 'Briefing guardado.' : 'Campanha criada. A IA está a escrever os anúncios.', false, campanha ? null : guardada);
    } catch (err) {
      setErro(err.message);
      onGuardado(err.message, true);
    } finally {
      setAGuardar(false);
    }
  };

  const ocupadoTudo = ocupado || aGuardar;

  // Alterações por guardar: sair pelo menu, ou fechar o separador, pergunta
  // antes (ensaio de ponta a ponta, 24 Set: perdia-se o briefing sem aviso).
  // Numa página, a pergunta é a da navegação (app.jsx), também no "Cancelar".
  const inicialRef = React.useRef(null);
  // As lojas contam também (revisor, 24 Set): mudar só as lojas era "nada por guardar".
  const inicialLojasRef = React.useRef(null);
  const sujo = Boolean(aberto && inicialRef.current && (
    JSON.stringify(campos) !== JSON.stringify(inicialRef.current)
    || (inicialLojasRef.current && JSON.stringify([...escolhidas].sort()) !== JSON.stringify([...inicialLojasRef.current].sort()))
    || (!campanha && fotoId !== undefined)
  ));
  React.useEffect(() => {
    window.__porGuardar = sujo ? 'O briefing tem alterações por guardar.' : null;
    if (!sujo) return undefined;
    const avisar = (e) => { e.preventDefault(); e.returnValue = ''; };
    window.addEventListener('beforeunload', avisar);
    return () => { window.removeEventListener('beforeunload', avisar); window.__porGuardar = null; };
  }, [sujo]);

  // A linha "Vai sair".
  const destinos = destinosDoBriefing({ canaisDoPlano, lojas, canais: (inicialRef.current && inicialRef.current.canais) || [], semVideo: criarActivo && !(campanha && campanha.briefing && campanha.briefing.video) });
  const ligados = destinos.filter((d) => d.ligado(campos) && d.chave !== 'video').map((d, i) => (i && d.frase ? d.frase : d.rotulo));
  const canaisTexto = ligados.length
    ? (ligados.length > 1 ? `${ligados.slice(0, -1).join(', ')} e ${ligados[ligados.length - 1]}` : ligados[0])
      + (campos.video && temOrigemDoVideo(campos) ? ', com vídeo' : '')
    : 'nenhum destino';
  const datasTexto = campos.data_inicio && campos.data_fim
    ? `${formatDataCurta(campos.data_inicio)} a ${formatDataCurta(campos.data_fim)}`
    : 'sem datas';
  // `perfis` null: ainda não lidos, ou não deu para ler; aí não se avisa de nada.
  const redesSemPerfil = perfis && campos.canais.includes('social')
    ? (campos.redes || []).filter((r) => !perfis.some((p) => p.rede === r)).map((r) => (r === 'instagram' ? 'Instagram' : 'Facebook'))
    : [];
  const lojasTexto = !lojasLidas ? 'a ler as lojas…' : !lojas.length ? 'sem lojas' : escolhidas.length === lojas.length && lojas.length > 1
    ? `todas as lojas (${lojas.length})` : `${escolhidas.length} ${escolhidas.length === 1 ? 'loja' : 'lojas'}`;

  return (
    <div className="space-y-6">
      <header>
        <p className="flex items-center gap-2">
          <button type="button" onClick={onFechar} className="label-eyebrow hover:text-foreground">Campanhas</button>
          {campanha && <span className="label-eyebrow" aria-hidden="true">/</span>}
          {campanha && <span className="label-eyebrow normal-case">{campanha.titulo}</span>}
        </p>
        <h1 className="mt-1 font-display text-4xl uppercase leading-none tracking-wide">{campanha ? 'Briefing' : 'Nova campanha'}</h1>
      </header>

      <form id="form-briefing" onSubmit={submeter} className="max-w-2xl space-y-5 border border-border bg-card p-5 lg:p-6">
        {/* Uma repetição diz o que trouxe e o que falta decidir (parecer do
            Codex, ponto 3): as datas e, se mudou, o preço. */}
        {repetida && (
          <div className="border-l-2 border-accent bg-[hsl(var(--accent)/0.08)] px-3 py-2 text-xs text-foreground">
            <p>{`Repetida de «${repetidaDe}». Trouxemos a mensagem, os canais, as lojas, as fotografias e os textos, que voltam a precisar da tua aprovação.`}</p>
            <p className="mt-1">
              {'Falta decidir: as datas'}
              {valores.length ? `, e se ainda valem ${valores.join(', ')}` : ', e se o preço mudou'}.
            </p>
          </div>
        )}

        <div>
          <label htmlFor="br-mensagem" className="ns-rotulo">O que queres comunicar</label>
          <textarea
            id="br-mensagem"
            value={campos.mensagem}
            rows={4}
            autoFocus
            maxLength={2000}
            onChange={(e) => mudarMensagem(e.target.value)}
            placeholder="Ex.: Bolo-rei com 20% de desconto de 1 a 6 de Janeiro, em todas as lojas."
            className="mt-1 w-full border border-border bg-background px-3 py-2 text-sm text-foreground outline-none"
          />
          <p className="ns-ajuda">
            Como o dirias a um colega. O que escreveres aqui (produto, preço, desconto, datas) sai tal como
            está; sobre a marca, só se afirma o que confirmaste em A minha marca.
          </p>
        </div>

        {/* "Vai sair": o que a campanha faz, numa linha, antes de a criar (28 Set). */}
        <div className="rounded-md border border-border bg-background/40 p-3">
          <div className="flex flex-wrap items-start justify-between gap-3">
            <p className="min-w-0 flex-1 text-sm leading-relaxed text-foreground">
              <span className="text-muted-foreground">Vai sair: </span>
              {canaisTexto}
              {' · '}
              <span className={datasTrocadas ? 'text-perigo-texto' : sugeridas ? 'text-accent' : ''}>{datasTexto}</span>
              {/* Com o painel fechado o erro continuava a travar o botão sem se ver (cliente simulado, 5 Out). */}
              {datasTrocadas && !verDetalhes && <span className="text-perigo-texto" role="alert"> (acaba antes de começar: corrige em «Mudar»)</span>}
              {sugeridas && !datasTrocadas && <span className="text-accent"> (sugestão: confirma as datas)</span>}
              {datasLidas && !sugeridas && <span className="text-muted-foreground"> (da mensagem)</span>}
              {' · '}
              {lojasTexto}
              {!campanha && fotos && (
                <React.Fragment>
                  {' · '}
                  {fotoEscolhida
                    // Escolhida por nós, diz-se, e com o nome: a mais recente pode não ser do
                    // produto (parecer do Codex, 3 Out). Troca-se em «Mudar», onde há «Sem».
                    ? <span className="inline-flex items-center gap-1.5 align-middle">
                        {fotoId === undefined ? 'fotografia escolhida por nós:' : 'fotografia:'}
                        <ImagemOuAviso src={fotoEscolhida.url} alt={fotoEscolhida.original_name} texto={null} className="inline-flex h-7 w-7 rounded-sm border border-border object-cover" />
                        <span className="text-muted-foreground">{fotoEscolhida.original_name}</span>
                      </span>
                    // Sem mensagem ainda, não há tema com que comparar (florista virgem, 4 Nov: lia «nenhuma
                    // parece deste tema» com a fotografia acabada de juntar).
                    : fotoId === undefined && fotos.length && !String(campos.mensagem || '').trim()
                      ? 'a fotografia escolhe-se pelo texto: escreve a mensagem, ou escolhe uma em «Mudar»'
                    : fotoId === undefined && fotos.length
                      ? <span className="text-accent">sem fotografia (escolhe uma em «Mudar»: nenhuma da marca parece deste tema)</span>
                      : 'sem fotografia'}
                </React.Fragment>
              )}
            </p>
            <button
              type="button"
              aria-expanded={verDetalhes}
              aria-controls="br-detalhes"
              onClick={() => setVerDetalhes((v) => !v)}
              className="ns-btn ns-btn-sm ns-btn-secundario shrink-0 text-xs"
            >
              {verDetalhes ? 'Fechar' : 'Mudar'}
            </button>
          </div>
          {/* A rede explicada logo, e não só dentro de «Mudar»; e as redes sem perfil guardado em Conta,
              para onde o post não tem por onde sair (agentes, 20 Out). */}
          {!verDetalhes && destinos.some((d) => d.chave === 'rede' && d.ligado(campos)) && (
            <p className="mt-2 text-xs text-muted-foreground">A rede Brand Digital são painéis da NetScreen fora das tuas lojas (por exemplo, em gasolineiras). Vem com o teu plano.</p>
          )}
          {redesSemPerfil.length > 0 && (
            <p className="mt-2 text-xs text-accent" role="note">
              {`Ainda não guardaste ${redesSemPerfil.length === 1 ? `o perfil do ${redesSemPerfil[0]}` : `os perfis do ${redesSemPerfil.join(' e do ')}`} em Conta: a NetScreen precisa ${redesSemPerfil.length === 1 ? 'dele' : 'deles'} para saber onde publicar o post. Guarda-${redesSemPerfil.length === 1 ? 'o' : 'os'} antes de aprovar, ou tira essa rede em «Mudar».`}
            </p>
          )}
          {/* O vídeo à vista, e não só dentro de «Mudar» (teste de raiz, 9 Out: quem pedia «com vídeo» não o
              encontrava). O mesmo campo do «Onde sai». */}
          {!verDetalhes && destinos.some((d) => d.chave === 'video') && temOrigemDoVideo(campos) && (
            <label htmlFor="br-video-visivel" className="mt-2 flex cursor-pointer items-center gap-2 text-sm text-foreground">
              <input
                id="br-video-visivel"
                type="checkbox"
                checked={Boolean(campos.video)}
                onChange={() => setCampos((c) => alternarDestino(c, 'video', destinos.some((x) => x.chave === 'lojas')))}
                className="accent-primary"
              />
              <span>Com vídeo curto <span className="text-muted-foreground">(a partir da imagem dos painéis ou do post; vês as cenas antes de se fazer)</span></span>
            </label>
          )}

          {verDetalhes && (
            <div id="br-detalhes" className="mt-4 space-y-5 border-t border-border pt-4">
              {/* "Onde sai", uma lista só (advogado da simplicidade, 28 Set): eram os
                  canais, e dentro deles a rede, as redes do post e o vídeo, cada um
                  com a sua regra. O que o plano não dá, ou a conta não tem, não aparece. */}
              <fieldset>
                <legend className="ns-rotulo">Onde sai</legend>
                <div className="mt-1.5 grid gap-2 sm:grid-cols-2">
                  {destinos.map((d) => {
                    const ligado = d.ligado(campos);
                    const bloqueado = d.chave === 'video' && !temOrigemDoVideo(campos);
                    return (
                      <label
                        key={d.chave}
                        htmlFor={`br-destino-${d.chave}`}
                        className={`flex items-start gap-2.5 rounded-md border border-border px-3 py-2 text-sm ${bloqueado ? 'text-muted-foreground' : 'cursor-pointer text-foreground'}`}
                      >
                        <input
                          id={`br-destino-${d.chave}`}
                          type="checkbox"
                          checked={ligado}
                          disabled={bloqueado}
                          onChange={() => setCampos((c) => alternarDestino(c, d.chave, destinos.some((x) => x.chave === 'lojas')))}
                          className="mt-0.5 accent-primary"
                        />
                        <span>
                          {d.rotulo}
                          {d.ajuda && <span className="block text-xs text-muted-foreground">{bloqueado ? 'Precisa dos painéis ou das redes: o vídeo parte da imagem deles.' : d.ajuda}</span>}
                        </span>
                      </label>
                    );
                  })}
                </div>
                {/* Sem painéis nas lojas, o destino não aparecia e nada dizia porquê (utilizadores virgens, 3 Nov). */}
                {(!canaisDoPlano || canaisDoPlano.dooh) && !destinos.some((d) => d.chave === 'lojas') && (
                  <p className="mt-2 text-xs text-muted-foreground">
                    Os painéis das tuas lojas aparecem aqui quando a NetScreen indicar em que loja está cada painel.
                  </p>
                )}
              </fieldset>

              <div>
                <div className="grid grid-cols-2 gap-3">
                  <div>
                    <label htmlFor="br-inicio" className="ns-rotulo">Começa</label>
                    <input
                      id="br-inicio"
                      type="date"
                      value={campos.data_inicio}
                      onChange={(e) => { setDatasLidas(false); setSugeridas(false); mudarInicio(e.target.value); }}
                      className="mt-1 w-full border border-border bg-background px-3 py-2 text-sm text-foreground outline-none"
                    />
                  </div>
                  <div>
                    <label htmlFor="br-fim" className="ns-rotulo">Acaba</label>
                    <input
                      id="br-fim"
                      type="date"
                      value={campos.data_fim}
                      min={campos.data_inicio || undefined}
                      aria-invalid={datasTrocadas || undefined}
                      aria-describedby={datasTrocadas ? 'br-fim-erro' : undefined}
                      onChange={(e) => { setDatasLidas(false); setSugeridas(false); setCampos((c) => ({ ...c, data_fim: e.target.value })); }}
                      className="mt-1 w-full border border-border bg-background px-3 py-2 text-sm text-foreground outline-none"
                    />
                  </div>
                </div>
                {sugeridas && (
                  <p className="ns-ajuda">A mensagem não diz datas: sugerimos de amanhã a duas semanas. Muda-as se não for isto.</p>
                )}
                {datasTrocadas && (
                  <div id="br-fim-erro" role="alert" className="mt-1.5 text-xs text-perigo-texto">
                    {`Acaba a ${formatDataCurta(campos.data_fim)}, antes de começar. Escolhe outra data de fim.`}
                    {fimProposto && (
                      <button
                        type="button"
                        onClick={() => setCampos((c) => ({ ...c, data_fim: fimProposto }))}
                        className="ml-2 text-foreground underline underline-offset-2 hover:text-primary"
                      >
                        {`Manter ${duracao + 1} ${duracao === 0 ? 'dia' : 'dias'}: acaba a ${formatDataCurta(fimProposto)}`}
                      </button>
                    )}
                  </div>
                )}
              </div>

              <div>
                <p className="ns-rotulo">Lojas</p>
                {!lojasLidas ? (
                  <div className="loading-skeleton mt-1.5 h-16 rounded-md" role="status" aria-label="A ler as lojas" />
                ) : lojas.length === 0 ? (
                  <p className="mt-1 text-xs text-muted-foreground">
                    Ainda não há lojas nesta conta. Só nas redes sociais, a campanha sai sem elas; os painéis precisam delas.
                  </p>
                ) : (
                  <div className="mt-1.5 max-h-48 space-y-2 overflow-y-auto">
                    {lojas.map((l) => (
                      <label
                        key={l.id}
                        htmlFor={`br-loja-${l.id}`}
                        className="flex cursor-pointer items-center gap-2.5 rounded-md border border-border px-3 py-2.5 text-sm text-foreground"
                      >
                        <input
                          id={`br-loja-${l.id}`}
                          type="checkbox"
                          checked={escolhidas.includes(l.id)}
                          onChange={() => setEscolhidas((e) => alternar(e, l.id))}
                          className="accent-primary"
                        />
                        <span className="min-w-0 flex-1 truncate">{l.name}</span>
                        {l.city && <span className="text-xs text-muted-foreground">{l.city}</span>}
                      </label>
                    ))}
                  </div>
                )}
              </div>

              {!campanha && fotos && fotos.length > 0 && (
                <div>
                  <p className="ns-rotulo">Fotografia</p>
                  <div role="radiogroup" aria-label="Fotografia" className="mt-1.5 flex flex-wrap gap-2">
                    {[null].concat(fotos.slice(0, 11)).map((f) => {
                      const marcada = (fotoEscolhida ? fotoEscolhida.id : null) === (f ? f.id : null);
                      return (
                        <button
                          key={f ? f.id : 'nenhuma'}
                          type="button"
                          role="radio"
                          aria-checked={marcada}
                          onClick={() => setFotoId(f ? f.id : null)}
                          title={f ? f.original_name : 'Sem fotografia'}
                          className={`ns-escolha relative flex h-16 w-16 items-center justify-center overflow-hidden rounded-md border text-center text-[11px] ${marcada ? 'border-primary ring-1 ring-primary' : 'border-border text-muted-foreground'}`}
                        >
                          {f ? <ImagemOuAviso src={f.url} alt={f.original_name} className="h-full w-full object-cover" /> : 'Sem'}
                        </button>
                      );
                    })}
                  </div>
                  <p className="ns-ajuda">
                    {fotoId === undefined && fotoAuto ? 'Escolhemos esta porque o nome dela bate com a mensagem. Troca-a se não for a do produto.'
                      : fotoId === undefined ? 'Nenhuma fotografia da marca parece ser deste tema: escolhe a do produto, ou fica sem.'
                      : fotoEscolhida ? 'Escolheste esta fotografia.' : 'Sem fotografia: as imagens usam só as cores da marca.'}
                  </p>
                </div>
              )}
            </div>
          )}
        </div>

        {/* O nome, a página e o objectivo têm bons valores por omissão (28 Set):
            ficam aqui, para quem os quiser mudar. */}
        <details className="rounded-md border border-border px-3 py-2">
          <summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
            Mais opções{titulo ? ` · nome: ${titulo}` : ''}
          </summary>
          <div className="mt-3 space-y-4">
            <div>
              <label htmlFor="br-titulo" className="ns-rotulo">Nome da campanha</label>
              <input
                id="br-titulo"
                value={campos.titulo}
                maxLength={255}
                onChange={(e) => setCampos((c) => ({ ...c, titulo: e.target.value }))}
                placeholder={tituloDaMensagem(campos.mensagem) || 'Ex.: Bolo-rei de Natal'}
                className="mt-1 w-full border border-border bg-background px-3 py-2 text-sm text-foreground outline-none"
              />
              <p className="ns-ajuda">Só para ti: não aparece nos anúncios. Vazio, usamos o início da mensagem.</p>
            </div>

            <div>
              <label htmlFor="br-link" className="ns-rotulo">Página do produto</label>
              <input
                id="br-link"
                type="url"
                value={campos.link_produto}
                maxLength={500}
                onChange={(e) => setCampos((c) => ({ ...c, link_produto: e.target.value }))}
                placeholder="https://"
                className="mt-1 w-full border border-border bg-background px-3 py-2 text-sm text-foreground outline-none"
              />
              <p className="ns-ajuda">
                Para onde o anúncio encaminha. A IA não abre a página: o que ela deve saber do
                produto escreve-se na mensagem.
              </p>
            </div>

            <div>
              <p className="ns-rotulo">Para quê</p>
              {objectivoSugerido && campos.objectivo && (
                <p className="mt-0.5 text-xs text-muted-foreground">Escolhemos pela mensagem. Muda se não for isto.</p>
              )}
              <div className="mt-1 flex flex-wrap gap-2">
                {Object.entries(OBJECTIVOS).map(([chave, rotulo]) => (
                  <button
                    key={chave}
                    type="button"
                    aria-pressed={campos.objectivo === chave}
                    onClick={() => { setObjectivoSugerido(false); setCampos((c) => ({ ...c, objectivo: c.objectivo === chave ? '' : chave })); }}
                    className="ns-chip"
                  >
                    {rotulo}
                  </button>
                ))}
              </div>
            </div>
          </div>
        </details>

        {erro && <p className="text-sm text-perigo-texto" role="alert">{erro}</p>}
        {/* Uma campanha no ar ou terminada já não muda (o servidor recusa):
            diz-se antes de se escrever, e não depois (revisor, 28 Set). */}
        {soLeitura && (
          <p className="ns-ajuda">
            {campanha.estado === 'ativa'
              ? 'Esta campanha já está no ar: o briefing não muda. Para outra versão, repete a campanha.'
              : 'Esta campanha terminou: o briefing fica como estava.'}
          </p>
        )}

        <div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
          {/* Porque é que o botão está desligado, à vista (cliente simulado, 5 Out). */}
          {!soLeitura && !ocupadoTudo && (!titulo || datasTrocadas || !campos.canais.length) && (
            <p className="mr-auto text-xs text-muted-foreground">
              {!titulo ? 'Escreve o que queres comunicar.'
                : !campos.canais.length ? 'Escolhe pelo menos um sítio onde sai, em «Mudar».'
                : 'As datas estão trocadas: corrige-as em «Mudar».'}
            </p>
          )}
          <button type="button" onClick={onFechar} className="ns-btn ns-btn-fantasma">
            {/* Numa repetição, sair sem guardar apaga a cópia (agentes, 20 Out): diz-se no botão. */}
            {soLeitura ? 'Voltar' : repetida ? 'Desistir da repetição' : 'Cancelar'}
          </button>
          <button
            type="submit"
            disabled={soLeitura || ocupadoTudo || !titulo || datasTrocadas || !campos.canais.length}
            className="bg-primary px-4 py-2 text-sm text-primary-foreground outline-none hover:bg-primary/90"
          >
            {aGuardar ? 'A guardar…' : campanha ? 'Guardar' : 'Criar e escrever os anúncios'}
          </button>
        </div>
      </form>
    </div>
  );
}

/**
 * O texto de um criativo, seja qual for o canal.
 *
 * Cada canal tem forma propria -- legenda, blocos, pontos de conversa -- e a
 * gaveta so quer mostrar o que la esta escrito. A forma completa fica no JSON
 * e chega ao ecra de aprovacao, que ainda nao existe.
 */
/**
 * O que quem aprova tem de confirmar neste criativo, como o servidor o pede
 * (api/campanhas.js, problemaDaAprovacao): uma data depois do fim, uma oferta
 * que mudou, promessas sem origem. Devolve os campos a mandar ao aprovar.
 */
function confirmacoesDoCriativo(cr) {
  if (cr.recusa) return {};
  return {
    ...(cr.datas_depois_do_fim && cr.datas_depois_do_fim.length ? { datas_certas: true } : {}),
    ...(cr.desactualizado ? { continua_certo: true } : {}),
    ...(cr.por_confirmar && cr.por_confirmar.promessas && cr.por_confirmar.promessas.length ? { promessas_certas: true } : {}),
    ...(cr.por_confirmar && cr.por_confirmar.avisos && cr.por_confirmar.avisos.length ? { avisos_certos: true } : {}),
  };
}

function textoDoCriativo(criativo) {
  const c = criativo.conteudo || {};
  if (criativo.canal === 'social') return [c.legenda, (c.hashtags || []).map((h) => `#${h}`).join(' ')].filter(Boolean).join('\n\n');
  if (criativo.canal === 'dooh') return (c.blocos || []).map((b) => b.texto).filter(Boolean).join('\n');
  return [...(c.pontos || []), c.cta].filter(Boolean).join('\n');
}

/**
 * O cartaz DOOH desenhado, a cada medida onde esta campanha vai aparecer.
 *
 * Sem isto um criativo DOOH aprovado era uma lista de frases: quem aprovava
 * nunca via se "FIM DE STOCK" cabia numa faixa de 144 px de altura. As medidas
 * vêm do servidor — da rede, se o plano der spots, e dos painéis nas lojas
 * escolhidas —, e um painel sem medida registada diz-se pelo número de série.
 */
/**
 * A imagem de um painel, com o que a composição avisa (28 Set): a letra que
 * fica pequena de mais a esta medida, o texto legal que não coube. Vai-se
 * buscar com fetch, e não com <img src>, para ler o cabeçalho X-Avisos.
 */
// `descarregar`: o nome do ficheiro, numa peça aprovada (agentes, 20 Out: só havia miniaturas, sem
// as ampliar nem as levar).
function ImagemDoPainel({ src, alt, largura, altura, classe = 'max-h-64', semImagem = 'A imagem ainda não está pronta.', descarregar = null }) {
  const [url, setUrl] = React.useState(null);
  const [avisos, setAvisos] = React.useState([]);
  // Sem imagem (404, ou a rota ainda a chegar): uma caixa discreta, e não o
  // ícone de imagem partida (28 Set).
  const [falhou, setFalhou] = React.useState(false);
  React.useEffect(() => {
    let vivo = true;
    let objecto = null;
    setFalhou(false);
    setUrl(null);
    fetch(src, { credentials: 'same-origin' })
      .then(async (r) => {
        if (!r.ok) throw new Error(String(r.status));
        let a = [];
        try { a = JSON.parse(decodeURIComponent(r.headers.get('X-Avisos') || '%5B%5D')); } catch { a = []; }
        objecto = URL.createObjectURL(await r.blob());
        if (vivo) { setUrl(objecto); setAvisos(a); }
      })
      .catch(() => { if (vivo) setFalhou(true); });
    return () => { vivo = false; if (objecto) URL.revokeObjectURL(objecto); };
  }, [src]);
  if (falhou) {
    return (
      <div className={`flex max-w-full items-center justify-center rounded-md border border-dashed border-border px-3 text-center text-xs text-muted-foreground ${classe}`} style={{ aspectRatio: `${largura} / ${altura}`, height: Math.min(256, altura) }}>
        {semImagem}
      </div>
    );
  }
  return (
    // Num só bloco: numa grelha, os avisos soltos ocupavam células (revisor, 28 Set).
    <div className="min-w-0 space-y-1">
      {url
        ? <img src={url} alt={alt} width={largura} height={altura} className={`block h-auto w-auto max-w-full border border-border bg-secondary ${classe}`} />
        // Leva uns segundos a desenhar no servidor: a caixa diz o que está a acontecer, senão
        // parecia um painel vazio (gravação dos vídeos de onboarding, 8 Out).
        : (
          <div role="status" className={`loading-skeleton flex max-w-full items-center justify-center px-2 text-center text-xs text-muted-foreground ${classe}`} style={{ aspectRatio: `${largura} / ${altura}`, height: Math.min(256, altura) }}>
            {largura / altura < 0.6 ? 'A desenhar…' : 'A desenhar a imagem para este painel…'}
          </div>
        )}
      {url && descarregar && (
        <p className="flex flex-wrap gap-3 text-xs">
          <a href={url} target="_blank" rel="noopener noreferrer" className="text-muted-foreground underline underline-offset-2 hover:text-foreground">Ver em tamanho real</a>
          <a href={url} download={`${String(descarregar).replace(/[\\/:*?"<>|]+/g, '-')}.png`} className="text-muted-foreground underline underline-offset-2 hover:text-foreground">Descarregar</a>
        </p>
      )}
      {avisos.map((a) => <p key={a} className="text-xs text-accent">{a}</p>)}
    </div>
  );
}

function PreviewDoohCriativo({ slug, campanhaId, criativo, lojasKey, porRever = false, titulo = '' }) {
  const [dados, setDados] = React.useState(null);

  React.useEffect(() => {
    let vivo = true;
    setDados(null);
    api.get(`/api/${slug}/campanhas/${campanhaId}/formatos-dooh`)
      .then((d) => { if (vivo) setDados(d); })
      .catch(() => { if (vivo) setDados({ formatos: [], sem_medida: [] }); });
    return () => { vivo = false; };
  }, [slug, campanhaId, lojasKey]);

  if (!dados) return <AProcessar className="mt-2" texto="A ver os painéis desta campanha" />;

  return (
    <div className="mt-3 space-y-3">
      {dados.formatos.length === 0 && (
        <p className="text-xs leading-relaxed text-muted-foreground">
          Não vai para painel nenhum: as lojas escolhidas não têm painéis com medida registada, e a rede
          Brand Digital não está escolhida no briefing (ou não vem com o teu plano). Para a campanha passar
          nos painéis, escolhe a rede no briefing, ou fala com a NetScreen sobre um painel na loja.
        </p>
      )}
      {dados.formatos.map((f) => (
        <figure key={`${f.largura}x${f.altura}`} className="space-y-1">
          <ImagemDoPainel
            src={`/api/${slug}/campanhas/${campanhaId}/criativos/${criativo.id}/dooh.png?largura=${f.largura}&altura=${f.altura}&v=${lojasKey.length}-${lojasKey.slice(-12)}`}
            alt={`Imagem para o painel, a ${f.largura} por ${f.altura} píxeis: ${textoDoCriativo(criativo).split('\n').join('. ')}`}
            largura={f.largura}
            altura={f.altura}
            descarregar={criativo.aprovado && !porRever ? `${titulo} - ${f.nome} ${f.largura}x${f.altura}` : null}
          />
          <figcaption className="text-xs text-muted-foreground">
            {f.nome} · {f.largura}×{f.altura}
            {explicarDestino(f.nome) && <span className="block">{explicarDestino(f.nome)}</span>}
            {f.paineis && ` · ${f.paineis.length} ${f.paineis.length === 1 ? 'painel' : 'painéis'}`}
            {/* Em que loja (rondas 8 e 9 dos virgens: com duas lojas, não se sabia qual painel era qual). */}
            {f.paineis && f.paineis.some((x) => x.loja) && ` · ${[...new Set(f.paineis.map((x) => x.loja).filter(Boolean))].join(', ')}`}
          </figcaption>
        </figure>
      ))}
      {dados.sem_medida.length > 0 && (
        <p className="text-xs leading-relaxed text-muted-foreground">
          Sem medida registada, e por isso sem pré-visualização: {dados.sem_medida.join(', ')}.
        </p>
      )}
      {/* Aprovado e sem revisão pendente, o cartaz está fixado com a fotografia: apagá-la depois não o muda (revisor, 6 Out). */}
      {dados.fotografia && dados.fotografia.escolhida && dados.fotografia.legivel === false && (!criativo.aprovado || porRever) && (
        <p className="text-xs text-accent" role="status">
          {dados.fotografia.problema
            ? `${dados.fotografia.problema} A imagem para os painéis aparece sem ela, e não se aprova assim.`
            : 'A fotografia escolhida não se conseguiu ler agora, e a imagem para os painéis aparece sem ela. Não se aprova assim: tenta daqui a pouco, ou troca-a.'}
        </p>
      )}
      {dados.formatos.length > 0 && (
        <p className="text-xs text-muted-foreground">
          {criativo.aprovado && porRever
            ? 'Assim é como vai sair: aprova-o em «Os cartazes precisam de ser revistos», em cima.'
            : criativo.aprovado
            ? 'Aprovado: este é o ficheiro que vai para os painéis, e já não muda se mudares as cores ou o logótipo.'
            : 'Com as cores e o logótipo confirmados em A minha marca; sem eles, cores neutras.'}
        </p>
      )}
    </div>
  );
}

/**
 * A fotografia da campanha: o fundo dos cartazes DOOH.
 *
 * Escolhe-se da Biblioteca, ou carrega-se aqui (sem OCR: uma fotografia não
 * tem nada a ensinar ao agente, e lê-la punha-a à espera de aprovação).
 * Critério 2 da avaliação de workload de 23 Set.
 */
const FOTO_MIME = /^image\/(png|jpeg|webp)$/;

// As mesmas regras do servidor (lib/cartaz.js): PNG, JPEG ou WebP, até 10 MB.
// Se divergissem, o ecrã mostrava uma fotografia que o cartaz não usava.
function ehFotografia(m) {
  return Boolean(m && m.kind === 'image' && FOTO_MIME.test(m.detected_mime || '')
    && Number(m.size_bytes || 0) <= 10 * 1024 * 1024);
}

// Abaixo disto, a fotografia fica ampliada mais de duas vezes no post (1080 px) e desfocada por trás
// do texto (agentes, 20 Out: uma de 210×64 entrou num painel de 256×1024 e o texto deixou de se ler).
const FOTO_MINIMA = 540;

/** A medida de uma imagem no browser, antes de a usar; null se não abre. */
function medidaDaImagem(src) {
  return new Promise((ok) => {
    const i = new Image();
    i.onload = () => ok({ w: i.naturalWidth, h: i.naturalHeight });
    i.onerror = () => ok(null);
    i.src = src;
  });
}

function FotografiaCampanha({ podeGerir = false, slug, campanha, materiais, onMudou }) {
  const [aberto, setAberto] = React.useState(false);
  const [biblioteca, setBiblioteca] = React.useState(null);
  const [ocupado, setOcupado] = React.useState(false);
  const [aviso, setAviso] = React.useState(null);
  // Uma fotografia pequena à espera de «Usar mesmo assim»: `{ medida, id }` (da biblioteca) ou `{ medida, ficheiro }`.
  const [pequena, setPequena] = React.useState(null);
  const campo = React.useRef(null);

  // Mede antes de usar: uma pequena pede confirmação, em vez de o aviso «Fraco» só chegar depois.
  async function escolher(m) {
    if (ocupado) return;
    // Medir leva um momento: o botão fica ocupado, e um segundo clique não guarda duas vezes (revisor, 20 Out).
    setOcupado(true);
    const medida = m.url ? await medidaDaImagem(m.url).finally(() => setOcupado(false)) : (setOcupado(false), null);
    if (medida && Math.min(medida.w, medida.h) < FOTO_MINIMA) return setPequena({ medida, id: m.id });
    guardar(m.id);
  }
  async function aoFicheiro(ficheiro) {
    if (!ficheiro) return;
    const local = URL.createObjectURL(ficheiro);
    const medida = await medidaDaImagem(local);
    URL.revokeObjectURL(local);
    if (medida && Math.min(medida.w, medida.h) < FOTO_MINIMA) {
      if (campo.current) campo.current.value = '';
      return setPequena({ medida, ficheiro });
    }
    carregar(ficheiro);
  }

  const noAr = campanha.estado === 'ativa' || campanha.estado === 'encerrada';
  const aprovada = noAr || campanha.estado === 'aprovada';
  const lista = materiais || [];
  const actual = lista.find(ehFotografia) || null;

  React.useEffect(() => {
    setPequena(null);
    if (!aberto) return;
    setBiblioteca(null);
    // As da marca e as desta campanha; as que são só de outra campanha ficam
    // lá (24 Set). Os logótipos vão para o fim: raramente são o fundo.
    // As de mais de 10 MB aparecem, apagadas e com a razão: sumiam sem aviso,
    // e quem as tinha carregado na marca ia procurá-las (cliente simulado, 24 Set).
    api.get(`/api/${slug}/materiais`)
      .then((l) => setBiblioteca(l
        .filter((m) => m.kind === 'image' && FOTO_MIME.test(m.detected_mime || '')
          && (!m.campanha_id || m.campanha_id === campanha.id))
        .sort((a, b) => (a.papel === 'logotipo') - (b.papel === 'logotipo'))))
      .catch((e) => { setBiblioteca([]); setAviso(e.message || 'Não deu para ler os ficheiros da marca.'); });
  }, [aberto, slug, campanha.id]);

  // A fotografia vai à frente; os outros materiais da campanha ficam como estavam.
  async function guardar(idFoto, foco) {
    setOcupado(true);
    setAviso(null);
    try {
      // Com a legenda: o Agente PM lê-a, e mandar só o id apagava-a.
      const outros = lista.filter((m) => !ehFotografia(m)).map((m) => ({ material_id: m.id, legenda: m.legenda || null }));
      // O lado do produto fica com a fotografia; uma fotografia nova começa ao centro.
      const focoFoto = foco || (actual && actual.id === idFoto ? actual.foco : 'centro') || 'centro';
      const r = await api.put(`/api/${slug}/campanhas/${campanha.id}/materiais`, {
        materiais: (idFoto ? [{ material_id: idFoto, legenda: null, foco: focoFoto }] : []).concat(outros)
          .map((m, i) => ({ ...m, ordem: i })),
      });
      setAberto(false);
      if (r && r.estado && r.estado !== campanha.estado) {
        setAviso('A campanha estava aprovada e voltou à revisão: a fotografia mudou.');
      }
      onMudou();
    } catch (e) {
      setAviso(e.message || 'Não foi possível guardar a fotografia.');
    } finally {
      setOcupado(false);
    }
  }

  async function carregar(ficheiro) {
    if (!ficheiro) return;
    setOcupado(true);
    setAviso(null);
    try {
      // Carregada aqui, é desta campanha (24 Set): não aparece nas outras nem
      // ensina nada à marca até alguém a dar à marca.
      const q = new URLSearchParams({ nome: ficheiro.name, ler: '0', campanha: campanha.id });
      const r = await fetch(`/api/${slug}/materiais?${q}`, {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': ficheiro.type || 'application/octet-stream' },
        body: ficheiro,
      });
      const corpo = await r.json().catch(() => null);
      if (!r.ok) throw new Error((corpo && corpo.error) || `Erro ${r.status}`);
      const novo = corpo.material || corpo;
      if (!ehFotografia(novo)) throw new Error('Isso não é uma fotografia em PNG, JPG ou WebP.');
      await guardar(novo.id);
      // O mesmo ficheiro não entra duas vezes: diz-se, em vez de parecer novo.
      if (corpo.existia) {
        setAviso(novo.campanha_id
          ? 'Já tinhas esta fotografia nesta campanha: usámos a que existia.'
          : 'Já tinhas esta fotografia nos ficheiros da marca: usámos essa, e continua da marca.');
      }
    } catch (e) {
      setAviso(e.message);
      setOcupado(false);
    } finally {
      if (campo.current) campo.current.value = '';
    }
  }

  return (
    <div className="space-y-3">
      {actual && actual.url ? (
        <ImagemOuAviso src={actual.url} alt={actual.original_name} className="max-h-40 w-full border border-border object-cover" />
      ) : !actual ? (
        <p className="text-sm text-muted-foreground">
          {(campanha.canais || []).includes('dooh')
            ? 'Sem fotografia: as imagens para os painéis e o post usam só as cores da marca.'
            : 'Sem fotografia: a imagem do post usa só as cores da marca.'}
        </p>
      ) : null}
      {/* Apagada da Biblioteca: aprovada, a campanha mantém a que se aprovou (ensaio de 3 Out);
          por aprovar, troca-se ou tira-se, que é o que o vídeo pede (revisor, 5 Out). */}
      {actual && actual.apagado && (
        <p className={`text-xs ${actual.url || aprovada ? 'text-muted-foreground' : 'text-accent'}`}>
          {actual.url
            ? `«${actual.original_name}» foi apagada da Biblioteca e carregada outra vez: a campanha continua com a mesma imagem.`
            : aprovada
              ? `«${actual.original_name}» foi apagada da Biblioteca. Esta campanha mantém a imagem com que foi aprovada.`
              : `«${actual.original_name}» foi apagada da Biblioteca. Troca-a ou tira-a.`}
        </p>
      )}
      {/* Onde está o produto: cada formato corta a fotografia para esse lado
          (reavaliação do Codex -- uma faixa larga cortada ao centro podia tirar o produto). */}
      {podeGerir && actual && !noAr && !(actual.apagado && !actual.url) && (
        <div>
          <p className="text-xs text-muted-foreground">Onde está o produto na fotografia?</p>
          <div className="mt-1.5 flex flex-wrap gap-1.5" role="radiogroup" aria-label="Onde está o produto">
            {[['centro', 'Ao centro'], ['esquerda', 'À esquerda'], ['direita', 'À direita'], ['topo', 'Em cima'], ['baixo', 'Em baixo']].map(([valor, rotulo]) => (
              <button
                key={valor}
                type="button"
                role="radio"
                aria-checked={(actual.foco || 'centro') === valor}
                disabled={ocupado}
                onClick={() => guardar(actual.id, valor)}
                className="ns-chip"
              >
                {rotulo}
              </button>
            ))}
          </div>
        </div>
      )}
      {!podeGerir ? null : noAr ? (
        <p className="text-xs text-muted-foreground">Depois de pedires a publicação, ou numa campanha encerrada, a fotografia não muda.</p>
      ) : (
        <div className="flex flex-wrap gap-2">
          <button type="button" disabled={ocupado} onClick={() => setAberto(true)} className="border border-border px-3 py-1.5 text-sm hover:bg-secondary">
            {actual ? 'Trocar' : 'Escolher uma fotografia'}
          </button>
          {actual && (
            <button type="button" disabled={ocupado} onClick={() => guardar(null)} className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground">
              Tirar
            </button>
          )}
        </div>
      )}
      {aviso && <p className="text-xs text-accent" role="status">{aviso}</p>}

      <Modal
        aberto={aberto}
        onFechar={() => setAberto(false)}
        titulo="A fotografia da campanha"
        acoes={
          <button type="button" onClick={() => setAberto(false)} className="border border-border px-4 py-2 text-sm hover:bg-secondary">Fechar</button>
        }
      >
        <div className="space-y-4">
          <p className="text-sm text-muted-foreground">
            Entra por baixo do texto, cortada à medida de cada painel.
          </p>
          {pequena && (
            <div className="border-l-2 border-accent bg-[hsl(var(--accent)/0.08)] px-3 py-2 text-xs leading-relaxed text-foreground" role="alert">
              <p className="font-medium">{`Esta fotografia é pequena: tem ${pequena.medida.w}×${pequena.medida.h} px.`}</p>
              <p className="mt-0.5">Nos painéis e no post fica ampliada e desfocada por trás do texto, que pode deixar de se ler. Uma com pelo menos {FOTO_MINIMA} px de cada lado fica nítida.</p>
              <div className="mt-2 flex flex-wrap gap-2">
                <button type="button" onClick={() => setPequena(null)} className="bg-primary px-3 py-1.5 text-xs text-primary-foreground hover:bg-primary/90">Escolher outra</button>
                <button
                  type="button"
                  disabled={ocupado}
                  onClick={() => { const p = pequena; setPequena(null); if (p.ficheiro) carregar(p.ficheiro); else guardar(p.id); }}
                  className="border border-border px-3 py-1.5 text-xs hover:bg-secondary disabled:opacity-50"
                >
                  Usar mesmo assim
                </button>
              </div>
            </div>
          )}
          {biblioteca === null ? (
            <div className="loading-skeleton h-24" />
          ) : biblioteca.length === 0 ? (
            <p className="text-xs text-muted-foreground">Ainda não há fotografias nos ficheiros da marca.</p>
          ) : (
            <div className="grid grid-cols-3 gap-2">
              {biblioteca.map((m) => {
                const serve = ehFotografia(m);
                return (
                  <button
                    key={m.id}
                    type="button"
                    disabled={ocupado || !serve}
                    onClick={() => escolher(m)}
                    title={serve ? m.original_name : `${m.original_name}: tem mais de 10 MB, grande de mais para os painéis`}
                    className={`overflow-hidden border text-left ${actual && actual.id === m.id ? 'border-primary' : 'border-border hover:border-foreground/40'} ${serve ? '' : 'cursor-not-allowed opacity-40'}`}
                  >
                    <ImagemOuAviso src={m.url} alt={m.original_name} className="h-24 w-full object-cover" />
                    <span className="block truncate px-1.5 py-1 text-[11px] text-muted-foreground">
                      {serve ? m.original_name : 'Mais de 10 MB'}
                    </span>
                  </button>
                );
              })}
            </div>
          )}
          <div>
            <p className="mb-1 text-xs text-muted-foreground">
              Ou carrega uma nova. Fica só nesta campanha; se a quiseres noutras, dá-a à marca em Ficheiros e fontes.
            </p>
            <ZonaFicheiro
              id="campanha-foto"
              campoRef={campo}
              aceita="image/png,image/jpeg,image/webp"
              desactivado={ocupado}
              aEnviar={ocupado ? 'a fotografia' : null}
              onFicheiro={aoFicheiro}
              dica={`PNG, JPG ou WebP, até 10 MB, com pelo menos ${FOTO_MINIMA} px de cada lado.`}
            />
          </div>
        </div>
      </Modal>
    </div>
  );
}

/**
 * Corrigir o texto de um criativo à mão, no formato do canal. Guarda uma
 * versão nova só deste canal: os outros ficam como estão, aprovados ou não.
 */
const ROTULO_BLOCO = { destaque: 'Destaque', titulo: 'Título', sub: 'Subtítulo', cta: 'Chamada' };
const CAMPO_EDITOR = 'w-full border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:border-primary';

// `comPaineis`: a campanha tem painéis, e a imagem do post leva o texto deles
// (fonteDaImagemDoPost, api/campanhas.js); os campos "Na imagem" não contam.
/**
 * A razão de um texto que não pôde sair, sem o conselho que vem colado («Escreve outra vez: não
 * precisas de mudar nada.», lib/agentePm.js): o conselho depende de onde se lê, e repetia-se em
 * quatro sítios, também dentro da correcção à mão (agentes, 20 Out).
 */
function razaoDaRecusa(recusa) {
  return String(recusa || '').replace(/\s*Escreve outra vez: não precisas de mudar nada\.?\s*$/, '').trim();
}

function EditorCriativo({ criativo, ocupado, onGuardar, onCancelar, comPaineis = false }) {
  const c = criativo.conteudo || {};
  const [legenda, setLegenda] = React.useState(c.legenda || '');
  const [hashtags, setHashtags] = React.useState((c.hashtags || []).join(' '));
  // Os quatro tipos de bloco sempre à vista, os que faltam vazios: um criativo recusado
  // sem blocos deixava o editor sem nada para preencher (cliente simulado, 5 Out).
  const [blocos, setBlocos] = React.useState(() => {
    const tem = (c.blocos || []).map((b) => ({ ...b }));
    return [...tem, ...['titulo', 'destaque', 'sub', 'cta'].filter((t) => !tem.some((b) => b.tipo === t)).map((tipo) => ({ tipo, texto: '' }))];
  });
  const [disclaimer, setDisclaimer] = React.useState(c.disclaimer || '');
  const [pontos, setPontos] = React.useState((c.pontos || []).join('\n'));
  const [cta, setCta] = React.useState(c.cta || '');
  // O texto por cima da fotografia do post (28 Set): sem painéis, é o da imagem.
  const [imgTitulo, setImgTitulo] = React.useState((c.imagem && c.imagem.titulo) || '');
  const [imgDestaque, setImgDestaque] = React.useState((c.imagem && c.imagem.destaque) || '');

  function conteudo() {
    if (criativo.canal === 'social') {
      return {
        legenda: legenda.trim(),
        hashtags: hashtags.split(/[\s,]+/).map((h) => h.replace(/^#/, '')).filter(Boolean),
        imagem: imgTitulo.trim() || imgDestaque.trim() ? { titulo: imgTitulo.trim() || null, destaque: imgDestaque.trim() || null } : null,
      };
    }
    if (criativo.canal === 'dooh') {
      return {
        blocos: blocos.map((b) => ({ tipo: b.tipo, texto: String(b.texto || '').trim() })).filter((b) => b.texto),
        disclaimer: disclaimer.trim() || null,
      };
    }
    return { pontos: pontos.split('\n').map((p) => p.trim()).filter(Boolean), cta: cta.trim() || null };
  }

  // A decisão pedida pelo servidor quando a correcção tira parte da oferta.
  const [retirada, setRetirada] = React.useState(null);
  const [erroAoGuardar, setErroAoGuardar] = React.useState(null);
  // Guardar outra vez com o aviso da oferta aberto não mudava nada à vista (teste de raiz, 9 Out): a página
  // vai até ao aviso, e junto aos botões diz-se o que falta.
  const avisoOferta = React.useRef(null);
  const tratar = (r) => {
    setRetirada(r && r.alteracao_oferta ? r : null);
    setErroAoGuardar(r && r.erro ? r.erro : null);
    if (r && r.alteracao_oferta) {
      setTimeout(() => { if (avisoOferta.current) avisoOferta.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 0);
    }
  };
  const guardar = async () => tratar(await onGuardar(conteudo()));
  const guardarMudandoAOferta = async () => tratar(await onGuardar(conteudo(), { muda_a_oferta: true }));

  const id = `editar-${criativo.id}`;
  // O que há para corrigir, à vista de quem corrige: a recusa e as provas que a
  // NetScreen não aceitou (cliente simulado, 5 Out).
  const recusadas = (criativo.excecoes || []).filter((x) => x.estado === 'recusada');
  const motivos = [
    // Aqui corrige-se à mão: «Escreve outra vez: não precisas de mudar nada» dizia o contrário (agentes, 20 Out).
    criativo.recusa ? `O texto anterior não pôde sair: ${razaoDaRecusa(criativo.recusa)} Escreve aqui o que deve dizer.` : null,
    ...recusadas.map((x) => {
      const prova = ((criativo.por_confirmar && criativo.por_confirmar.provas) || []).find((p) => p.tipo === x.tipo);
      return `A NetScreen não aceitou${x.nota ? ` («${x.nota}»)` : ''}.${prova ? ` ${prova.texto}` : ''}`;
    }),
  ].filter(Boolean);
  return (
    <div className="mt-2 space-y-3">
      {motivos.length > 0 && (
        <div className="border-l-2 border-accent bg-[hsl(var(--accent)/0.08)] px-3 py-2 text-xs leading-relaxed text-foreground">
          <p className="font-medium">O que corrigir</p>
          {motivos.map((m, i) => <p key={i} className="mt-0.5">{m}</p>)}
        </div>
      )}
      {erroAoGuardar && (
        <p className="border-l-2 border-[hsl(var(--offline))] bg-[hsl(var(--offline)/0.08)] px-3 py-2 text-xs leading-relaxed text-foreground" role="alert">
          {`Não ficou guardado. ${erroAoGuardar}`}
        </p>
      )}
      {criativo.canal === 'social' && (
        <>
          <label htmlFor={`${id}-legenda`} className="text-xs text-muted-foreground">Texto do post</label>
          <textarea id={`${id}-legenda`} rows={5} value={legenda} onChange={(e) => setLegenda(e.target.value)} className={CAMPO_EDITOR} />
          <label htmlFor={`${id}-hashtags`} className="text-xs text-muted-foreground">Hashtags, separadas por espaços</label>
          <input id={`${id}-hashtags`} value={hashtags} onChange={(e) => setHashtags(e.target.value)} className={CAMPO_EDITOR} />
          {/* Com painéis, a imagem leva o texto deles, e estes campos não contam: à
              vista, diziam uma coisa e a imagem mostrava outra (ensaio de 7 Out, P9).
              Só aparecem quando mandam, numa campanha sem painéis. */}
          {comPaineis ? (
            <p className="text-xs text-muted-foreground">O texto da imagem é o dos painéis: para o mudar, corrige o texto dos painéis.</p>
          ) : (
            <div className="grid gap-2 sm:grid-cols-2">
              <div>
                <label htmlFor={`${id}-img-titulo`} className="text-xs text-muted-foreground">Na imagem: o título</label>
                <input id={`${id}-img-titulo`} value={imgTitulo} onChange={(e) => setImgTitulo(e.target.value)} className={CAMPO_EDITOR} />
              </div>
              <div>
                <label htmlFor={`${id}-img-destaque`} className="text-xs text-muted-foreground">Na imagem: o preço ou o desconto</label>
                <input id={`${id}-img-destaque`} value={imgDestaque} onChange={(e) => setImgDestaque(e.target.value)} className={CAMPO_EDITOR} />
              </div>
            </div>
          )}
        </>
      )}
      {criativo.canal === 'dooh' && (
        <>
          {blocos.map((b, i) => (
            <div key={i}>
              <label htmlFor={`${id}-b${i}`} className="text-xs text-muted-foreground">{ROTULO_BLOCO[b.tipo] || b.tipo}</label>
              <input
                id={`${id}-b${i}`}
                value={b.texto || ''}
                onChange={(e) => setBlocos(blocos.map((x, j) => (j === i ? { ...x, texto: e.target.value } : x)))}
                className={CAMPO_EDITOR}
              />
            </div>
          ))}
          <label htmlFor={`${id}-disc`} className="text-xs text-muted-foreground">Texto legal (opcional)</label>
          <input id={`${id}-disc`} value={disclaimer} onChange={(e) => setDisclaimer(e.target.value)} className={CAMPO_EDITOR} />
          <p className="text-xs text-muted-foreground">Poucas palavras: quem passa pelo painel lê isto em três segundos.</p>
        </>
      )}
      {criativo.canal === 'pdv' && (
        <>
          <label htmlFor={`${id}-pontos`} className="text-xs text-muted-foreground">Pontos de conversa, um por linha</label>
          <textarea id={`${id}-pontos`} rows={5} value={pontos} onChange={(e) => setPontos(e.target.value)} className={CAMPO_EDITOR} />
          <label htmlFor={`${id}-cta`} className="text-xs text-muted-foreground">O que sugere no fim</label>
          <input id={`${id}-cta`} value={cta} onChange={(e) => setCta(e.target.value)} className={CAMPO_EDITOR} />
        </>
      )}
      <p className="text-xs text-muted-foreground">
        Guarda uma versão nova só deste canal, por aprovar. Os outros canais não mudam.
      </p>
      {/* A correcção muda uma condição essencial da oferta (revisão do Codex,
          24 Set): acrescentar, retirar, substituir ou inverter. Não se proíbe;
          decide-se uma vez, aqui, com o antes e o depois, e a aprovação não o
          pede outra vez. */}
      {retirada && (
        <div ref={avisoOferta} className="border-l-2 border-accent bg-[hsl(var(--accent)/0.08)] px-3 py-2">
          <p className="text-xs font-medium text-accent">Esta correcção muda a oferta</p>
          <dl className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs leading-relaxed">
            <dt className="text-muted-foreground">Antes</dt>
            <dd className="text-foreground">
              {retirada.alteracao_oferta.antes.length ? retirada.alteracao_oferta.antes.map((x) => `«${x}»`).join(', ') : 'nada'}
            </dd>
            <dt className="text-muted-foreground">Depois</dt>
            <dd className="text-foreground">
              {retirada.alteracao_oferta.depois.length ? retirada.alteracao_oferta.depois.map((x) => `«${x}»`).join(', ') : 'nada'}
            </dd>
          </dl>
          <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
            A loja vai ter de cumprir o que o texto novo diz. Se não é de propósito, volta a pôr o que estava.
          </p>
          <button
            type="button"
            disabled={ocupado}
            onClick={guardarMudandoAOferta}
            className="mt-1.5 bg-primary px-3 py-1.5 text-xs text-primary-foreground hover:bg-primary/90 disabled:bg-secondary disabled:text-muted-foreground"
          >
            Confirmar alteração da oferta
          </button>
        </div>
      )}
      <div className="flex flex-wrap gap-2">
        <button
          type="button"
          disabled={ocupado}
          onClick={guardar}
          className="bg-primary px-3 py-1.5 text-xs text-primary-foreground hover:bg-primary/90 disabled:bg-secondary disabled:text-muted-foreground"
        >
          Guardar a correcção
        </button>
        <button type="button" onClick={onCancelar} className="border border-border px-3 py-1.5 text-xs hover:bg-secondary">Cancelar</button>
        {retirada && (
          <p className="basis-full text-xs text-accent" role="status">Ainda não ficou guardada: confirma a alteração da oferta acima, ou volta a pôr o que estava.</p>
        )}
      </div>
    </div>
  );
}

/**
 * O que a aprovação da campanha pede, a partir do que o ecrã tem: o que impede
 * (bloqueios, com onde se corrige) e o que quem aprova tem de confirmar
 * (decisões, cada uma de um criativo). O servidor pergunta o mesmo
 * (api/campanhas.js, problemaDoConjunto) e é quem decide; isto só o diz antes.
 */
/** O briefing pede vídeo, e há painéis de onde ele parte. */
function videoPedido(campanha) {
  // Dos painéis, ou, sem eles, do post (V0, 2 Out): o mesmo que lib/origemDoVideo.js.
  const canais = (campanha && campanha.canais) || [];
  return Boolean(campanha && campanha.briefing && campanha.briefing.video === true && (canais.includes('dooh') || canais.includes('social')));
}

/** Segundos à portuguesa: 4,2 e não 4.2. */
const segundos = (t) => String(Math.round(Number(t) * 10) / 10).replace('.', ',');

/** O canal de que um storyboard partiu; um de antes do V0 não o diz, e é dos painéis. */
const canalDoMockup = (conteudo) => (conteudo && conteudo.mockup && conteudo.mockup.canal) || 'dooh';

// `semObrigatoria`: os formatos onde a frase obrigatória da marca não cabe
// inteira (o detalhe da campanha, parecer do Codex de 28 Set).
// `opcoes.semVideo`: o cliente escolheu publicar sem o vídeo por agora (P5, 28 Set);
// `opcoes.videoProblema`: o que o servidor disse do vídeo (não cabe, por exemplo).
// `opcoes.adiarPost`: o post espera a revisão da NetScreen, e os painéis saem já (F5, 2 Out).
function pedidosDaAprovacao(campanha, criativos, lojas, semObrigatoria = [], opcoes = {}) {
  const porCanal = Object.fromEntries((criativos || []).map((c) => [c.canal, c]));
  const bloqueios = [];
  const decisoes = [];
  // `canal`, `criativo` e `curto` (28 Set) servem o bloco "Antes de aprovar" e o
  // botão principal da página; o modal lê só `texto` e `onde`.
  if (!campanha.canais.length) bloqueios.push({ texto: 'A campanha não tem canais.', onde: 'briefing', curto: 'Falta escolher os canais' });
  // Sem datas o servidor recusa aprovar, e o botão dizia «Aprovar e publicar» com «Sem datas escolhidas» ao lado
  // (parceira virgem, 4 Nov). O Início já o dizia; agora o botão também.
  // O vídeo do Criar ainda por aprovar: a campanha espera por ele (4 Nov).
  if (opcoes.videoCriarPorAprovar) {
    bloqueios.push({ texto: opcoes.videoCriarPorAprovar === 'video_por_rever' ? 'O vídeo está pronto para rever: aprova-o em Criar vídeo, e depois a campanha.'
      : opcoes.videoCriarPorAprovar === 'preparado' ? 'O vídeo foi retirado da fila: pede-o outra vez em Criar vídeo, e depois aprova-se a campanha.'
      : ['recusado', 'interrompido'].includes(opcoes.videoCriarPorAprovar) ? 'O vídeo não foi criado: vê porquê em Criar vídeo e pede-o outra vez; depois aprova-se a campanha.'
      : opcoes.videoCriarPorAprovar === 'fila' ? 'O vídeo está na fila, à espera de vez. Podes ir escrevendo o post; a campanha aprova-se depois de aprovares o vídeo.'
      : 'O vídeo ainda está a ser criado. Podes ir escrevendo o post; a campanha aprova-se depois de aprovares o vídeo.', onde: 'criar', canal: 'video', curto: 'Falta aprovar o vídeo' });
  }
  // O post sem o perfil da rede em Conta: a NetScreen não sabe onde o publicar (florista virgem, 4 Nov).
  const semPerfil = opcoes.redesSemPerfil || [];
  if (semPerfil.length && campanha.canais.includes('social') && !opcoes.adiarPost) {
    bloqueios.push({ texto: `Falta guardar ${semPerfil.length === 1 ? `o perfil do ${semPerfil[0]}` : `os perfis do ${semPerfil.join(' e do ')}`} em Conta: a NetScreen precisa de saber onde publicar o post.`, onde: 'conta', canal: 'social', curto: 'Falta o perfil da rede' });
  }
  if (!campanha.data_inicio || !campanha.data_fim) bloqueios.push({ texto: 'A campanha ainda não tem datas: escolhe quando começa e quando acaba.', onde: 'briefing', curto: 'Faltam as datas' });
  // O assistente na loja ainda não tem por onde ser entregue: a entrega ficava
  // "A publicar" para sempre (revisor, 28 Set). Tira-se no briefing.
  if (campanha.canais.includes('pdv')) {
    bloqueios.push({ texto: 'O assistente na loja ainda não está disponível: tira-o no briefing, em «Mudar», e a campanha sai sem ele.', onde: 'briefing', curto: 'Falta tirar o assistente na loja' });
  }
  // O vídeo pedido aprova-se com a campanha (28 Set), quando já está escrito;
  // se ainda não está, a campanha aprova-se e ele fica à espera.
  const comVideo = videoPedido(campanha) && porCanal.video && !opcoes.semVideo ? ['video'] : [];
  // As miniaturas do vídeo ainda não carregaram com a referência actual: a aprovação
  // esperaria por elas, e o servidor recusava (ensaio de 3 Out).
  if (comVideo.length && opcoes.videoPorVer && !porCanal.video.recusa && !opcoes.videoProblema) {
    bloqueios.push(opcoes.videoFalhou
      ? { texto: 'Não deu para carregar as cenas do vídeo: tenta carregar novamente, na secção do vídeo.', onde: 'video', canal: 'video', curto: 'Falta carregar o vídeo' }
      : { texto: 'A carregar as cenas do vídeo…', onde: 'video', canal: 'video', curto: 'A carregar o vídeo', tecnica: true });
  }
  if (comVideo.length && opcoes.videoProblema) {
    bloqueios.push({ texto: `Vídeo: ${opcoes.videoProblema}`, onde: 'video', canal: 'video', curto: 'Falta resolver o vídeo' });
  }
  for (const canal of [...campanha.canais.filter((c) => !(opcoes.adiarPost && c === 'social')), ...comVideo]) {
    // Com o artigo: "criativo do assistente na loja", e não "de assistente".
    const nome = canal === 'video' ? 'Vídeo' : (CANAIS_CAMPANHA.find((x) => x.chave === canal) || {}).rotulo || canal;
    const doCanal = { social: 'das redes sociais', dooh: 'dos painéis', pdv: 'do assistente na loja' }[canal] || canal;
    const cr = porCanal[canal];
    // O assistente tem o seu bloqueio (tirá-lo): não se pede para o escrever.
    if (canal === 'pdv') continue;
    // Nascida de um vídeo aprovado no Criar, o vídeo já é o criativo: falta só o que o acompanha, e
    // «Falta escrever os anúncios» soava a recomeçar do zero (cliente simulado Pulso, 3 Nov).
    if (!cr && campanha.briefing && campanha.briefing.criacao_video_id && canal !== 'video') {
      const social = canal === 'social';
      bloqueios.push({ texto: social ? 'Falta escrever o texto do post que acompanha o vídeo.' : 'Falta escrever o texto da imagem parada, que passa nos painéis quando o vídeo não pode passar; o vídeo não muda.', onde: 'criativos', canal, curto: social ? 'Falta o texto do post' : 'Falta a imagem dos painéis' });
      continue;
    }
    if (!cr) { bloqueios.push({ texto: `Falta escrever o anúncio ${doCanal}.`, onde: 'criativos', canal, curto: 'Falta escrever os anúncios' }); continue; }
    // O vídeo repete os painéis: corrigidos eles, o storyboard tem de se
    // escrever outra vez (revisor, 28 Set: o servidor recusava sem o ecrã dizer).
    const origemDoVideo = canal === 'video' && cr.conteudo && cr.conteudo.mockup ? porCanal[canalDoMockup(cr.conteudo)] : null;
    if (origemDoVideo && Number(origemDoVideo.versao) !== Number(cr.conteudo.mockup.versao)) {
      bloqueios.push({ texto: `Vídeo: foi escrito a partir de uma versão anterior ${canalDoMockup(cr.conteudo) === 'social' ? 'do post' : 'da imagem para os painéis'}. Se só mudou o texto legal, actualiza-o sem o escrever outra vez; senão, escreve-o outra vez.`, onde: 'video', canal, criativo: cr.id, curto: 'Falta actualizar o vídeo', actualizavel: true });
      continue;
    }
    if (cr.aprovado) continue;
    // Sem «recusado», que soava à decisão de alguém, e com as duas saídas; depois de uma versão
    // falhada, corrigir à mão vem primeiro (agentes, 20 Out). O `curto` conta-se no fim.
    if (cr.recusa) {
      bloqueios.push({
        // As cenas do vídeo não se corrigem à mão: têm as saídas da secção do vídeo.
        texto: canal === 'video' ? `${nome}: ${razaoDaRecusa(cr.recusa)} Escreve as cenas outra vez, ou publica sem o vídeo por agora.`
          : `${nome}: ${razaoDaRecusa(cr.recusa)} ${Number(cr.versao) > 1 ? 'Se escrever outra vez não resolver, corrige-o à mão.' : 'Escreve-o outra vez, ou corrige-o à mão.'}`,
        onde: canal === 'video' ? 'video' : 'criativos', canal, criativo: cr.id, recusa: true,
      });
      continue;
    }
    // O que exige prova (055): só sai com a excepção aceite pela NetScreen, desta
    // versão. O mesmo critério do servidor (problemaDaAprovacao).
    const aceites = new Set((cr.excecoes || []).filter((x) => x.estado === 'aceite').map((x) => x.tipo));
    for (const p of (cr.por_confirmar && cr.por_confirmar.provas) || []) {
      if (aceites.has(p.tipo)) continue;
      const x = (cr.excecoes || []).find((e) => e.tipo === p.tipo);
      const pedido = x ? x.estado : null;
      const estado = pedido === 'pedida' ? 'À espera da revisão da NetScreen. '
        : pedido === 'recusada' ? `A NetScreen não aceitou${x.nota ? ` («${x.nota}»)` : ''}. ` : '';
      bloqueios.push({
        texto: `${nome}: ${estado}${p.texto}`, onde: 'prova', canal, criativo: cr.id, tipo: p.tipo, aviso: p.texto, pedido, anterior: x || null,
        chave: `${cr.id}:prova:${p.tipo}`,
        curto: pedido === 'pedida' ? 'À espera da NetScreen' : 'Falta corrigir ou pedir revisão',
      });
    }
    // O que a plataforma exige ao destino (2 Out): corrige-se o texto; nenhuma
    // revisão da NetScreen o dispensa.
    for (const r of cr.requisitos || []) {
      bloqueios.push({ texto: `${nome}: ${r.texto}`, onde: 'criativos', canal, criativo: cr.id, curto: 'Falta corrigir o texto' });
    }
    if (cr.datas_fora === null) {
      bloqueios.push({ texto: `${nome}: o texto fala de datas, e a campanha ainda não tem as suas.`, onde: 'briefing', canal, criativo: cr.id, curto: 'Falta escolher as datas' });
      continue;
    }
    if (cr.datas_fora && cr.datas_fora.length) {
      bloqueios.push({ texto: `${nome}: o texto fala de ${[...new Set(cr.datas_fora)].map((x) => `"${x}"`).join(', ')}, fora das datas da campanha.`, onde: 'criativos', canal, criativo: cr.id, curto: 'Falta acertar as datas do texto' });
      continue;
    }
    if (cr.datas_depois_do_fim && cr.datas_depois_do_fim.length) {
      decisoes.push({ chave: `${cr.id}:datas`, criativo: cr.id, campo: 'datas_certas',
        texto: `${nome}: o texto fala de ${[...new Set(cr.datas_depois_do_fim)].map((x) => `"${x}"`).join(', ')}, depois do fim da campanha. É de propósito.` });
    }
    if (cr.desactualizado) {
      decisoes.push({ chave: `${cr.id}:oferta`, criativo: cr.id, campo: 'continua_certo',
        texto: `${nome}: o texto foi escrito antes de a oferta mudar, e continua certo.` });
    }
    if (cr.por_confirmar && cr.por_confirmar.promessas && cr.por_confirmar.promessas.length) {
      decisoes.push({ chave: `${cr.id}:promessas`, criativo: cr.id, campo: 'promessas_certas',
        texto: `${nome}: o texto promete ${[...new Set(cr.por_confirmar.promessas)].map((x) => `"${x}"`).join(', ')}, e é verdade tal como está escrito, sem excepções.` });
    }
    // O que a lei ou a marca pedem e só quem aprova decide (2 Out): o
    // tratamento, um recrutamento, um passatempo, uma redução de preço.
    if (cr.por_confirmar && cr.por_confirmar.avisos && cr.por_confirmar.avisos.length) {
      decisoes.push({ chave: `${cr.id}:avisos`, criativo: cr.id, campo: 'avisos_certos',
        texto: `${nome}: ${cr.por_confirmar.avisos.join(' ')}` });
    }
    // A nota do Agente PM que pede decisão confirma-se, e o servidor confere-o
    // (quinto parecer do Codex, 3 Out). A mesma chave da nota: a caixa fica nela.
    if (canal !== 'video' && cr.aviso_decisao) {
      decisoes.push({ chave: `${cr.id}:aviso`, criativo: cr.id, campo: 'nota_lida',
        texto: `${nome}: ${cr.aviso_cliente || (cr.conteudo && cr.conteudo.aviso) || ''}` });
    }
  }
  // Só os painéis e o assistente estão nas lojas: uma campanha só de redes sociais não precisa delas
  // (agentes, 20 Out). A mesma regra do servidor (precisaDeLojas, api/campanhas.js).
  const nRecusas = bloqueios.filter((b) => b.recusa).length;
  bloqueios.forEach((b) => { if (b.recusa) b.curto = nRecusas === 1 ? 'Falta refazer 1 texto' : `Falta refazer ${nRecusas} textos`; });
  if (!lojas.length && (campanha.canais.includes('pdv') || (campanha.canais.includes('dooh') && !(campanha.briefing && campanha.briefing.paineis === false)))) {
    bloqueios.push({ texto: 'Falta escolher as lojas onde estão os painéis.', onde: 'briefing', curto: 'Falta escolher as lojas' });
  }
  // A marca por confirmar (ou sem logótipo): as peças saem sem logótipo e com cores neutras. Não
  // impede, mas confirma-se à vista antes de aprovar (agentes, 20 Out: aprovou-se sem dar por isso).
  if (opcoes.marca && (opcoes.marca.porConfirmar || opcoes.marca.semLogotipo)) {
    // A chave leva a campanha: a confirmação de uma não pode vir marcada na seguinte (revisor, 20 Out).
    decisoes.unshift({ chave: `marca:${campanha.id}`, campo: 'marca_por_confirmar',
      texto: opcoes.marca.porConfirmar
        ? 'A marca ainda não está confirmada: as peças saem sem o logótipo e com cores neutras.'
        : 'A marca não tem logótipo escolhido: as peças saem sem ele.' });
  }
  // A imagem do post e a legenda com preços que não coincidem (parecer do Codex,
  // 28 Set). Calculado no servidor, pela mesma regra da aprovação: uma legenda
  // só com valores de condições ("compras acima de 30 €") não é contradição.
  const contra = opcoes.imagemDoPost && opcoes.imagemDoPost.contradicao;
  if (contra && porCanal.social) {
    const ondeCorrigir = opcoes.imagemDoPost.de === 'paineis' ? 'o texto dos painéis' : 'o texto da imagem';
    bloqueios.push({
      texto: `A imagem do post diz ${contra.imagem.map((p) => `${p} €`).join(' e ')}, e a legenda ${contra.legenda.map((p) => `${p} €`).join(' e ')}. Corrige a legenda, ou ${ondeCorrigir}.`,
      onde: 'criativos', canal: 'social', criativo: porCanal.social.id, curto: 'Falta acertar o preço do post',
    });
  }
  // As frases das condições dos painéis que a legenda do post não tem, tal como estão (9 Out): o post visto
  // sozinho tem de as ter. Calculado no servidor, que recusa pela mesma regra.
  const fora = opcoes.imagemDoPost && opcoes.imagemDoPost.condicoes_fora;
  if (fora && fora.length && porCanal.social && !opcoes.adiarPost) {
    bloqueios.push({
      texto: `A legenda do post não tem estas condições dos painéis, tal como estão: ${fora.map((x) => `«${x}»`).join(" ")}. Junta-as à legenda.`,
      // Com o botão que as junta: colá-las à mão era uma das voltas do ciclo de bloqueios (teste de raiz, 9 Out).
      onde: 'legenda', canal: 'social', criativo: porCanal.social.id, curto: 'Falta pôr as condições na legenda',
    });
  }
  if (semObrigatoria && semObrigatoria.length) {
    const quais = semObrigatoria.map((f) => `${f.nome ? `${f.nome}, ` : ''}${f.largura}×${f.altura}`).join('; ');
    bloqueios.push({
      // Diz-se onde se encurta: a frase é da marca, e o texto legal do anúncio não a muda (cliente simulado, 6 Out).
      texto: `A frase que a tua marca leva sempre não cabe inteira em: ${quais}. Encurta-a em A minha marca → Identidade → «Leva sempre» (fica em todas as campanhas), ou tira esse destino no briefing, em «Mudar».`,
      onde: 'briefing', curto: 'Falta caber a frase obrigatória',
    });
  }
  // O detalhe por reler depois de uma mudança (Codex, 9 Out, achado 5): a referência do que se viu
  // seria a de antes, e o servidor recusava. A ler, espera-se; se falhou, tenta-se outra vez.
  if (opcoes.releitura === 'a-ler') {
    bloqueios.push({ texto: 'A actualizar a campanha…', onde: 'reler', curto: 'A actualizar', tecnica: true });
  } else if (opcoes.releitura === 'falhou') {
    bloqueios.push({ texto: 'Não deu para actualizar a campanha depois da última mudança: o que está no ecrã pode não ser o que vai para o ar.', onde: 'reler', curto: 'Falta actualizar a campanha' });
  }
  // As condições da oferta que não cabem inteiras num painel (9 Out): bloqueia, e nenhuma confirmação
  // o dispensa. Ter um post não chega: o painel é visto sozinho.
  if (opcoes.semCondicoes && opcoes.semCondicoes.length) {
    const quais = opcoes.semCondicoes.map((f) => `${f.nome ? `${f.nome}, ` : ''}${f.largura}×${f.altura}`).join('; ');
    bloqueios.push({
      // Quanto cabe em cada um, para se saber até onde encurtar (teste de raiz, 9 Out).
      texto: `As condições da oferta não cabem inteiras ${opcoes.semCondicoes.length === 1 ? 'no painel' : 'nos painéis'} ${quais}. `
        + `${opcoes.semCondicoes.map((f) => (f.aviso || '').replace(/ Este painel não se pode aprovar assim\..*$/, '')).filter(Boolean).join(' ')} `
        + 'Encurta o texto legal dos painéis, ou tira esse destino no briefing, em «Mudar».',
      // Com o criativo: a acção é «Corrigir o texto», e não «Escrever este canal», que pedia ao
      // Agente PM outro texto em vez de abrir este para encurtar (verificação no browser, 9 Out).
      onde: 'criativos', canal: 'dooh', criativo: porCanal.dooh ? porCanal.dooh.id : undefined, curto: 'Falta caber as condições',
    });
  }
  // Uma faixa com a janela de produto e texto abaixo do mínimo que se lê (Codex, 3 Out): encurta-se o
  // texto dos painéis; não se tira informação sozinho. O servidor recusa a aprovação na mesma.
  for (const f of opcoes.faixasTextoPequeno || []) {
    bloqueios.push({
      texto: `Na faixa ${f.nome ? `${f.nome}, ` : ''}${f.largura}×${f.altura}: ${f.aviso || 'um texto fica pequeno de mais para se ler'}`,
      onde: 'criativos', canal: 'dooh', criativo: porCanal.dooh ? porCanal.dooh.id : undefined, curto: 'Falta encurtar o texto da faixa',
    });
  }
  return { porCanal, bloqueios, decisoes };
}

/**
 * Aprovar a campanha: o conjunto num sítio só (sessão de 24 Set, parecer do
 * Codex). Os canais com a versão que vai ser usada, as datas, as lojas, e só
 * as decisões que faltam. Um botão; o servidor aprova tudo ou nada.
 */
/**
 * O que se manda ao aprovar: o que se viu (`visto`, com a referência da
 * composição), as confirmações, e `publicar: true` (aprovar e pedir a
 * publicação num gesto, 28 Set). O modal e o clique directo usam o mesmo.
 */
function corpoDaAprovacao(campanha, criativos, detalhe, confirmacoes, semVideo = false, adiarPost = false) {
  const porCanal = Object.fromEntries((criativos || []).map((c) => [c.canal, c]));
  return {
    visto: {
      canais: campanha.canais,
      data_inicio: campanha.data_inicio || null,
      data_fim: campanha.data_fim || null,
      lojas: (detalhe.lojas || []).map((l) => l.id),
      criativos: Object.fromEntries(campanha.canais.map((c) => [c, porCanal[c] ? porCanal[c].id : null])),
      composicao: detalhe.ref_composicao,
      // O storyboard que se viu, quando o vídeo se aprova com a campanha (28 Set).
      // E as entradas que as miniaturas dele mostraram (Codex, 3 Out, #3).
      // A referência vem das miniaturas desenhadas (o StoryboardVideo), e não do
      // detalhe: este era lido antes de o storyboard existir (ensaio de 3 Out).
      ...(videoPedido(campanha) && porCanal.video && !semVideo ? { video: porCanal.video.id, entradas_video: detalhe.entradas_video_vistas || null } : {}),
    },
    confirmacoes: confirmacoes || {},
    publicar: true,
    // As peças prontas saem; o vídeo fica em "À tua espera" (P5, 28 Set).
    ...(semVideo ? { sem_video: true } : {}),
    // O post fica à espera da revisão da NetScreen (F5, 2 Out).
    ...(adiarPost ? { adiar: ['social'] } : {}),
  };
}

/** O estado da marca que a aprovação mostra (api/campanhas.js, o detalhe da campanha). */
function marcaDoDetalhe(detalhe) {
  return detalhe ? { porConfirmar: Boolean(detalhe.marca_por_confirmar), semLogotipo: Boolean(detalhe.sem_logotipo) } : null;
}

/** As confirmações para o servidor, a partir das decisões marcadas (na nota ou no modal). */
function confirmacoesDe(decisoes, marcadas) {
  const confirmacoes = {};
  // A da marca não é de nenhum texto: confirma-se no ecrã, e o servidor não a pede.
  for (const d of decisoes.filter((x) => x.criativo)) {
    if (marcadas[d.chave]) confirmacoes[d.criativo] = { ...(confirmacoes[d.criativo] || {}), [d.campo]: true };
  }
  return confirmacoes;
}

// O texto da caixa, por tipo de decisão.
const ROTULO_DECISAO = {
  marca_por_confirmar: 'Aprovo assim, sem a marca',
  promessas_certas: 'É verdade tal como está escrito',
  datas_certas: 'É de propósito',
  continua_certo: 'Continua certo',
  avisos_certos: 'Li, e fica assim',
  nota_lida: 'Li a nota, e fica assim',
};

function ModalAprovarCampanha({ aberto, slug, campanha, criativos, entradasVideo = null, onFechar, onEditarBriefing, onAprovada, onMudou }) {
  const [marcadas, setMarcadas] = React.useState({});
  const [aEnviar, setAEnviar] = React.useState(false);
  const [erro, setErro] = React.useState(null);
  // A campanha lida ao abrir, com a referência do que se vai ver (a
  // fotografia, o foco, a oferta, o cartaz). Os cartazes só se pedem depois:
  // uma mudança pelo meio faz a referência ser a de antes, e o servidor
  // recusa em vez de aprovar o que não se viu (seguimento do Codex, 24 Set).
  const [detalhe, setDetalhe] = React.useState(null);
  const [leitura, setLeitura] = React.useState(0);

  React.useEffect(() => {
    if (!aberto || !campanha) return undefined;
    let vivo = true;
    setDetalhe(null);
    api.get(`/api/${slug}/campanhas/${campanha.id}`)
      .then((d) => { if (vivo) setDetalhe(d); })
      .catch((e) => { if (vivo) setErro(e.message); });
    return () => { vivo = false; };
  }, [aberto, campanha && campanha.id, slug, leitura]);

  React.useEffect(() => {
    if (aberto) { setMarcadas({}); setErro(null); }
  }, [aberto]);

  if (!aberto || !campanha) return null;

  const lojas = detalhe ? detalhe.lojas : [];
  const { porCanal, bloqueios, decisoes } = pedidosDaAprovacao(campanha, criativos, lojas, [], { marca: marcaDoDetalhe(detalhe) });
  const faltaDecidir = decisoes.some((d) => !marcadas[d.chave]);
  const pronto = detalhe && criativos && !bloqueios.length && !faltaDecidir;

  const aprovar = async () => {
    setAEnviar(true);
    setErro(null);
    const confirmacoes = confirmacoesDe(decisoes, marcadas);
    try {
      // Aprova e pede a publicação no mesmo gesto (28 Set).
      const r = await api.post(`/api/${slug}/campanhas/${campanha.id}/aprovar`, corpoDaAprovacao(campanha, criativos, { ...detalhe, entradas_video_vistas: entradasVideo }, confirmacoes));
      onAprovada(r && r.publicacao);
    } catch (e) {
      setErro(e.message);
      // Mudou alguma coisa desde que se abriu: o painel lê outra vez e mostra
      // o que está agora, e as confirmações pedem-se outra vez.
      if (e.corpo && e.corpo.mudou) { setMarcadas({}); setLeitura((n) => n + 1); onMudou(); }
    } finally {
      setAEnviar(false);
    }
  };

  return (
    <Modal
      aberto={aberto}
      onFechar={onFechar}
      titulo="Aprovar e publicar"
      largo
      acoes={
        <>
          <button type="button" onClick={onFechar} className="border border-border px-4 py-2 text-sm text-muted-foreground hover:text-foreground">
            Voltar
          </button>
          <button
            type="button"
            disabled={!pronto || aEnviar}
            onClick={aprovar}
            className="bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:bg-secondary disabled:text-muted-foreground"
          >
            {aEnviar ? 'A aprovar…' : 'Aprovar e publicar'}
          </button>
        </>
      }
    >
      <div className="space-y-5 text-foreground">
        {/* Só o que falta confirmar (28 Set): o que vai sair, quando e onde já
            se viu na página. Sem confirmações, aprovar é um clique e isto não abre. */}
        <p className="text-muted-foreground">
          {decisoes.length
            ? 'Antes de aprovar e publicar, confirma que isto é verdade. É a loja que o vai ter de cumprir.'
            : 'A campanha vai para o ar tal como está na página.'}
        </p>
        {porCanal.social && !porCanal.social.recusa && (
          <div className="flex items-start gap-3">
            <ImagemDoPainel
              semImagem="Sem imagem: sai só com o texto."
              src={`/api/${slug}/campanhas/${campanha.id}/criativos/${porCanal.social.id}/imagem.png?v=${porCanal.social.versao}-${porCanal.dooh ? porCanal.dooh.id : ''}`}
              alt="O post das redes sociais, como vai sair"
              largura={1080}
              altura={1350}
              classe="max-h-40"
            />
            <p className="text-xs text-muted-foreground">O post das redes sociais, como vai sair.</p>
          </div>
        )}

        {bloqueios.length > 0 && (
          <section className="border-l-2 border-[hsl(var(--offline))] bg-[hsl(var(--offline)/0.08)] px-3 py-2">
            <p className="text-xs font-medium text-perigo-texto">Antes de aprovar</p>
            <ul className="mt-1 space-y-1">
              {bloqueios.map((b) => (
                <li key={b.texto} className="text-xs leading-relaxed">
                  {b.texto}{' '}
                  <button
                    type="button"
                    onClick={b.onde === 'briefing' ? onEditarBriefing : onFechar}
                    className="underline underline-offset-2 hover:text-primary"
                  >
                    {b.onde === 'briefing' ? 'Abrir o briefing' : 'Corrigir nos anúncios'}
                  </button>
                </li>
              ))}
            </ul>
          </section>
        )}

        {decisoes.length > 0 && (
          <section className="border-l-2 border-accent bg-[hsl(var(--accent)/0.08)] px-3 py-2">
            <p className="text-xs font-medium text-accent">É verdade, tal como está escrito?</p>
            <ul className="mt-1 space-y-1.5">
              {decisoes.map((d) => (
                <li key={d.chave}>
                  <label className="flex items-start gap-2 text-xs leading-relaxed">
                    <input
                      type="checkbox"
                      className="mt-0.5 accent-primary"
                      checked={Boolean(marcadas[d.chave])}
                      onChange={(e) => { const sim = e.target.checked; setMarcadas((m) => ({ ...m, [d.chave]: sim })); }}
                    />
                    <span>{d.texto}</span>
                  </label>
                </li>
              ))}
            </ul>
            <p className="mt-1.5 text-xs text-muted-foreground">Se alguma não for verdade, volta e corrige o texto.</p>
          </section>
        )}

        {erro && <p className="text-sm text-perigo-texto" role="alert">{erro}</p>}
      </div>
    </Modal>
  );
}

const NOME_MOVIMENTO = {
  estatico: 'imagem parada', zoom_lento: 'aproximação lenta', deslize: 'deslize', revelacao_produto: 'o produto entra',
  contagem_preco: 'o preço conta até ao valor', respirar: 'movimento lento',
  aproximar: 'a câmara aproxima-se', afastar: 'a câmara afasta-se', deslizar: 'a câmara desliza para o lado',
};
const NOME_PAPEL_CENA = { mensagem: 'A mensagem', gancho: 'Abertura', produto: 'O produto', prova: 'Prova' };

/**
 * "Nas cenas c2" é o id interno da cena; o cliente vê "cena 2" (ensaio de 7 Out,
 * P8). As notas novas já vêm assim (lib/storyboard.js); as já guardadas
 * corrigem-se aqui, ao mostrar.
 */
function semIdsDeCena(texto) {
  return String(texto || '').replace(/\b([Nn]as? )?cenas? (c\d+(?:(?:, | e )c\d+)*)\b/g, (_, prep, ids) => {
    const ns = ids.split(/, | e /).map((x) => x.slice(1));
    const lista = ns.length === 1 ? `cena ${ns[0]}` : `cenas ${ns.slice(0, -1).join(', ')} e ${ns[ns.length - 1]}`;
    return prep ? `${prep[0]}${ns.length === 1 ? 'a' : 'as'} ${lista}` : lista;
  });
}

/**
 * O storyboard do vídeo (28 Set, docs/specs/2026-09-28-storyboard-video.md).
 * Parte da imagem para os painéis: o Agente PM escreve as cenas, e cada uma é
 * desenhada aqui pelo nosso compositor, sem custo, para se ver o vídeo antes
 * de se gastar a fazê-lo. O vídeo em si vem depois (o motor de render).
 */
/**
 * O MP4 composto a partir do storyboard aprovado (V0, 2 Out). Compõe-se no
 * worker, sem geração paga; aparece aqui quando estiver pronto.
 */
// `storyboard`: as cenas aprovadas de agora. Uma versão nova lê tudo outra vez e
// espera pelo MP4 dela, sem ficar com o anterior (Codex, 2 Out, #8).
function VideoComposto({ slug, campanha, storyboard, podeGerir, onBloqueado, onPronto }) {
  const [dados, setDados] = React.useState(null);
  const [aTentar, setATentar] = React.useState(false);
  const [erroTentar, setErroTentar] = React.useState(null);
  const [videoEscolhido, setVideoEscolhido] = React.useState(null);
  const [erroLer, setErroLer] = React.useState(null);
  const ler = React.useCallback(() => api.get(`/api/${slug}/campanhas/${campanha.id}/videos`)
    .then((r) => { setDados(r); setErroLer(null); }).catch((e) => setErroLer(e.message)), [slug, campanha.id, storyboard.id, storyboard.versao]);
  React.useEffect(() => { setDados(null); ler(); }, [ler]);
  const prontos = dados ? dados.videos.filter((x) => x.actual && x.criativo_id === storyboard.id) : [];
  const v = prontos.find((x) => x.id === videoEscolhido) || prontos[0];
  const porCompor = dados?.pendentes || [];
  const problema = dados && dados.problema && dados.problema.criativo_id === storyboard.id ? dados.problema : null;
  // Enquanto se compõe, volta a ver de 5 em 5 segundos (eram 15: o vídeo ficava pronto e o
  // ecrã só o mostrava até 15 s depois).
  React.useEffect(() => {
    if (problema || (v && !porCompor.length)) return undefined;
    const t = setInterval(ler, 5000);
    return () => clearInterval(t);
  }, [dados, v, problema, ler, porCompor.length]);
  // O que impede compor (uma imagem apagada, o que o vídeo não faz), à vista (#4, #7).
  React.useEffect(() => { if (onBloqueado) onBloqueado(Boolean(problema)); }, [problema, onBloqueado]);
  // E o pai diz "Vídeo pronto" só quando o MP4 desta versão das cenas existe (E11, 7 Out).
  React.useEffect(() => { if (onPronto) onPronto(Boolean(v) && !porCompor.length); }, [v, onPronto, porCompor.length]);
  // O sino relê a fila quando o vídeo fica pronto ou falha: de 30 em 30 s, dizia «a produzir o vídeo» com
  // o vídeo já feito (teste de raiz, 9 Out).
  React.useEffect(() => { if (v || problema) window.dispatchEvent(new Event('fila-mudou')); }, [Boolean(v), Boolean(problema)]);
  if (erroLer) return <div className="mt-3 space-y-2 text-xs" role="alert"><p className="text-perigo-texto">{erroLer}</p><button type="button" onClick={() => { setErroLer(null); setDados(null); ler(); }} className="border border-border px-3 py-1.5 hover:bg-secondary">Voltar a ver o vídeo</button></div>;
  if (dados === null) return <div className="loading-skeleton mt-3 h-10 rounded-md" role="status" aria-label="A ver o vídeo" />;
  // Uma imagem em falta resolve-se com o mesmo ficheiro, sem IA: tentar outra vez
  // (Codex, 3 Out, #2). O resto pede cenas novas, com o botão de cima.
  const tentar = async () => {
    setATentar(true);
    setErroTentar(null);
    try {
      await api.post(`/api/${slug}/campanhas/${campanha.id}/videos/tentar`, {});
      setDados(null);
      ler();
    } catch (e) {
      setErroTentar(e.message);
    } finally {
      setATentar(false);
    }
  };
  if (problema) {
    return (
      <div className="mt-3 space-y-2 text-xs" role="status">
        <p className="text-perigo-texto">{`O vídeo não se compôs: ${problema.erro}`}</p>
        {podeGerir && problema.codigo === 'ENTRADA_EM_FALTA' && (
          <button type="button" disabled={aTentar} onClick={tentar} className="border border-border px-3 py-1.5 text-foreground hover:bg-secondary disabled:opacity-50">
            {aTentar ? 'A conferir…' : 'Tentar outra vez'}
          </button>
        )}
        {/* A recusa repete muitas vezes o problema: só se diz o que é novo (ensaio de 3 Out). */}
        {erroTentar && erroTentar !== problema.erro && <p className="text-perigo-texto" role="alert">{erroTentar}</p>}
      </div>
    );
  }
  if (!v) {
    return (
      <AProcessar
        variante="bloco"
        className="mt-3"
        texto="O vídeo está a ser composto"
        detalhe="Aparece aqui sozinho quando estiver pronto."
      />
    );
  }
  return (
    <div className="mt-4 space-y-2">
      {prontos.length > 1 && <label className="block text-xs text-muted-foreground">Medida do vídeo
        <select value={v.id} onChange={(e) => setVideoEscolhido(e.target.value)} className="ml-2 border border-border bg-background px-2 py-1 text-foreground">
          {prontos.map((x) => <option key={x.id} value={x.id}>{x.largura_px}×{x.altura_px}</option>)}
        </select>
      </label>}
      <video src={v.url} controls muted playsInline className="max-h-[480px] max-w-full border border-border bg-black" />
      {porCompor.length > 0 && <p className="text-xs text-muted-foreground" role="status">A compor: {porCompor.map((m) => `${m.largura}×${m.altura}`).join(', ')}.</p>}
      <p className="text-xs text-muted-foreground">
        {v.largura_px}×{v.altura_px} · {(v.duracao_ms / 1000).toFixed(0)} s · sem som.{' '}
        <a className="underline" href={`${v.url}?descarregar=1`}>Descarregar o MP4</a>
      </p>
    </div>
  );
}

/**
 * Uma miniatura das cenas pedida por fetch, para se lerem os avisos que o servidor manda no
 * cabeçalho X-Avisos (um <img src> não os deixa ler: ensaio de 4 Out). `onCarregou(avisos)`
 * quando a imagem aparece; `onFalhou()` se o pedido ou a imagem falharem.
 */
function MiniaturaComAvisos({ src, alt, largura, altura, pequena = false, onCarregou, onFalhou }) {
  const [url, setUrl] = React.useState(null);
  const avisos = React.useRef([]);
  React.useEffect(() => {
    if (!src) return undefined;
    let vivo = true;
    let objecto = null;
    setUrl(null);
    fetch(src, { credentials: 'same-origin' })
      .then(async (r) => {
        if (!r.ok) throw new Error(String(r.status));
        let lidos = [];
        try { lidos = JSON.parse(decodeURIComponent(r.headers.get('X-Avisos') || '[]')); } catch (e) { lidos = []; }
        avisos.current = Array.isArray(lidos) ? lidos : [];
        const b = await r.blob();
        if (!vivo) return;
        objecto = URL.createObjectURL(b);
        setUrl(objecto);
      })
      .catch(() => { if (vivo && onFalhou) onFalhou(); });
    return () => { vivo = false; if (objecto) URL.revokeObjectURL(objecto); };
  }, [src]);
  const classe = pequena ? 'block w-16 bg-secondary' : 'block w-36 border border-border bg-secondary';
  // A desenhar no servidor: o mesmo aviso dos painéis, e não uma caixa vazia (8 Out).
  if (!url) {
    return (
      <div role="status" style={{ height: Math.round(altura * (pequena ? 64 : 144) / largura) }}
        className={`${classe} loading-skeleton flex items-center justify-center text-center text-xs text-muted-foreground`}>
        {pequena ? null : 'A desenhar…'}
      </div>
    );
  }
  return <img src={url} onLoad={() => onCarregou && onCarregou(avisos.current)} onError={() => onFalhou && onFalhou()} alt={alt} width={largura} height={altura} className={classe} />;
}

function StoryboardVideo({ slug, campanha, criativos, podeGerir, onMudou, lerVideo = 0, onEntradasVistas }) {
  // Pedido no briefing e com a campanha por aprovar: aprova-se com ela, no
  // "Aprovar e publicar" (28 Set). Depois (no ar), tem o seu próprio botão.
  const comACampanha = videoPedido(campanha) && ['draft', 'em_revisao'].includes(campanha.estado);
  const inicial = (criativos || []).find((c) => c.canal === 'video') || null;
  const [sb, setSb] = React.useState(inicial);
  const [aEscrever, setAEscrever] = React.useState(false);
  // Aprovar tem o seu estado: com o de escrever, aprovar dizia «A escrever as cenas…» (8 Out).
  const [aAprovar, setAAprovar] = React.useState(false);
  const [erro, setErro] = React.useState(null);
  // Uma aprovação que pede confirmações (promessas, datas): o texto do servidor,
  // e um segundo gesto para dizer que é verdade (como nos outros criativos).
  const [aConfirmar, setAConfirmar] = React.useState(null);
  // O storyboard em cada formato da campanha: se cabe, e porquê se não (spec 2).
  const [formatos, setFormatos] = React.useState(null);
  // A referência das entradas que as miniaturas mostram (Codex, 3 Out, #3): a
  // aprovação devolve-a, e o servidor recusa se a marca mudou entretanto.
  const [entradasRef, setEntradasRef] = React.useState(null);
  // As miniaturas carregadas com a referência actual, e as recusas seguidas.
  // O que está à vista pertence a um storyboard, uma versão, uma referência, um formato e
  // uma tentativa: muda qualquer um, começa do zero (quinto parecer do Codex, 3 Out).
  const [carregadas, setCarregadas] = React.useState({ chave: null, n: new Set() });
  const [tentativa, setTentativa] = React.useState(0);
  // As tentativas (valores de `t`) que falharam: conta-se uma por tentativa, e não
  // uma por imagem (ensaio de 3 Out: quatro imagens a falhar eram "três tentativas").
  const falhouEm = React.useRef(new Set());
  // A última leitura dos formatos falhou: a referência que está à vista é a antiga, e
  // não se aprova com ela (ensaio de 3 Out: voltava a activar-se com a releitura falhada).
  const [formatosFalharam, setFormatosFalharam] = React.useState(false);
  // As miniaturas que falharam nesta tentativa: uma caixa com o estado, e não uma
  // imagem partida (sétimo parecer do Codex, 3 Out).
  const [errosMini, setErrosMini] = React.useState({ chave: null, n: new Set() });
  const [falhas, setFalhas] = React.useState(0);
  const [lerOutraVez, setLerOutraVez] = React.useState(0);
  // Um vídeo bloqueado e ainda não entregue (o leitor diz qual): no ar, deixa escrever as cenas outra vez.
  const [videoBloqueado, setVideoBloqueado] = React.useState(false);
  // O MP4 já composto: a linha de estado diz "Vídeo pronto", e não "a NetScreen
  // produz o vídeo" por cima do vídeo (ensaio de 7 Out, E11).
  const [videoPronto, setVideoPronto] = React.useState(false);
  React.useEffect(() => { setSb((criativos || []).find((c) => c.canal === 'video') || null); }, [criativos]);
  // Um storyboard novo começa do zero; uma releitura do mesmo mantém as
  // miniaturas à vista até chegar a nova (ensaio de 3 Out: piscavam as de base).
  React.useEffect(() => { setFormatos(null); setCarregadas({ chave: null, n: new Set() }); setFalhas(0); falhouEm.current = new Set(); }, [sb && sb.id, sb && sb.versao]);
  React.useEffect(() => {
    if (!sb || sb.recusa) return;
    api.get(`/api/${slug}/campanhas/${campanha.id}/storyboard/${sb.id}/formatos`)
      .then((r) => { setFormatos(r.formatos); setEntradasRef(r.entradas_ref || null); setFormatosFalharam(false); })
      // Por aprovar, uma falha conta como tentativa, como as imagens: sem formatos não
      // há imagens, e a aprovação ficava à espera sem saída (sexto parecer do Codex, 3 Out).
      .catch(() => { if (sb.aprovado) setFormatos([]); else { setFormatosFalharam(true); falhou(); } });
  }, [sb && sb.id, slug, campanha.id, lerOutraVez, lerVideo]);
  // Ao voltar à aba, lêem-se outra vez: a marca pode ter mudado noutra (ensaio de 3 Out).
  // As miniaturas só se redesenham se a referência das entradas mudou (vai no URL delas).
  React.useEffect(() => {
    const aoVoltar = () => { if (document.visibilityState === 'visible') setLerOutraVez((n) => n + 1); };
    document.addEventListener('visibilitychange', aoVoltar);
    return () => document.removeEventListener('visibilitychange', aoVoltar);
  }, []);
  const naoCabe = (formatos || []).filter((f) => f.nao_cabe.length);
  // As cenas que se vêem e aprovam são as do destino principal, tal como saem
  // (Codex, 2 Out, #3): o Reel quando há redes, senão o primeiro formato. Os
  // textos, as cenas, os tempos e as miniaturas vêm da mesma derivação do MP4.
  const principal = formatos && formatos.length
    ? (formatos.find((f) => f.largura === 1080 && f.altura === 1920) || formatos[0]) : null;
  // Escrito a partir de uma versão anterior dos painéis (revisor, 28 Set).
  // Dos painéis, ou do post numa campanha sem eles (V0, 2 Out).
  const doohAgora = sb && sb.conteudo ? (criativos || []).find((c) => c.canal === canalDoMockup(sb.conteudo)) : null;
  const desactualizado = Boolean(sb && sb.conteudo && sb.conteudo.mockup && doohAgora
    && Number(doohAgora.versao) !== Number(sb.conteudo.mockup.versao));

  // De onde parte: os painéis, ou o post sem eles (V0, 2 Out).
  const canalOrigem = (campanha.canais || []).includes('dooh') ? 'dooh' : 'social';
  const temPainel = (criativos || []).some((c) => c.canal === canalOrigem && !c.recusa);
  // Só quando a campanha pede vídeo, ou já tem um storyboard (28 Set): antes,
  // cada campanha com painéis tinha esta secção e mais uma aprovação.
  const pedeVideo = Boolean(campanha.briefing && campanha.briefing.video);
  // Aprovada sem o vídeo («Publicar sem o vídeo por agora», ou ainda por escrever): fica para depois,
  // sem o vermelho de um erro a pedir para escrever outra vez (agentes, 20 Out).
  const paraDepois = ['aprovada', 'ativa'].includes(campanha.estado) && !(sb && sb.aprovado);

  const escrever = async () => {
    setAEscrever(true);
    setErro(null);
    try {
      const r = await api.post(`/api/${slug}/campanhas/${campanha.id}/storyboard`, {});
      setSb(r.criativos && r.criativos[0] ? r.criativos[0] : null);
      // A página relê: o que se aprova com a campanha é este storyboard novo.
      if (onMudou) onMudou();
    } catch (e) {
      setErro(e.message);
    } finally {
      setAEscrever(false);
    }
  };

  // `confirmados`: só as confirmações que o servidor pediu e o cliente viu, uma
  // de cada vez (revisor, 28 Set: mandavam-se as três, e aprovava-se "a oferta
  // mudou" sem nunca se ter visto essa mensagem).
  const aprovar = async (confirmados = {}) => {
    setAAprovar(true);
    setErro(null);
    try {
      await api.post(`/api/${slug}/campanhas/${campanha.id}/storyboard/${sb.id}/aprovar`, { ...confirmados, entradas: refVista });
      setSb({ ...sb, aprovado: true });
      setAConfirmar(null);
    } catch (e) {
      const corpo = e.corpo || {};
      const campo = corpo.promessas_por_confirmar ? 'promessas_certas'
        : corpo.datas_depois_do_fim ? 'datas_certas' : corpo.desactualizado ? 'continua_certo'
        : corpo.avisos_por_confirmar ? 'avisos_certos' : null;
      if (campo && !confirmados[campo]) setAConfirmar({ texto: e.message, campo, confirmados });
      else { setErro(e.message); setAConfirmar(null); }
      // A marca mudou entre ver e aprovar: as miniaturas desenham-se outra vez.
      if (corpo.mudou) setLerOutraVez((n) => n + 1);
    } finally {
      setAAprovar(false);
    }
  };

  const c = sb && sb.conteudo;
  // Enquanto os formatos não chegam, as de base; depois, as do destino principal.
  const cenas = principal && principal.derivadas
    ? principal.derivadas.map((d) => ({ ...d, blocos: d.textos.map((t) => ({ texto: t })) }))
    : (c && Array.isArray(c.cenas) ? c.cenas : []);
  const altMini = principal ? Math.round(270 * principal.altura / principal.largura) : 480;
  // A referência só conta como vista quando todas as miniaturas carregaram com
  // ela; uma recusada (a marca mudou) faz ler outra vez (Codex, 3 Out, #1).
  // Uma cena com movimento de início e fim mostra também o fim, e conta (bloco local do V2, 3 Out).
  const totalMinis = cenas.length + 1 + cenas.filter((s) => s.com_fim).length;
  const chaveVista = sb && principal ? `${sb.id}:${sb.versao}:${entradasRef}:${principal.largura}x${principal.altura}:${tentativa}` : null;
  const marcarVista = (n) => setCarregadas((c) => (c.chave === chaveVista ? { chave: c.chave, n: new Set([...c.n, n]) } : { chave: chaveVista, n: new Set([n]) }));
  // Uma imagem que falhou: lê-se outra vez a referência (pode ter mudado a marca) e
  // pede-se de novo a imagem (outra tentativa no URL), até três vezes; depois diz-se.
  // Uma vez por tentativa, venha a falha dos formatos ou das imagens.
  function falhou() {
    if (!sb || sb.aprovado || falhouEm.current.has(tentativa)) return;
    falhouEm.current.add(tentativa);
    setFalhas(falhouEm.current.size);
    // Com intervalo, a crescer: uma falha breve da rede não esgota as três de uma vez.
    const n = falhouEm.current.size;
    if (n < 3) setTimeout(() => { setLerOutraVez((k) => k + 1); setTentativa((t) => t + 1); }, 800 * n);
  }
  // Só as miniaturas do destino (as de base não contam).
  const falhouMini = (n) => {
    setErrosMini((c) => (c.chave === chaveVista ? { chave: c.chave, n: new Set([...c.n, n]) } : { chave: chaveVista, n: new Set([n]) }));
    if (principal) falhou();
  };
  const caixaMini = (n) => {
    if (!principal) return '';
    if (errosMini.chave !== chaveVista || !errosMini.n.has(n)) return null;
    if (sb.aprovado) return 'Prévia indisponível; a aprovação mantém-se.';
    return falhas >= 3 ? 'Não carregou.' : 'A tentar outra vez…';
  };
  // Os avisos de cada miniatura, por cena, da tentativa à vista; repetidos juntam-se.
  const [avisosMini, setAvisosMini] = React.useState({ chave: null, porMini: {} });
  const guardarAvisos = (n, av) => setAvisosMini((x) => (x.chave === chaveVista
    ? { chave: x.chave, porMini: { ...x.porMini, [n]: av } } : { chave: chaveVista, porMini: { [n]: av } }));
  const avisosVistos = avisosMini.chave === chaveVista
    ? [...new Set(Object.entries(avisosMini.porMini).flatMap(([n, av]) => (av || []).map((a) => (n === 'final' ? `Cartão final: ${a}` : `Cena ${parseInt(n, 10) + 1}: ${a}`))))] : [];
  const caixa = (texto) => (
    <div aria-hidden={texto ? undefined : 'true'} style={{ height: Math.round(altMini * 144 / 270) }} className="flex w-36 items-center justify-center border border-border bg-secondary p-2 text-center text-xs text-muted-foreground">{texto}</div>
  );
  const refVista = !formatosFalharam && entradasRef && chaveVista && carregadas.chave === chaveVista && carregadas.n.size >= totalMinis ? entradasRef : null;
  // O pai (a aprovação conjunta) recebe a referência só quando as imagens dela estão à vista.
  // Enquanto as cenas se escrevem outra vez, nada está visto: a aprovação espera
  // (ensaio de 3 Out). E diz-se ao pai se as miniaturas não carregaram.
  React.useEffect(() => {
    if (onEntradasVistas) onEntradasVistas(aEscrever ? null : refVista, falhas >= 3 && !refVista);
    if (refVista) { setFalhas(0); falhouEm.current = new Set(); }
  }, [refVista, aEscrever, falhas]);
  // Depois de todos os hooks: sem vídeo pedido nem storyboard, a secção não aparece.
  if (!pedeVideo && !sb) return null;
  const img = (n) => principal
    ? `/api/${slug}/campanhas/${campanha.id}/criativos/${sb.id}/cena/${n}.png?destino=${principal.largura}x${principal.altura}&largura=270&v=${sb.versao}&e=${entradasRef || ''}&t=${tentativa}`
    // Sem os formatos ainda, não se pede a miniatura de base: seria cancelada logo a seguir.
    : undefined;
  const duracaoFinal = principal && principal.final ? principal.final.duracao : (c && c.cartao_final ? c.cartao_final.duracao : 0);

  return (
    <div id="video" className="mt-6 scroll-mt-4 border-t border-border pt-5">
      <SeccaoCampanha titulo="Vídeo">
        {paraDepois && (
          <p className="text-xs leading-relaxed text-muted-foreground">
            O vídeo ficou para depois: a campanha saiu sem ele. Quando quiseres, escreve as cenas e aprova-as aqui.
          </p>
        )}
        {!sb && !paraDepois && (
          <p className="text-xs leading-relaxed text-muted-foreground">
            Pediste vídeo no briefing. A IA escreve as cenas a partir da imagem para os painéis (ou do post, sem painéis), e mostramos
            cada uma aqui, com a tua marca, antes de se fazer o vídeo.
          </p>
        )}
        {sb && sb.recusa && (
          <p className={`text-xs ${paraDepois ? 'text-muted-foreground' : 'text-perigo-texto'}`} role="status">
            {paraDepois ? `Da última vez, as cenas não se escreveram: ${razaoDaRecusa(sb.recusa)}` : `As cenas não se escreveram. ${sb.recusa}`}
          </p>
        )}
        {cenas.length > 0 && (
          <div className="space-y-2">
            <p className="text-xs text-muted-foreground">
              {`${c.duracao_total} s · ${cenas.length} ${cenas.length === 1 ? 'cena' : 'cenas'} e o cartão final${principal ? ` · como sai em ${principal.nome}` : ''}${c.rotulo_ia ? ' · leva a indicação de conteúdo gerado com IA' : ''}`}
            </p>
            <ol className="flex gap-3 overflow-x-auto pb-2">
              {cenas.map((s, i) => (
                <li key={s.id || i} className="w-36 shrink-0 space-y-1">
                  {/* O nome da cena vem por cima, para as miniaturas de início ficarem
                      alinhadas; o fim do movimento vai por baixo, e não num canto do
                      início, onde tapava o texto da cena (ensaio de 7 Out, E6). */}
                  <p className="text-xs font-medium text-foreground">{`${i + 1}. ${(s.papel === 'gancho' && i > 0 ? 'Reforço' : NOME_PAPEL_CENA[s.papel]) || ''} · ${segundos(s.duracao)} s`}</p>
                  {caixaMini(i) !== null ? caixa(caixaMini(i)) : <MiniaturaComAvisos src={img(i)} onCarregou={(av) => { if (principal) marcarVista(i); guardarAvisos(i, av); }} onFalhou={() => falhouMini(i)} alt={`Cena ${i + 1}: ${s.blocos.map((b) => b.texto).join('. ')}`} largura={270} altura={altMini} />}
                  {s.com_fim && (
                    <div className="flex items-end gap-2 pt-1">
                      <div className="w-16 shrink-0 border border-border">
                        {caixaMini(`${i}-fim`) !== null ? <div className="h-6 bg-secondary" /> : <MiniaturaComAvisos src={`${img(i)}&quadro=fim`} onCarregou={(av) => { if (principal) marcarVista(`${i}-fim`); guardarAvisos(`${i}-fim`, av); }} onFalhou={() => falhouMini(`${i}-fim`)} alt={`Cena ${i + 1}, no fim do movimento`} largura={270} altura={altMini} pequena />}
                      </div>
                      <p className="text-xs text-muted-foreground">No fim do movimento</p>
                    </div>
                  )}
                </li>
              ))}
              <li className="w-36 shrink-0 space-y-1">
                <p className="text-xs font-medium text-foreground">{`Final · ${segundos(duracaoFinal)} s`}</p>
                {caixaMini('final') !== null ? caixa(caixaMini('final')) : <MiniaturaComAvisos src={img('final')} onCarregou={(av) => { if (principal) marcarVista('final'); guardarAvisos('final', av); }} onFalhou={() => falhouMini('final')} alt={`Cartão final: ${c.cartao_final.cta}`} largura={270} altura={altMini} />}
                <p className="text-xs text-muted-foreground">Logótipo e chamada</p>
              </li>
            </ol>
            {/* Os avisos das miniaturas (letra pequena, fotografia com pouca resolução), que vinham no
                cabeçalho X-Avisos e não se viam (ensaio de 4 Out). Informam; não impedem aprovar. */}
            {avisosVistos.length > 0 && (
              <ul className="space-y-0.5 text-xs text-accent">
                {avisosVistos.map((a) => <li key={a}>{a}</li>)}
              </ul>
            )}
            {/* As miniaturas não carregaram depois de três tentativas: diz-se, e tenta-se à mão. */}
            {sb && !sb.aprovado && falhas >= 3 && !refVista && (
              <p className="text-xs text-perigo-texto" role="alert">
                Não deu para carregar as cenas.{' '}
                <button type="button" onClick={() => { setFalhas(0); falhouEm.current = new Set(); setLerOutraVez((k) => k + 1); setTentativa((t) => t + 1); }} className="underline underline-offset-2">
                  Tentar carregar novamente
                </button>
              </p>
            )}
            {/* O que não cabe impede aprovar: fica à vista, numa linha. */}
            {naoCabe.length > 0 && (
              <p className="text-xs text-accent" role="status">
                {/* O porquê traz o que fazer («Escolhe outra imagem»): só para quem pode corrigir. */}
                {podeGerir ? `Não cabe em ${naoCabe.map((f) => f.nome).join(', ')}: ${naoCabe[0].nao_cabe[0].razao}` : `Não cabe em ${naoCabe.map((f) => f.nome).join(', ')}.`}
              </p>
            )}
            {/* O mecanismo (o movimento de cada plano, o fundo animado, os
                formatos, as notas sobre os materiais) é para quem faz o vídeo (28 Set). */}
            <details className="rounded-md border border-border px-3 py-2 text-xs">
              <summary className="text-muted-foreground">Pormenores técnicos</summary>
              <div className="mt-2 space-y-2 text-muted-foreground">
                <ul className="space-y-0.5">
                  {cenas.map((s, i) => (
                    <li key={s.id || i}>{`Cena ${i + 1}: ${s.planos.map((p) => NOME_MOVIMENTO[p.movimento] || p.movimento).join(', ')}`}</li>
                  ))}
                </ul>
                {c.camada_viva && <p>{`Fundo animado: ${c.camada_viva.prompt}`}</p>}
                {formatos && formatos.length > 0 && (
                  <ul className="space-y-0.5">
                    {formatos.map((f) => (
                      <li key={`${f.largura}x${f.altura}`} className={f.nao_cabe.length ? 'text-accent' : ''}>
                        {`${f.nome} · ${f.largura}×${f.altura} · `}
                        {f.nao_cabe.length ? f.nao_cabe[0].razao : `${f.cenas} ${f.cenas === 1 ? 'cena' : 'cenas'}, cabe${f.tempos && f.tempos.length ? ` (${f.tempos.map(segundos).join(' + ')} s e o final)` : ''}`}
                      </li>
                    ))}
                  </ul>
                )}
                {c.aviso && <p className="whitespace-pre-wrap">{semIdsDeCena(c.aviso)}</p>}
              </div>
            </details>
          </div>
        )}
        {erro && <p className="mt-2 text-xs text-perigo-texto" role="alert">{erro}</p>}
        {sb && sb.aprovado && (
          <p className="mt-3 text-xs text-foreground" role="status">
            {/* Cenas aprovadas não é vídeo entregue (parecer do Codex, 28 Set): a
                entrega aparece em "Entrega por destino". Com o MP4 composto, diz-se
                que está pronto -- é o mesmo estado que o Início lê (ensaio de 7 Out, E11). */}
            {videoPronto && !videoBloqueado ? (
              <>
                <Badge variante="sucesso">Vídeo pronto</Badge>{' '}
                A NetScreen envia-o para os destinos; a entrega aparece em «Entrega por destino».
              </>
            ) : (
              <>
                <Badge variante="sucesso">Cenas aprovadas</Badge>{' '}
                O vídeo compõe-se a partir delas; a entrega aparece em «Entrega por destino».
              </>
            )}
            {/* No ar não se reescrevem as cenas, salvo um vídeo bloqueado e por entregar (Codex, 3 Out). */}
            {campanha.estado === 'ativa' ? '' : ' Uma versão nova das cenas volta a pedir aprovação.'}
          </p>
        )}
        {aConfirmar && (
          <div className="mt-3 space-y-2 border-l-2 border-accent bg-[hsl(var(--accent)/0.08)] px-3 py-2 text-xs">
            <p>{aConfirmar.texto}</p>
            <button type="button" disabled={aEscrever || aAprovar} onClick={() => aprovar({ ...aConfirmar.confirmados, [aConfirmar.campo]: true })} className="bg-primary px-3 py-1.5 text-xs text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
              É verdade: aprovar
            </button>
          </div>
        )}
        {comACampanha && sb && !sb.recusa && (!sb.aprovado || desactualizado) && (
          <p className={`mt-3 text-xs ${naoCabe.length || desactualizado ? 'text-accent' : 'text-muted-foreground'}`}>
            {desactualizado ? 'O texto de onde o vídeo parte mudou depois de ele ser escrito: escreve-o outra vez antes de aprovar a campanha.'
              : naoCabe.length ? 'Não cabe em todos os formatos: escreve-o outra vez antes de aprovar a campanha.'
              : 'Aprova-se com a campanha, no mesmo gesto.'}
          </p>
        )}
        {!comACampanha && sb && !sb.recusa && desactualizado && campanha.estado !== 'encerrada' && (
          <p className="mt-3 text-xs text-accent">O texto de onde o vídeo parte mudou depois de ele ser escrito: escreve-o outra vez.</p>
        )}
        {podeGerir && !comACampanha && sb && !sb.aprovado && !sb.recusa && !desactualizado && !aConfirmar && campanha.estado !== 'encerrada' && (
          <button
            type="button"
            disabled={aEscrever || aAprovar || naoCabe.length > 0 || formatos === null || !refVista}
            title={naoCabe.length ? 'Não cabe em todos os formatos: escreve outra vez' : undefined}
            onClick={() => aprovar({})}
            className="mt-3 mr-2 bg-primary px-3 py-1.5 text-xs text-primary-foreground outline-none hover:bg-primary/90 disabled:opacity-50"
          >
            {aAprovar ? 'A aprovar o storyboard…' : 'Aprovar o storyboard'}
          </button>
        )}
        {podeGerir && campanha.estado !== 'encerrada' && !(campanha.estado === 'ativa' && sb && sb.aprovado && !desactualizado && !videoBloqueado) && (
          <button
            type="button"
            disabled={aEscrever || aAprovar || !temPainel}
            onClick={escrever}
            title={temPainel ? undefined : canalOrigem === 'dooh' ? 'Escreve primeiro a imagem para os painéis' : 'Escreve primeiro o post'}
            className="mt-3 border border-border px-3 py-1.5 text-xs text-foreground outline-none hover:bg-secondary disabled:opacity-50"
          >
            {aEscrever ? 'A escrever as cenas…' : sb ? 'Escrever as cenas outra vez' : 'Escrever as cenas do vídeo'}
          </button>
        )}
        {/* A razão de o botão estar desligado à vista, e não só no title (cliente simulado, 5 Out). */}
        {podeGerir && campanha.estado !== 'encerrada' && !temPainel && !aEscrever && (
          <p className="mt-2 text-xs text-muted-foreground">
            {canalOrigem === 'dooh' ? 'Escreve primeiro a imagem para os painéis: as cenas partem dela.' : 'Escreve primeiro o post: as cenas partem dele.'}
          </p>
        )}
        {podeGerir && !comACampanha && sb && !sb.aprovado && !sb.recusa && !desactualizado && naoCabe.length > 0 && (
          <p className="mt-2 text-xs text-accent">Não cabe em todos os formatos: escreve-o outra vez antes de o aprovar.</p>
        )}
        {aEscrever && <AProcessar className="mt-2" texto="A IA está a escrever as cenas do vídeo" detalhe="Leva uns 15 a 30 segundos." />}
        {sb && sb.aprovado && <VideoComposto key={`${sb.id}-${sb.versao}`} slug={slug} campanha={campanha} storyboard={sb} podeGerir={podeGerir} onBloqueado={setVideoBloqueado} onPronto={setVideoPronto} />}
      </SeccaoCampanha>
    </div>
  );
}

/**
 * "Antes de aprovar" (28 Set, avaliação de UX, mudança 3): um bloco só, no topo
 * dos criativos, com o que impede aprovar separado do que se confirma ao
 * aprovar. Antes, cada criativo tinha até quatro caixas de aviso, repetidas por
 * canal, por cima do texto do anúncio, e o motivo de não se poder aprovar só
 * aparecia no fundo do modal.
 *
 * Os bloqueios são os de `pedidosDaAprovacao` (o mesmo cálculo do modal, que o
 * servidor confirma). As notas são o aviso do Agente PM (com a verificação dos
 * preços), as promessas por confirmar, a oferta que mudou e as datas depois do
 * fim. Cada ponto diz o canal e leva onde se resolve.
 */
/**
 * O jargão do Agente PM, em palavras do cliente (28 Set): o "bloco 'destaque'"
 * é o número em destaque, o "CTA" é a chamada, o "material m3" é uma imagem.
 */
function semJargao(texto) {
  const q = `['"‘’«»“”]?`;
  return String(texto || '')
    .replace(new RegExp(`os blocos ${q}destaque${q} e ${q}disclaimer${q}`, 'gi'), 'o número em destaque e o texto legal')
    .replace(new RegExp(`o bloco ${q}destaque${q}`, 'gi'), 'o número em destaque')
    .replace(new RegExp(`bloco ${q}destaque${q}`, 'gi'), 'número em destaque')
    .replace(new RegExp(`o bloco ${q}disclaimer${q}`, 'gi'), 'o texto legal')
    .replace(/\bO CTA\b/g, 'A chamada').replace(/\bo CTA\b/g, 'a chamada')
    .replace(/\bdo CTA\b/g, 'da chamada').replace(/\bno CTA\b/g, 'na chamada').replace(/\bao CTA\b/g, 'à chamada')
    .replace(/\bCTA\b/g, 'chamada')
    .replace(/\bO material m\d+\b/g, 'Uma das imagens').replace(/\bo material m\d+\b/g, 'uma das imagens')
    .replace(new RegExp(`o campo ${q}disclaimer${q}`, 'gi'), 'o texto legal')
    .replace(new RegExp(`${q}disclaimer${q}`, 'gi'), 'texto legal')
    .replace(/\bEste JSON\b/g, 'Este texto').replace(/\beste JSON\b/g, 'este texto').replace(/\bJSON\b/g, 'texto');
}

/**
 * Uma nota do Agente PM pede alguma coisa ao cliente? As que confirmam, prometem,
 * tiram ou tratam de outra maneira pedem; "o link não foi incluído", "o vídeo é
 * feito depois" ou o porquê de um bloco são para quem faz, e ficam fechadas em
 * "Notas do Agente PM" (28 Set, advogado da simplicidade).
 */
// Na dúvida, fica à vista: o revisor (28 Set) achou 5 notas com perguntas
// escondidas em "não pedem nada" -- uma data que não bate, uma gralha, um
// produto que não é da marca.
function notaPedeDecisao(texto) {
  return /confirm|promess|promete|trata(r)? por tu|tratamento|deixad[ao]s? de fora|retirad|tirad[ao]s?|condiç|verdade|responsabilidade|superlativ|(exige|requer|pede) prova|inconsist|contradi|n[ãa]o (corresponde|bate|confere|é (um|uma|da|do))|gralha|\bv[êe] |verific|errad|d[úu]vida|deve constar|\?/i.test(String(texto || ''));
}

/** Os preços de um texto, normalizados ("9,90 €", "9.90€" e "9,90 euros" são o mesmo). */
function precosDe(texto) {
  const t = String(texto || '');
  // "1.000 €", "1 000 €", "9,90 €", "9.90€", "€12,90", "12 euros" (revisor, 28 Set).
  const numero = '(\\d{1,3}(?:[.\\s]\\d{3})+(?:,\\d{1,2})?|\\d+(?:[.,]\\d{1,2})?)';
  const depois = [...t.matchAll(new RegExp(`${numero}\\s*(?:€|euros?\\b)`, 'gi'))].map((m) => m[1]);
  // Um «€» logo a seguir a um número já fechou esse preço: «15 € 1 a 24 de dezembro» não é «€ 1»
  // (florista virgem, 4 Nov: avisava que a imagem dizia 1,00 €).
  const antes = [...t.matchAll(new RegExp(`(?<!\\d\\s*)€\\s*${numero}`, 'g'))].map((m) => m[1]);
  const normal = (p) => {
    const milhares = /^\d{1,3}(?:[.\s]\d{3})+/.test(p) && !/[.,]\d{1,2}$/.test(p.replace(/^\d{1,3}(?:[.\s]\d{3})+/, ''));
    let x = milhares ? p.replace(/[.\s]/g, '') : p.replace(/[.\s](?=\d{3}(?:\D|$))/g, '');
    x = x.replace('.', ',');
    return x.includes(',') ? x.replace(/,(\d)$/, ',$10') : `${x},00`;
  };
  return new Set([...depois, ...antes].map(normal));
}

function notasDaRevisao(criativos, campanha = null) {
  const notas = [];
  // A imagem do post leva o texto dos painéis: um preço num e noutro não é um
  // post que diz duas coisas (verificação de 28 Set: "2,00 €" na imagem e
  // "1,50 €" na legenda). Diz-se no post.
  const social = (criativos || []).find((c) => c.canal === 'social' && !c.aprovado && !c.recusa);
  const dooh = (criativos || []).find((c) => c.canal === 'dooh' && !c.recusa);
  if (social && dooh && social.conteudo && dooh.conteudo) {
    const naImagem = precosDe((dooh.conteudo.blocos || []).map((b) => b.texto).join(' '));
    const naLegenda = precosDe(social.conteudo.legenda);
    const soNaImagem = [...naImagem].filter((p) => !naLegenda.has(p));
    // Os dois dão preços e nenhum coincide: é bloqueio (pedidosDaAprovacao).
    // Aqui fica só a omissão: a legenda não diz o preço da imagem.
    const contradicao = naImagem.size && naLegenda.size && ![...naImagem].some((p) => naLegenda.has(p));
    if (soNaImagem.length && !contradicao) {
      notas.push({ chave: `${social.id}:coerencia`, canal: 'social', criativo: social.id, titulo: 'A imagem e a legenda não batem',
        texto: `A imagem do post diz ${soNaImagem.map((p) => `${p} €`).join(' e ')}, e a legenda não. Corrige o texto dos painéis ou a legenda antes de aprovar.` });
    }
  }
  for (const cr of criativos || []) {
    if (cr.aprovado || cr.recusa) continue;
    if (cr.canal === 'video' && !videoPedido(campanha)) continue;
    const canal = cr.canal;
    if (cr.conteudo && cr.conteudo.aviso && canal !== 'video') {
      // A apresentação vem do servidor; vazia (só tinha linguagem interna), não aparece (Codex, 3 Out).
      const textoNota = 'aviso_cliente' in cr ? cr.aviso_cliente : cr.conteudo.aviso;
      const pedeDecisao = 'aviso_decisao' in cr ? cr.aviso_decisao : notaPedeDecisao(cr.conteudo.aviso);
      // Uma nota que pede decisão aparece sempre: com a apresentação vazia, a original.
      const mostrar = textoNota || (pedeDecisao ? cr.conteudo.aviso : null);
      if (mostrar) notas.push({ chave: `${cr.id}:aviso`, canal, criativo: cr.id, titulo: 'Uma nota da IA', texto: semJargao(mostrar), tecnica: !pedeDecisao });
    }
    if (cr.desactualizado) {
      notas.push({ chave: `${cr.id}:oferta`, canal, criativo: cr.id, titulo: 'A oferta mudou depois deste texto',
        texto: `${cr.oferta_em_falta && cr.oferta_em_falta.length
          ? `O briefing diz ${[...new Set(cr.oferta_em_falta)].join(' e ')}, e o texto não. `
          : 'O briefing mudou (a mensagem, o link ou as datas). '}Corrige o texto, escreve-o outra vez, ou confirma aqui que continua certo.` });
    }
    if (cr.datas_depois_do_fim && cr.datas_depois_do_fim.length) {
      notas.push({ chave: `${cr.id}:datas`, canal, criativo: cr.id, titulo: 'Uma data depois do fim da campanha',
        texto: `O texto fala de ${[...new Set(cr.datas_depois_do_fim)].map((x) => `"${x}"`).join(', ')}, depois do fim. Se é de propósito (uma entrega ou uma validade), confirma-o aqui. Senão, corrige o texto.` });
    }
    if (confirmacoesDoCriativo(cr).promessas_certas) {
      const frases = cr.por_confirmar.frases && cr.por_confirmar.frases.length ? cr.por_confirmar.frases : null;
      notas.push({ chave: `${cr.id}:promessas`, canal, criativo: cr.id, titulo: 'Promessas por confirmar',
        texto: frases
          ? 'O briefing e os factos da marca não dizem isto. Se é verdade tal como está escrito, confirma-o aqui: a loja vai ter de o cumprir.'
          : `O texto promete ${[...new Set(cr.por_confirmar.promessas)].map((x) => `"${x}"`).join(', ')}, e o briefing e os factos da marca não o dizem. Se é verdade, confirma-o aqui.`,
        frases });
    }
    // A NetScreen aceitou a excepção (055): diz-se, com a nota, em vez de o
    // bloqueio desaparecer sem explicação (ensaio, 2 Out).
    for (const x of (cr.excecoes || []).filter((e) => e.estado === 'aceite')) {
      if (!((cr.por_confirmar && cr.por_confirmar.provas) || []).some((p) => p.tipo === x.tipo)) continue;
      notas.push({ chave: `${cr.id}:aceite:${x.tipo}`, canal, criativo: cr.id, titulo: 'Revisto pela NetScreen', tecnica: true,
        texto: `A NetScreen aceitou a excepção${x.nota ? `: «${x.nota}»` : '.'} Pode sair assim.` });
    }
    if (confirmacoesDoCriativo(cr).avisos_certos) {
      notas.push({ chave: `${cr.id}:avisos`, canal, criativo: cr.id, titulo: 'Antes de aprovar, confirma',
        // Um aviso que já pede para corrigir ou confirmar não leva a frase outra vez (ensaio de 3 Out).
        texto: cr.por_confirmar.avisos.map((a) => a.replace(/\s*Corrige o texto, ou confirma que fica assim\.\s*$/, '')).join(' ')
          + ' Corrige o texto, ou confirma aqui que fica assim.' });
    }
  }
  return notas;
}

const nomeDoCanal = (c) => (c === 'video' ? 'Vídeo' : (CANAIS_CAMPANHA.find((x) => x.chave === c) || {}).rotulo || c);

/**
 * O mesmo ponto em dois canais ("o texto fala de datas", "Copiado de …") é uma
 * linha só, com as etiquetas dos dois canais: a repetição por canal era o que
 * enchia o ecrã de caixas iguais.
 */
function juntarPorTexto(itens) {
  const semCanal = (i) => (i.canal && !i.titulo ? i.texto.replace(`${nomeDoCanal(i.canal)}: `, '') : i.texto);
  const grupos = [];
  for (const i of itens) {
    const texto = semCanal(i);
    const chave = `${i.titulo || ''}|${texto}|${(i.frases || []).join('|')}`;
    const g = grupos.find((x) => x.chave === chave);
    if (g) { g.canais.push(i.canal); g.itens.push(i); } else {
      grupos.push({ chave, titulo: i.titulo, texto: texto.charAt(0).toUpperCase() + texto.slice(1), frases: i.frases, onde: i.onde, canais: i.canal ? [i.canal] : [], itens: [i] });
    }
  }
  return grupos;
}

/**
 * O post que ficou à espera da revisão da NetScreen (F5, 2 Out), com os painéis
 * já aprovados ou no ar: o que falta (pedir a revisão, esperar por ela), as
 * confirmações do post, e "Aprovar o post", que o põe a sair. As mesmas regras
 * da aprovação da campanha, só para o post.
 */
function PostAdiado({ slug, campanha, criativos, lojas, podeGerir, onPedirRevisao, onCorrigir, onMudou }) {
  const [marcadas, setMarcadas] = React.useState({});
  const [aEnviar, setAEnviar] = React.useState(false);
  const [erro, setErro] = React.useState(null);
  const post = (criativos || []).find((c) => c.canal === 'social');
  if (!post) return null;
  const { bloqueios, decisoes } = pedidosDaAprovacao({ ...campanha, canais: ['social'] }, criativos, lojas, [], { semVideo: true });
  const pronto = !bloqueios.length && decisoes.every((d) => marcadas[d.chave]);
  const noAr = campanha.estado === 'ativa';
  const aprovar = async () => {
    setAEnviar(true);
    setErro(null);
    try {
      await api.post(`/api/${slug}/campanhas/${campanha.id}/canais-adiados/social/aprovar`, { visto: post.id, confirmacoes: confirmacoesDe(decisoes, marcadas) });
      onMudou();
    } catch (e) {
      setErro(e.message);
    } finally {
      setAEnviar(false);
    }
  };
  return (
    <section className="mb-5 rounded-md border border-border p-4">
      {/* Aceite a revisão, o que falta é aprovar (ensaio de 2 Out). "A publicar", e
          não "no ar": as entregas dos painéis podem ainda estar por enviar. */}
      <p className="text-sm font-semibold text-foreground">
        {bloqueios.some((b) => b.onde === 'prova') ? 'O post das redes está à espera da revisão da NetScreen' : 'O post das redes pode sair: falta aprová-lo'}
      </p>
      <p className="mt-1 text-sm text-muted-foreground">
        {noAr ? 'Os painéis já estão a publicar.' : 'Os painéis já estão aprovados.'}{' '}
        {bloqueios.some((b) => b.onde === 'prova') ? 'O post sai quando a NetScreen aceitar a revisão e o aprovares aqui.' : 'O post sai quando o aprovares aqui.'}
      </p>
      {bloqueios.length > 0 && (
        <ul className="mt-3 divide-y divide-border">
          {bloqueios.map((b) => (
            <li key={b.chave || b.texto} className="flex flex-col gap-2 py-2 sm:flex-row sm:items-start sm:justify-between">
              <p className="text-sm text-foreground">{b.texto}</p>
              {podeGerir && b.onde === 'prova' && b.pedido !== 'pedida' && (
                <button type="button" onClick={() => onPedirRevisao(b)} className="ns-btn ns-btn-sm ns-btn-suave shrink-0 text-xs">
                  {b.pedido === 'recusada' ? 'Pedir outra vez' : 'Pedir revisão'}
                </button>
              )}
              {/* O post adiado ainda não saiu: corrige-se, também com a campanha no ar (2 Out). */}
              {podeGerir && b.criativo && b.onde !== 'prova' && (
                <button type="button" onClick={() => onCorrigir(b.criativo)} className="ns-btn ns-btn-sm ns-btn-suave shrink-0 text-xs">Corrigir o texto</button>
              )}
            </li>
          ))}
        </ul>
      )}
      {!bloqueios.length && decisoes.map((d) => (
        <label key={d.chave} htmlFor={`adiado-${d.chave}`} className="mt-3 flex cursor-pointer items-start gap-2 text-sm text-foreground">
          <input
            id={`adiado-${d.chave}`}
            type="checkbox"
            checked={Boolean(marcadas[d.chave])}
            onChange={(e) => { const sim = e.target.checked; setMarcadas((m) => ({ ...m, [d.chave]: sim })); }}
            className="mt-0.5 accent-primary"
          />
          <span>{d.texto}</span>
        </label>
      ))}
      {podeGerir && !pronto && (
        <p className="mt-3 text-xs text-muted-foreground">
          {bloqueios.length ? 'Aprova-se quando o que está acima estiver resolvido.' : 'Marca as confirmações acima para aprovar.'}
        </p>
      )}
      {podeGerir && (
        <button
          type="button"
          disabled={!pronto || aEnviar}
          onClick={aprovar}
          className="mt-3 bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
        >
          {noAr ? 'Aprovar o post e publicá-lo' : 'Aprovar o post'}
        </button>
      )}
      {erro && <p className="mt-2 text-xs text-perigo-texto" role="alert">{erro}</p>}
    </section>
  );
}

function AntesDeAprovar({ onConta = null, onCriar = null, bloqueios, notas, decisoes = [], confirmadas = {}, onConfirmar, onReler, onActualizarVideo, onJuntarCondicoes, onSemVideo, semVideo = false, onAdiarPost, adiarPost = false, podeGerir, onBriefing, onCorrigir, onEscrever, onPedirRevisao, onMarca = null }) {
  if (!bloqueios.length && !notas.length && !decisoes.length && !semVideo && !adiarPost) return null;
  // As decisões confirmam-se aqui, na nota que as explica (advogado da
  // simplicidade, 28 Set): o modal de aprovar repetia as mesmas promessas.
  const decisaoDe = new Map(decisoes.map((d) => [d.chave, d]));
  // As notas técnicas não levam botão: não há nada a resolver.
  // As acções de um grupo: o briefing uma vez; corrigir ou escrever, uma por canal.
  const accoes = (g) => {
    if (!podeGerir || g.itens.every((i) => i.tecnica)) return [];
    if (g.onde === 'briefing') return [['Abrir o briefing', onBriefing]];
    if (g.onde === 'marca') return onMarca ? [['Ir a A minha marca', onMarca]] : [];
    if (g.onde === 'conta') return onConta ? [['Guardar o perfil em Conta', onConta]] : [];
    if (g.onde === 'criar') return onCriar ? [['Abrir o vídeo', onCriar]] : [];
    // Um texto que não pôde sair: escrever outra vez, ou corrigir à mão (agentes, 20 Out).
    if (g.itens.every((i) => i.recusa && i.canal !== 'video')) {
      const varios = g.itens.length > 1;
      return g.itens.flatMap((i) => [
        [varios ? `Escrever outra vez: ${nomeDoCanal(i.canal)}` : 'Escrever outra vez', () => onEscrever(i.canal)],
        [varios ? `Corrigir à mão: ${nomeDoCanal(i.canal)}` : 'Corrigir à mão', () => onCorrigir(i.criativo)],
      ]);
    }
    if (g.onde === 'reler') return onReler ? [['Tentar outra vez', onReler]] : [];
    if (g.onde === 'legenda') {
      return [
        ...(onJuntarCondicoes ? [['Juntar as condições à legenda', onJuntarCondicoes]] : []),
        ['Corrigir a legenda', () => onCorrigir(g.itens[0].criativo)],
      ];
    }
    // O que exige prova (055): corrigir o texto, ou pedir a revisão da NetScreen.
    // À espera da decisão, só corrigir.
    if (g.onde === 'prova') {
      return [
        ...g.itens.flatMap((i) => [
          [i.canal ? `Corrigir: ${nomeDoCanal(i.canal)}` : 'Corrigir o texto', () => onCorrigir(i.criativo)],
          ...(i.pedido !== 'pedida' && onPedirRevisao ? [[i.pedido === 'recusada' ? 'Pedir outra vez' : 'Pedir revisão', () => onPedirRevisao(i)]] : []),
        ]),
        // Só o post espera a prova: os painéis não dependem dela, e saem (F5, 2 Out).
        ...(onAdiarPost && g.itens.every((i) => i.canal === 'social') ? [['Publicar sem o post por agora', () => onAdiarPost(true)]] : []),
      ];
    }
    if (g.onde === 'video' || g.itens.every((i) => i.canal === 'video')) {
      return [
        // Sem IA, quando o que o vídeo repete não mudou (teste de raiz, 9 Out); o servidor confere e diz se não dá.
        ...(onActualizarVideo && g.itens.some((i) => i.actualizavel) ? [['Actualizar o vídeo, sem o escrever outra vez', onActualizarVideo]] : []),
        ['Ir ao vídeo', () => { const el = document.getElementById('video'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }],
        // Um vídeo que falhou não prende os cartazes nem o post (parecer do Codex, 28 Set).
        ...(onSemVideo ? [['Publicar sem o vídeo por agora', () => onSemVideo(true)]] : []),
      ];
    }
    // Sempre com o canal: cinco «Corrigir o texto» iguais levavam a sítios diferentes (teste de raiz, 9 Out).
    const varios = g.itens.length > 1;
    return g.itens.map((i) => (i.criativo
      ? [i.canal ? `Corrigir: ${nomeDoCanal(i.canal)}` : 'Corrigir o texto', () => onCorrigir(i.criativo)]
      : i.canal ? [varios ? `Escrever: ${nomeDoCanal(i.canal)}` : 'Escrever este canal', () => onEscrever(i.canal)] : null)).filter(Boolean);
  };
  // Uma função, e não um componente definido aqui dentro: um componente novo a cada
  // desenho desmontava as notas a cada clique, e o foco caía no body (ensaio de 3 Out).
  const linha = (g, tom) => {
    const as = accoes(g);
    return (
      // Com mais de duas acções, os botões vão por baixo do texto: ao lado, empurravam-no para uma palavra
      // por linha e saíam da caixa (utilizador virgem, 4 Nov, a 1440 px).
      <li key={g.chave} className={`flex flex-col gap-3 py-3 first:pt-0 last:pb-0 ${as.length > 2 ? '' : 'sm:flex-row sm:items-start sm:justify-between'}`}>
        <div className="min-w-0 flex-1">
          {(g.canais.length > 0 || g.titulo) && (
            <p className="mb-1 flex flex-wrap items-center gap-1.5 text-sm">
              {g.canais.map((c) => <Badge key={c} variante="contorno">{nomeDoCanal(c)}</Badge>)}
              {g.titulo && <span className={`font-medium ${tom}`}>{g.titulo}</span>}
            </p>
          )}
          <p className="text-sm leading-relaxed text-foreground">{g.texto}</p>
          {g.frases && (
            <ul className="mt-1 space-y-0.5">
              {g.frases.map((f) => <li key={f} className="text-sm leading-relaxed text-muted-foreground">{`«${f}»`}</li>)}
            </ul>
          )}
          {podeGerir && onConfirmar && (() => {
            // A mesma frase em vários canais é uma decisão só (2 Out: a redução
            // de preço pedia-se três vezes, no post, nos painéis e no vídeo).
            const comDecisao = g.itens.filter((i) => decisaoDe.has(i.chave));
            if (!comDecisao.length) return null;
            const chave = comDecisao[0].chave;
            const d = decisaoDe.get(chave);
            return (
              <label htmlFor={`decisao-${chave}`} className="mt-2 flex cursor-pointer items-center gap-2 text-sm font-medium text-foreground">
                <input
                  id={`decisao-${chave}`}
                  type="checkbox"
                  checked={comDecisao.every((i) => confirmadas[i.chave])}
                  onChange={(e) => { const sim = e.target.checked; comDecisao.forEach((i) => onConfirmar(i.chave, sim)); }}
                  className="accent-primary"
                />
                {ROTULO_DECISAO[d.campo] || 'Confirmo'}
              </label>
            );
          })()}
        </div>
        {as.length > 0 && (
          <div className={`flex flex-wrap gap-2 ${as.length > 2 ? '' : 'sm:max-w-[50%] sm:shrink-0 sm:justify-end'}`}>
            {as.map(([rotulo, fazer]) => (
              <button key={rotulo} type="button" onClick={fazer} className="ns-btn ns-btn-sm ns-btn-suave text-xs">{rotulo}</button>
            ))}
          </div>
        )}
      </li>
    );
  };
  // O que ainda está a carregar («A carregar as cenas do vídeo…») não é uma coisa a resolver:
  // mostra-se a trabalhar, e não na caixa vermelha (8 Out).
  const aCarregar = bloqueios.filter((b) => /^A carregar/.test(b.texto));
  const gB = juntarPorTexto(bloqueios.filter((b) => !/^A carregar/.test(b.texto)));
  // "Publicar sem o vídeo por agora" diz-se, e desfaz-se (revisor, 28 Set).
  const avisoSemVideo = semVideo && onSemVideo ? (
    <p className="rounded-md border border-border px-4 py-2.5 text-xs text-muted-foreground">
      O vídeo fica para depois: a campanha publica-se sem ele.{' '}
      <button type="button" onClick={() => onSemVideo(false)} className="underline underline-offset-2 hover:text-foreground">Incluir o vídeo outra vez</button>
    </p>
  ) : null;
  const avisoAdiado = adiarPost && onAdiarPost ? (
    <p className="rounded-md border border-border px-4 py-2.5 text-xs text-muted-foreground">
      O post fica para depois: os painéis publicam-se já, e o post sai quando a NetScreen aceitar a revisão e o aprovares.{' '}
      <button type="button" onClick={() => onAdiarPost(false)} className="underline underline-offset-2 hover:text-foreground">Incluir o post outra vez</button>
    </p>
  ) : null;
  // Sem o vídeo (ou sem o post), as notas dele também saem: não se aprova agora.
  // Uma decisão sem nota que a explique ficava pendente sem caixa à vista ("Falta
  // confirmar 1 coisa", ensaio de 3 Out): aparece com o seu próprio texto.
  const visiveis = notas.filter((n) => !n.tecnica && !(semVideo && n.canal === 'video') && !(adiarPost && n.canal === 'social'));
  // Contra todas as notas: uma escondida de propósito (sem vídeo, post adiado) não é órfã.
  const explicadas = new Set(notas.map((n) => n.chave));
  const orfas = decisoes.filter((d) => !explicadas.has(d.chave))
    .map((d) => ({ chave: d.chave, criativo: d.criativo, canal: d.canal, titulo: 'Para confirmar', texto: d.texto }));
  const gN = juntarPorTexto([...visiveis, ...orfas]);
  const gT = juntarPorTexto(notas.filter((n) => n.tecnica));
  return (
    <section id="antes-de-aprovar" className="mb-5 space-y-4 scroll-mt-4">
      {avisoSemVideo}
      {avisoAdiado}
      {aCarregar.map((b) => <AProcessar key={b.texto} texto={b.texto.replace(/…$/, '')} />)}
      {gB.length > 0 && (
        <div className="rounded-md border border-[hsl(var(--offline)/0.45)] bg-[hsl(var(--offline)/0.06)] p-4">
          <p className="text-sm font-semibold text-foreground">
            {gB.length === 1 ? 'Falta resolver 1 coisa antes de aprovar' : `Falta resolver ${gB.length} coisas antes de aprovar`}
          </p>
          <ul className="mt-3 divide-y divide-border">
            {gB.map((g) => linha(g, "text-perigo-texto"))}
          </ul>
        </div>
      )}
      {gN.length > 0 && (
        <div className="rounded-md border border-[hsl(var(--accent)/0.4)] bg-[hsl(var(--accent)/0.05)] p-4">
          <p className="text-sm font-semibold text-foreground">
            {gN.length === 1 ? '1 nota para ler antes de aprovar' : `${gN.length} notas para ler antes de aprovar`}
          </p>
          {decisoes.length > 0 && (
            <p className="mt-0.5 text-xs text-muted-foreground">As que têm uma caixa confirmam-se aqui, antes de aprovar.</p>
          )}
          <ul className="mt-3 divide-y divide-border">
            {gN.map((g) => linha(g, "text-accent"))}
          </ul>
        </div>
      )}
      {gT.length > 0 && (
        <details className="rounded-md border border-border px-4 py-2.5">
          <summary className="text-xs text-muted-foreground">{`Notas da IA (${gT.length}) · não pedem nada`}</summary>
          <ul className="mt-3 divide-y divide-border">
            {gT.map((g) => linha({ ...g, titulo: null }, ""))}
          </ul>
        </details>
      )}
    </section>
  );
}

/**
 * Pedir à NetScreen a revisão de um texto que exige prova (migração 055, 2 Out):
 * o motivo, a evidência e, se houver, um ficheiro da Biblioteca. Sem mínimo de
 * caracteres: quem decide é a NetScreen, não uma regra de tamanho.
 */
function ModalPedirRevisao({ slug, campanhaId, pedido, onFechar, onPedido }) {
  const [motivo, setMotivo] = React.useState('');
  const [evidencia, setEvidencia] = React.useState('');
  const [ficheiro, setFicheiro] = React.useState('');
  const [ficheiros, setFicheiros] = React.useState(null);
  const [aEnviar, setAEnviar] = React.useState(false);
  const [erro, setErro] = React.useState(null);
  React.useEffect(() => {
    if (!pedido) return;
    // Pedir outra vez abre com o que se tinha escrito (ensaio, 2 Out).
    const antes = pedido.anterior || {};
    setMotivo(antes.motivo || ''); setEvidencia(antes.evidencia || ''); setFicheiro(antes.material_id || ''); setErro(null);
    api.get(`/api/${slug}/materiais`).then((l) => setFicheiros(l.filter((m) => !m.campanha_id || m.campanha_id === campanhaId)))
      .catch(() => setFicheiros([]));
  }, [pedido && pedido.chave, slug, campanhaId]);
  if (!pedido) return null;
  const enviar = async () => {
    setAEnviar(true); setErro(null);
    try {
      await api.post(`/api/${slug}/campanhas/${campanhaId}/criativos/${pedido.criativo}/excecoes`, {
        tipo: pedido.tipo, motivo, evidencia: evidencia || null, material_id: ficheiro || null,
      });
      onPedido();
    } catch (e) { setErro(e.message); } finally { setAEnviar(false); }
  };
  return (
    <Modal
      aberto
      onFechar={onFechar}
      titulo="Pedir a revisão da NetScreen"
      acoes={(
        <>
          <button type="button" onClick={onFechar} className="border border-border px-4 py-2 text-sm hover:bg-secondary">Cancelar</button>
          <button type="button" disabled={aEnviar || !motivo.trim()} onClick={enviar}
            className="bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50">
            {aEnviar ? 'A enviar…' : 'Pedir a revisão'}
          </button>
        </>
      )}
    >
      <div className="space-y-4">
        <p className="text-sm leading-relaxed text-muted-foreground">{pedido.aviso}</p>
        <label className="block text-sm">
          <span className="font-medium text-foreground">Porque é que o texto deve sair assim</span>
          <textarea value={motivo} onChange={(e) => setMotivo(e.target.value)} rows={3}
            className="mt-1 w-full border border-border bg-background px-3 py-2 text-sm" />
        </label>
        <label className="block text-sm">
          <span className="font-medium text-foreground">A evidência</span>
          <span className="block text-xs text-muted-foreground">{pedido.pede}</span>
          <textarea value={evidencia} onChange={(e) => setEvidencia(e.target.value)} rows={2}
            className="mt-1 w-full border border-border bg-background px-3 py-2 text-sm" placeholder="Uma referência, um link, a norma" />
        </label>
        <label className="block text-sm">
          <span className="font-medium text-foreground">Um ficheiro da Biblioteca (opcional)</span>
          <select value={ficheiro} onChange={(e) => setFicheiro(e.target.value)} disabled={ficheiros === null}
            className="mt-1 w-full border border-border bg-background px-3 py-2 text-sm">
            <option value="">Nenhum</option>
            {(ficheiros || []).map((m) => <option key={m.id} value={m.id}>{m.original_name}</option>)}
          </select>
        </label>
        {erro && <p className="text-xs text-perigo-texto" role="alert">{erro}</p>}
      </div>
    </Modal>
  );
}

// O que se pede como evidência, por tipo (o mesmo que lib/excecoes.js).
const PEDE_POR_TIPO = {
  recrutamento: 'O fundamento legal para o requisito (a excepção prevista na lei).',
  passatempo: 'O regulamento e, quando se aplica, a autorização da câmara municipal.',
  garantia: 'A prova adequada à alegação (estudo, dados, certificação).',
};

/**
 * O que pode melhorar nas fontes (18 Out; docs/specs/2026-10-18-rever-depois-do-rascunho.md).
 * O rascunho sai com o que há; aqui diz-se o ficheiro que limita a qualidade e onde se troca.
 * Medido nos bytes, sem custo. Só para quem pode mexer (o perfil Leitura não recebe tarefas).
 */
function MelhorarFontes({ slug, campanha, onNavegar }) {
  const [itens, setItens] = React.useState(null);
  React.useEffect(() => {
    let vivo = true;
    api.get(`/api/${slug}/campanhas/${campanha.id}/qualidade`)
      .then((r) => { if (vivo) setItens(r.itens || []); })
      .catch(() => { if (vivo) setItens([]); });
    return () => { vivo = false; };
  }, [slug, campanha.id, campanha.updated_at]);
  if (!itens || !itens.length) return null;
  const irFotografia = () => {
    const el = document.getElementById('fotografia-da-campanha');
    if (!el) return;
    el.scrollIntoView({ behavior: 'smooth', block: 'start' });
    // Num ecrã largo a secção já está à vista e a página quase não mexe: o contorno diz onde é (19 Out).
    Object.assign(el.style, { outline: '2px solid hsl(var(--primary))', outlineOffset: '8px', borderRadius: '6px' });
    clearTimeout(el._contorno);   // um segundo clique conta 2,4 s a partir dele, e não do primeiro
    el._contorno = setTimeout(() => Object.assign(el.style, { outline: '', outlineOffset: '', borderRadius: '' }), 2400);
  };
  return (
    <section aria-labelledby="melhorar-fontes" className="mb-6 rounded-md border border-border bg-card p-4">
      <h2 id="melhorar-fontes" className="text-sm font-semibold text-foreground">O que pode melhorar</h2>
      <p className="mt-1 text-xs text-muted-foreground">As peças saem com os ficheiros que há, e estes limitam a qualidade. Trocá-los não custa nada: as imagens por aprovar redesenham-se.</p>
      <ul className="mt-3 divide-y divide-border">
        {itens.map((i) => (
          <li key={i.chave} className="flex flex-col gap-2 py-2.5 sm:flex-row sm:items-start sm:justify-between">
            <div className="min-w-0">
              <p className="flex items-center gap-2 text-sm text-foreground">
                <Badge variante={i.nivel === 'fraco' ? 'erro' : 'contorno'}>{i.nivel === 'fraco' ? 'Fraco' : 'Pode melhorar'}</Badge>
                {i.titulo}
              </p>
              <p className="mt-1 text-xs leading-relaxed text-muted-foreground">{i.texto}</p>
            </div>
            {i.accao === 'logo' && onNavegar && (
              <button type="button" onClick={() => onNavegar(`/${slug}/marca/identidade#logotipo`)} className="ns-btn ns-btn-sm ns-btn-suave shrink-0 text-xs">{i.semLogo ? 'Escolher o logótipo' : 'Trocar o logótipo'}</button>
            )}
            {i.accao === 'fotografia' && (
              <button type="button" onClick={irFotografia} className="ns-btn ns-btn-sm ns-btn-suave shrink-0 text-xs">Ir à fotografia</button>
            )}
          </li>
        ))}
      </ul>
    </section>
  );
}

function PaginaCampanha({ podeGerir = false, onNavegar, slug, campanhas, aberta, detalhe, criativos, erroCriativos, info, ocupado, aGerar, onVoltar, onPedirTransicao, onEditar, onAprovar, onGerar, onMudou, onEditarCriativo, onRepetir, onEditando, confirmadas = {}, onConfirmar, semVideo = false, videoProblema = null, onSemVideo, adiarPost = false, onAdiarPost, lerVideo = 0, onEntradasVideo, entradasVideo = null, videoFalhou = false, releitura = null, onReler, onActualizarVideo, onJuntarCondicoes }) {
  const [aEditar, setAEditar] = React.useState(null);
  const [revisaoPedida, setRevisaoPedida] = React.useState(null);
  const [erroRever, setErroRever] = React.useState(null);
  // Com um texto a ser corrigido, o ecrã não se actualiza sozinho: uma versão
  // nova vinda de outra pessoa fechava o editor e perdia-se o que se escrevia.
  React.useEffect(() => {
    if (onEditando) onEditando(Boolean(aEditar));
  }, [aEditar, onEditando]);
  const voltar = (
    <button
      type="button"
      onClick={onVoltar}
      className="label-eyebrow outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-accent"
    >
      Campanhas
    </button>
  );

  // A revisão só com o detalhe desta campanha lido (as lojas vêm dele), e só
  // antes de publicada: depois, não há nada para aprovar.
  const emRevisao = aberta && ['draft', 'em_revisao', 'aprovada'].includes(aberta.estado)
    && criativos && detalhe && detalhe.id === aberta.id;
  const revisao = emRevisao ? pedidosDaAprovacao(aberta, criativos, detalhe.lojas || [], detalhe.formatos_sem_obrigatoria, { semVideo, videoProblema, imagemDoPost: detalhe.imagem_do_post, adiarPost, redesSemPerfil: detalhe.redes_sem_perfil, videoCriarPorAprovar: detalhe.video_criar_por_aprovar, videoPorVer: !entradasVideo, videoFalhou, faixasTextoPequeno: detalhe.faixas_texto_pequeno, semCondicoes: detalhe.formatos_sem_condicoes, releitura, marca: marcaDoDetalhe(detalhe) }) : null;
  const bloqueios = revisao ? revisao.bloqueios : [];
  const decisoes = revisao ? revisao.decisoes : [];
  // A marca por confirmar vem primeiro, com a sua nota e o caminho para a confirmar (1.9 dos agentes, 20 Out).
  const decisaoDaMarca = decisoes.find((d) => d.campo === 'marca_por_confirmar');
  const notas = emRevisao ? [
    ...(decisaoDaMarca ? [{ chave: decisaoDaMarca.chave, onde: 'marca', titulo: detalhe.marca_por_confirmar ? 'A marca ainda não está confirmada' : 'Sem logótipo',
      texto: `${decisaoDaMarca.texto} ${detalhe.marca_por_confirmar ? 'Confirma-a em A minha marca' : 'Escolhe-o em A minha marca'}, ou aprova assim.` }] : []),
    ...notasDaRevisao(criativos, aberta),
  ] : [];
  // Uma caixa confirma a mesma frase em vários canais: conta-se uma vez, como se vê em
  // «notas para ler» («Falta confirmar 2 coisas» com 1 nota, agentes, 20 Out).
  const notaDe = new Map(notas.map((n) => [n.chave, n]));
  const porConfirmar = new Set(decisoes.filter((d) => !confirmadas[d.chave])
    .map((d) => (notaDe.has(d.chave) ? `${notaDe.get(d.chave).titulo || ''}|${notaDe.get(d.chave).texto}` : d.chave))).size;
  // «Escrever outra vez» sobre um texto corrigido à mão perdia a correcção sem perguntar (1.4 dos
  // agentes, 20 Out): pergunta-se antes, a dizer quais. `canais` null é a campanha toda.
  const [aReescrever, setAReescrever] = React.useState(null);
  const pedirEscrita = (canais = null) => {
    const manuais = (criativos || []).filter((c) => c.canal !== 'video' && c.modelo === 'manual'
      && (canais ? canais.includes(c.canal) : (aberta.canais || []).includes(c.canal)));
    const doCanal = { social: 'das redes sociais', dooh: 'dos painéis', pdv: 'do assistente na loja' };
    if (manuais.length) return setAReescrever({ canais, nomes: manuais.map((c) => doCanal[c.canal] || nomeDoCanal(c.canal)) });
    onGerar(aberta, canais || undefined);
  };
  const irCorrigir = (id) => {
    setAEditar(id);
    setTimeout(() => {
      const el = document.getElementById(`criativo-${id}`);
      if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
    }, 50);
  };
  const verRevisao = () => {
    const el = document.getElementById('antes-de-aprovar');
    if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  };

  if (!campanhas) return <KanbanSkeleton />;
  if (!aberta) {
    return (
      <div className="space-y-6">
        <p>{voltar}</p>
        <div className="border border-border bg-card px-6 py-12 text-center">
          <p className="font-display text-xl tracking-wide">Esta campanha não existe nesta conta</p>
          <p className="mt-2 text-sm text-muted-foreground">Pode ter sido apagada, ou o endereço estar errado.</p>
        </div>
      </div>
    );
  }

  return (
    <div className="space-y-6">
      <header className="flex flex-wrap items-end justify-between gap-4">
        <div className="min-w-0">
          <p className="flex items-center gap-2">
            {voltar}
            <span className="label-eyebrow" aria-hidden="true">/</span>
            <Badge variante={rotuloDoCartao(aberta).variante}>{rotuloDoCartao(aberta).rotulo}</Badge>
            {rotuloDoCartao(aberta).detalhe && <span className="text-xs text-muted-foreground">{rotuloDoCartao(aberta).detalhe}</span>}
          </p>
          <h1 className="mt-1 font-display text-4xl uppercase leading-none tracking-wide">{aberta.titulo}</h1>
          <p className="mt-2 text-sm text-muted-foreground">
            {/* Sem objectivo nem datas saía "· — a —" (28 Set). */}
            {[OBJECTIVOS[aberta.objectivo], aberta.data_inicio || aberta.data_fim
              ? `${formatDataCurta(aberta.data_inicio)} a ${formatDataCurta(aberta.data_fim)}`
              : 'Sem datas escolhidas'].filter(Boolean).join(' · ')}
          </p>
          {aberta.estado === 'aprovada' && detalhe && detalhe.id === aberta.id && detalhe.falta_aprovar && detalhe.falta_aprovar.length > 0 && (
            <p className="mt-2 text-xs text-accent" role="status">
              {`Ficou aprovada antes de estar tudo aprovado. ${detalhe.falta_aprovar.join(' ')}`}
            </p>
          )}
        </div>
        {/* Secundárias primeiro, o principal no fim (28 Set). */}
        <div className="flex flex-wrap items-center gap-2">
          {podeGerir && (
            <button
              type="button"
              disabled={ocupado}
              onClick={() => onRepetir(aberta)}
              title="Uma campanha nova com o mesmo briefing, lojas, fotografia e textos. As datas ficam por definir."
              className="border border-border px-4 py-2 text-sm hover:bg-secondary disabled:opacity-50"
            >
              Repetir
            </button>
          )}
          {podeGerir && <RodapeTransicoes
            campanha={aberta}
            faltaAprovar={detalhe && detalhe.id === aberta.id ? detalhe.falta_aprovar : null}
            onPedirTransicao={onPedirTransicao}
            onEditar={onEditar}
            falta={['draft', 'em_revisao'].includes(aberta.estado) && aGerar ? 'A escrever os anúncios…'
              : ['draft', 'em_revisao'].includes(aberta.estado) && !(detalhe && detalhe.id === aberta.id) ? 'A ler a campanha…'
              : aberta.estado !== 'aprovada' && bloqueios.length ? (bloqueios[0].curto || 'Falta resolver antes de aprovar')
              : porConfirmar ? (porConfirmar === 1 ? 'Falta confirmar 1 coisa' : `Falta confirmar ${porConfirmar} coisas`) : null}
          />}
        </div>
      </header>

      {/* No ar, a fotografia já não se troca (o PUT dá 409): o cartão só antes (revisor, 18 Out). */}
      {podeGerir && !['ativa', 'encerrada'].includes(aberta.estado) && <MelhorarFontes slug={slug} campanha={aberta} onNavegar={onNavegar} />}
      {aberta.briefing?.criacao_video_id && <DestinosVideoCriar key={JSON.stringify([aberta.id,aberta.updated_at,aberta.briefing,detalhe?.lojas])} slug={slug} campanha={aberta} porAprovar={detalhe?.video_criar_por_aprovar||null}/>}
      <div className="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
        <div className="border border-border bg-card p-5">
          <SeccaoCampanha titulo="Anúncios">
            {/* Cartazes fixados antes de a frase obrigatória estar garantida, numa
                campanha no ar: vêem-se abaixo como vão sair agora, e aprovam-se
                outra vez antes de a NetScreen os enviar (segundo parecer do Codex). */}
            {detalhe && detalhe.id === aberta.id && detalhe.cartazes_por_rever && (
              <div className="mb-5 rounded-md border border-[hsl(var(--accent)/0.4)] bg-[hsl(var(--accent)/0.05)] p-4 text-sm">
                <p className="font-semibold text-foreground">Os cartazes precisam de ser revistos</p>
                <p className="mt-1 text-foreground">
                  {detalhe.cartazes_por_rever === 'rever'
                    ? 'Foram aprovados antes de a frase obrigatória da tua marca estar garantida em todos os formatos. As imagens abaixo já mostram como vão sair. Aprova-as, e a NetScreen envia-as.'
                    : 'Foram aprovados antes de a frase obrigatória da tua marca estar garantida, e algum já foi enviado. A NetScreen vai falar contigo.'}
                </p>
                {detalhe.cartazes_por_rever === 'rever' && podeGerir && (
                  <button
                    type="button"
                    disabled={ocupado}
                    onClick={async () => {
                      setErroRever(null);
                      try { await api.post(`/api/${slug}/campanhas/${aberta.id}/cartazes/rever`, {}); onMudou(); }
                      catch (e) { setErroRever(e.message); }
                    }}
                    className="mt-3 bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
                  >
                    Aprovar os cartazes como estão agora
                  </button>
                )}
                {erroRever && <p className="mt-2 text-xs text-perigo-texto" role="alert">{erroRever}</p>}
              </div>
            )}
            {detalhe && !emRevisao && ['aprovada', 'ativa'].includes(aberta.estado) && (aberta.canais_adiados || []).includes('social') && (
              <PostAdiado
                slug={slug}
                campanha={aberta}
                criativos={criativos}
                lojas={detalhe.lojas || []}
                podeGerir={podeGerir}
                onPedirRevisao={(i) => setRevisaoPedida({ ...i, pede: PEDE_POR_TIPO[i.tipo] })}
                onCorrigir={irCorrigir}
                onMudou={onMudou}
              />
            )}
            {emRevisao && detalhe.sem_factos_confirmados && criativos && criativos.length > 0 && (
              // C1, 2 Out: gera-se sem factos, e diz-se. O texto só afirma o que o briefing diz.
              <p className="mb-3 border-l-2 border-border pl-3 text-sm text-muted-foreground" role="note">
                Escrito com as cores, o logótipo e a maneira de falar da marca, e só com o que está no briefing: não há factos da marca confirmados (serviços, prémios, garantias). Para os usar, confirma-os em A minha marca › Dados da marca.
              </p>
            )}
            <AntesDeAprovar
              // A escrever, «Falta escrever o criativo» com um botão «Escrever este canal» dizia
              // o contrário do que estava a acontecer (gravação de 8 Out): esses ficam de fora.
              bloqueios={aGerar ? bloqueios.filter((b) => !/^Falta escrever/.test(b.texto)) : bloqueios}
              notas={notas}
              decisoes={decisoes}
              confirmadas={confirmadas}
              onConfirmar={onConfirmar}
              onReler={onReler}
              onActualizarVideo={onActualizarVideo}
              onJuntarCondicoes={onJuntarCondicoes ? () => onJuntarCondicoes(aberta, criativos) : null}
              semVideo={semVideo}
              onSemVideo={onSemVideo}
              adiarPost={adiarPost}
              // Só com painéis: a imagem do post sai deles (F5, 2 Out).
              onAdiarPost={aberta.canais.includes('dooh') && aberta.canais.includes('social') ? onAdiarPost : null}
              podeGerir={podeGerir}
              onBriefing={() => onEditar(aberta)}
              onCorrigir={irCorrigir}
              onEscrever={(canal) => pedirEscrita([canal])}
              onPedirRevisao={(i) => setRevisaoPedida({ ...i, pede: PEDE_POR_TIPO[i.tipo] })}
              onMarca={onNavegar ? () => onNavegar(`/${slug}/marca`) : null}
              onConta={onNavegar ? () => onNavegar(`/${slug}/configuracao?voltar=${encodeURIComponent(window.location.pathname)}`) : null}
              onCriar={onNavegar && aberta && aberta.briefing && aberta.briefing.criacao_video_id ? () => onNavegar(`/${slug}/criar?video=${aberta.briefing.criacao_video_id}`) : null}
            />
            <Modal
              aberto={Boolean(aReescrever)}
              onFechar={() => setAReescrever(null)}
              papel="alertdialog"
              titulo="Escrever outra vez?"
              acoes={
                <>
                  <button type="button" data-foco-inicial onClick={() => setAReescrever(null)} className="border border-border px-4 py-2 text-sm hover:bg-secondary">
                    Manter as correcções
                  </button>
                  <button
                    type="button"
                    onClick={() => { const r = aReescrever; setAReescrever(null); onGerar(aberta, r.canais || undefined); }}
                    className="bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
                  >
                    Escrever outra vez
                  </button>
                </>
              }
            >
              {aReescrever && (
                <p>
                  {`Corrigiste à mão o texto ${aReescrever.nomes.join(' e o ')}. Escrever outra vez substitui ${aReescrever.nomes.length === 1 ? 'essa correcção' : 'essas correcções'} por um texto novo, e ${aReescrever.nomes.length === 1 ? 'ela perde-se' : 'elas perdem-se'}.`}
                </p>
              )}
            </Modal>
            <ModalPedirRevisao
              slug={slug}
              campanhaId={aberta.id}
              pedido={revisaoPedida}
              onFechar={() => setRevisaoPedida(null)}
              onPedido={() => { setRevisaoPedida(null); onMudou(); }}
            />
            {erroCriativos && (
              <p className="mb-3 text-sm text-accent" role="status">Não deu para ler os anúncios: {erroCriativos}</p>
            )}
            {criativos === null ? (
              <div className="loading-skeleton h-24 rounded-md" role="status" aria-label="A carregar os criativos" />
            ) : criativos.length === 0 && aGerar ? (
              // A escrever pela primeira vez: o lugar dos criativos mostra que está a trabalhar, e
              // não «Ainda não há criativos» durante meio minuto (inventário de 8 Out).
              <AProcessar
                variante="bloco"
                texto={`A IA está a escrever ${(Array.isArray(aGerar) ? aGerar.length : aberta.canais.length) === 1 ? 'o anúncio' : 'os anúncios, um por canal'}`}
                detalhe={`Na voz da marca, só com o que confirmaste. Leva uns ${(Array.isArray(aGerar) ? aGerar.length : aberta.canais.length) * 15} segundos.`}
              />
            ) : criativos.length === 0 ? (
              // Sem «dados da marca que confirmaste» quando não há nenhum (agentes, 20 Out).
              <p className="text-xs leading-relaxed text-muted-foreground">
                {detalhe && detalhe.sem_factos_confirmados
                  ? 'Ainda não há anúncios. A IA escreve-os a partir do briefing. Ainda não confirmaste nada em «Dados da marca» (produtos, preços, horários), por isso usa só o que escreveste aqui.'
                  : 'Ainda não há anúncios. A IA escreve-os a partir do briefing e dos dados da marca que confirmaste.'}
              </p>
            ) : (
              <ul className="space-y-3">
                {/* O storyboard do vídeo tem secção própria (28 Set). */}
                {criativos.filter((cr) => cr.canal !== 'video').map((cr) => (
                  <li key={cr.id} id={`criativo-${cr.id}`} className="scroll-mt-4 border border-border p-4">
                    <div className="flex flex-wrap items-baseline justify-between gap-2">
                      {/* O canal é o título do bloco: claro e com peso, não uma etiqueta cinzenta. */}
                      <p className="text-sm font-semibold text-foreground">
                        {(CANAIS_CAMPANHA.find((x) => x.chave === cr.canal) || {}).rotulo || cr.canal}
                        {/* Para onde vai o post (utilizadora virgem, ronda 6: «nunca aparecem Instagram nem Facebook»). */}
                        {cr.canal === 'social' && detalhe && Array.isArray(detalhe.redes_do_post) && detalhe.redes_do_post.length > 0 && <span className="font-normal text-muted-foreground">{` · ${detalhe.redes_do_post.join(' e ')}`}</span>}
                        {cr.versao > 1 ? ` · versão ${cr.versao}` : ''}
                      </p>
                      {/* «Recusado» soava à decisão de alguém (agentes, 20 Out). */}
                      {cr.recusa && <Badge variante="erro">Por refazer</Badge>}
                    </div>

                    {/* O aviso é o canal do modelo para quem revê (23 Set): antes dele,
                        uma dúvida do modelo ia parar ao texto do anúncio. */}
                    {/* A alteração da oferta decidida ao corrigir (revisão do Codex, 24
                        Set): quem aprova vê o que foi decidido e por quem, sem ter de o
                        confirmar outra vez. */}
                    {cr.decisao_oferta && (
                      <p className="mt-2 text-xs leading-relaxed text-muted-foreground">
                        {`Alteração da oferta confirmada${cr.decisao_oferta.por ? ` por ${cr.decisao_oferta.por}` : ''} ao corrigir: `}
                        {[
                          cr.decisao_oferta.antes.length ? `antes ${cr.decisao_oferta.antes.map((x) => `«${x}»`).join(', ')}` : null,
                          cr.decisao_oferta.depois.length ? `depois ${cr.decisao_oferta.depois.map((x) => `«${x}»`).join(', ')}` : null,
                        ].filter(Boolean).join('; ')}
                      </p>
                    )}

                    {/* As notas e os bloqueios deste canal estão em "Antes de aprovar",
                        no topo; aqui fica uma linha só, e o texto do anúncio à vista (28 Set). */}
                    {(() => {
                      // A razão de um texto por refazer também fica só em cima, com as saídas: repetia-se
                      // aqui, no topo e no editor (agentes, 20 Out).
                      const nB = bloqueios.filter((x) => x.criativo === cr.id).length;
                      const nN = notas.filter((x) => x.criativo === cr.id && !x.tecnica).length;
                      if (!nB && !nN) return null;
                      const partes = [
                        nB ? `${nB} ${nB === 1 ? 'coisa a resolver' : 'coisas a resolver'}` : null,
                        nN ? `${nN} ${nN === 1 ? 'nota' : 'notas'}` : null,
                      ].filter(Boolean).join(' e ');
                      return (
                        <p className="mt-2 text-xs text-muted-foreground">
                          <span className={nB ? 'text-perigo-texto' : 'text-accent'}>{partes}</span>
                          {' · '}
                          <button type="button" onClick={verRevisao} className="underline-offset-2 hover:text-foreground">ver o que falta</button>
                        </p>
                      );
                    })()}

                    {cr.recusa ? (
                      // A razão está em cima, com as saídas; aqui fica o texto que não pôde sair,
                      // para se ver o que foi apanhado.
                      <>
                        {textoDoCriativo(cr) && (
                          <p className="mt-2 whitespace-pre-wrap border-t border-border pt-2 text-xs leading-relaxed text-muted-foreground line-through decoration-[hsl(var(--offline)/0.6)]">
                            {textoDoCriativo(cr)}
                          </p>
                        )}
                      </>
                    ) : (
                      <>
                        {/* No DOOH o cartaz desenhado substitui a lista de frases, que
                            passa para o texto alternativo da imagem. */}
                        {/* O post das redes com a imagem, como sai (28 Set): aprovava-se
                            só o texto, e a cliente pensava que levava a fotografia. */}
                        {cr.canal === 'social' && (
                          <div className="mt-2 grid gap-4 sm:grid-cols-[11rem_minmax(0,1fr)]">
                            <ImagemDoPainel
                              semImagem="Sem texto para os painéis, o post sai só com o texto."
                              src={`/api/${slug}/campanhas/${aberta.id}/criativos/${cr.id}/imagem.png?v=${cr.versao}-${(criativos.find((x) => x.canal === 'dooh' && !x.recusa) || {}).id || ''}-${detalhe ? detalhe.materiais.map((m) => `${m.id}:${m.foco || ''}`).join(',') : ''}${cr.aprovado ? '|aprovado' : ''}`}
                              alt={`O post das redes sociais: ${textoDoCriativo(cr).split('\n')[0]}`}
                              largura={1080}
                              altura={1350}
                              descarregar={cr.aprovado ? `${aberta.titulo} - post` : null}
                            />
                            <div className="min-w-0 space-y-2">
                              <p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">{textoDoCriativo(cr)}</p>
                              {/* A imagem sai do texto dos painéis: dito, para quem vê um preço
                                  na imagem e outro na legenda saber qual corrigir (28 Set). */}
                              {/* Só a quem pode corrigir: ao perfil Leitura não se dão instruções (cliente simulado, 18 Out).
                                  No ar já não há «Corrigir o texto»: não se manda usá-lo (agentes, 20 Out). */}
                              {(() => {
                                const corrige = podeGerir && !['ativa', 'encerrada'].includes(aberta.estado);
                                const texto = aberta.canais.includes('dooh')
                                  ? (corrige ? 'A imagem leva o texto dos painéis: para a mudar, corrige o texto dos painéis.' : 'A imagem leva o texto dos painéis.')
                                  : corrige ? 'O texto da imagem muda-se em «Corrigir o texto», nos campos «Na imagem».' : null;
                                return texto && <p className="text-xs text-muted-foreground">{texto}</p>;
                              })()}
                            </div>
                          </div>
                        )}
                        {cr.canal !== 'dooh' && cr.canal !== 'social' && (
                          <p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
                            {textoDoCriativo(cr)}
                          </p>
                        )}
                        {cr.canal === 'dooh' && (
                          <PreviewDoohCriativo
                            slug={slug}
                            campanhaId={aberta.id}
                            criativo={cr}
                            lojasKey={(detalhe ? detalhe.lojas.map((l) => l.id).concat(detalhe.materiais.map((m) => `${m.id}:${m.foco || ''}`)).join(',') : '') + (cr.aprovado ? '|aprovado' : '') + (detalhe && detalhe.cartazes_por_rever ? '|rever' : '')}
                            porRever={Boolean(detalhe && detalhe.cartazes_por_rever)}
                            titulo={aberta.titulo}
                          />
                        )}
                        <div className="mt-2 flex flex-wrap items-center gap-2">
                          {cr.aprovado ? (
                            <>
                              <Badge variante="sucesso">Aprovado</Badge>
                              <span className="text-xs text-muted-foreground">
                                por {cr.aprovado_por_nome || cr.aprovado_por}
                              </span>
                            </>
                          ) : (
                            null
                          )}
                          {/* Aprovar é um gesto só, sobre o conjunto (sessão de 24 Set):
                              aqui fica só o desfazer. */}
                          {podeGerir && cr.aprovado && !['ativa', 'encerrada'].includes(aberta.estado) && (
                            <button
                              type="button"
                              disabled={ocupado}
                              onClick={() => onAprovar(aberta, cr, false)}
                              className="border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-primary hover:text-foreground disabled:opacity-50"
                            >
                              Retirar aprovação
                            </button>
                          )}
                        </div>
                      </>
                    )}
                    {podeGerir && !['ativa', 'encerrada'].includes(aberta.estado) && aEditar !== cr.id && (
                      <div className="mt-2 flex flex-wrap gap-3">
                        <button type="button" disabled={ocupado} onClick={() => setAEditar(cr.id)} className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50">
                          Corrigir o texto
                        </button>
                        {aberta.canais.length > 1 && (
                          <button type="button" disabled={ocupado} onClick={() => pedirEscrita([cr.canal])} className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50">
                            Escrever outra vez só este canal
                          </button>
                        )}
                      </div>
                    )}
                    {podeGerir && aEditar === cr.id && (
                      <EditorCriativo
                        criativo={cr}
                        comPaineis={(aberta.canais || []).includes('dooh')}
                        ocupado={ocupado}
                        onCancelar={() => setAEditar(null)}
                        onGuardar={async (conteudo, opcoes) => {
                          const r = await onEditarCriativo(aberta, cr, conteudo, opcoes);
                          if (r === true) setAEditar(null);
                          return r;
                        }}
                      />
                    )}
                  </li>
                ))}
              </ul>
            )}

            {podeGerir && !['ativa', 'encerrada'].includes(aberta.estado) && (
              <button
                type="button"
                disabled={ocupado}
                aria-busy={aGerar ? 'true' : undefined}
                onClick={() => pedirEscrita()}
                // Com criativos escritos, escrever outra vez é secundário: o principal
                // da página é aprovar, lá em cima (28 Set).
                className={criativos && criativos.length
                  ? 'mt-3 border border-border px-3 py-1.5 text-xs text-foreground outline-none disabled:opacity-50'
                  : 'mt-3 bg-primary px-3 py-1.5 text-xs text-primary-foreground outline-none hover:bg-primary/90 disabled:bg-secondary disabled:text-muted-foreground'}
              >
                {aGerar
                  ? 'A escrever…'
                  : criativos && criativos.length ? 'Escrever outra vez' : 'Escrever os anúncios'}
              </button>
            )}
            {/* Uma chamada por canal, em sequência: uns cinco segundos cada. Sem
                isto o botão ficava cinzento quinze segundos sem dizer porquê. */}
            {aGerar && criativos && criativos.length > 0 && (
              <AProcessar
                className="mt-2"
                texto={`A IA está a escrever ${(Array.isArray(aGerar) ? aGerar.length : aberta.canais.length) === 1 ? 'o anúncio' : `${aberta.canais.length} anúncios, um por canal`}`}
                // O ensaio de 6 Out mediu 27 a 45 s por campanha: a estimativa de 5 s por canal prometia de menos.
                detalhe={`Leva uns ${(Array.isArray(aGerar) ? aGerar.length : aberta.canais.length) * 15} segundos.`}
              />
            )}
          </SeccaoCampanha>

          <StoryboardVideo
            slug={slug}
            campanha={aberta}
            criativos={criativos}
            lerVideo={lerVideo}
            onEntradasVistas={onEntradasVideo}
            podeGerir={podeGerir}
            onMudou={onMudou}
          />
        </div>
        <div className="border border-border bg-card p-5">
          {/* Antes de aprovar, "Publicação por canal" só dizia "Por publicar" (28 Set): sai. */}
          {['ativa', 'encerrada'].includes(aberta.estado) && (
            <SeccaoCampanha titulo="Entrega por destino">
              {/* Encerrada, o que estava «Por enviar» já não sai (agentes, 20 Out: parecia à espera). */}
              {aberta.estado === 'encerrada' && (
                <p className="mb-2 text-xs text-muted-foreground">A campanha foi encerrada: o que ainda estava por enviar já não sai.</p>
              )}
              {/* Lê outra vez quando o post adiado se aprova: a entrega dele cria-se aí (F5, 2 Out). */}
              <EntregasCampanha key={(aberta.canais_adiados || []).join(',')} slug={slug} campanhaId={aberta.id} />
            </SeccaoCampanha>
          )}

          <SeccaoCampanha titulo="Fotografia" id="fotografia-da-campanha">
            {detalhe
              ? <FotografiaCampanha podeGerir={podeGerir} slug={slug} campanha={aberta} materiais={detalhe.materiais} onMudou={onMudou} />
              : <div className="loading-skeleton h-40 rounded-md" role="status" aria-label="A carregar a fotografia" />}
          </SeccaoCampanha>

          <SeccaoCampanha titulo={`Lojas (${aberta.lojas.length})`}>
            {aberta.lojas.length === 0 ? (
              <p className="text-sm text-muted-foreground">
                {aberta.canais.some((c) => c === 'dooh' || c === 'pdv')
                  ? 'Nenhuma escolhida. Os painéis estão nas lojas: sem elas, a campanha não pode ir para o ar.'
                  : 'Nenhuma escolhida. Só nas redes sociais, não precisa de lojas.'}
              </p>
            ) : (
              <ul className="space-y-1 text-sm">
                {aberta.lojas.map((loja) => (
                  <li key={typeof loja === 'string' ? loja : loja.id} className="flex items-center gap-2">
                    <IconLojas size={14} className="text-muted-foreground" />
                    {typeof loja === 'string' ? loja : loja.name}
                  </li>
                ))}
              </ul>
            )}
          </SeccaoCampanha>

          {/* Com uma linha só, o histórico não conta nada. */}
          {detalhe && passosDoHistorico(detalhe.historico).length > 1 && (
            <details className="border-b border-border py-5 last:border-b-0">
              <summary className="ns-seccao-titulo cursor-pointer">Histórico</summary>
              <div className="mt-3"><TimelineEstados historico={detalhe.historico} /></div>
            </details>
          )}


        </div>
      </div>
    </div>
  );
}

/**
 * Uma campanha abre numa página própria, /:slug/campanhas/:id, e não numa
 * gaveta por cima do kanban (até 23 Set). É a regra da app: entidade abre em
 * página, acção abre em modal. A página tem espaço para os cartazes DOOH à
 * medida de cada painel, que numa gaveta de 448 px saíam em miniatura, e tem
 * endereço — dá para a partilhar e o "voltar" do browser funciona.
 */
/**
 * O que está a ser criado no Criar e ainda não é uma campanha. O que se criava lá não aparecia aqui, e
 * o cliente não sabia onde o tinha deixado (agentes, 20 Out). Só lê: abre-se e acaba-se no Criar.
 */
const ESTADO_EM_CRIACAO = {
  preparado: 'Pronto para criar', fila: 'Na fila', a_criar: 'A criar', video_por_rever: 'Por rever',
  aprovado: 'Aprovado: falta abrir a campanha', recusado: 'Não foi criado', interrompido: 'Interrompido',
};
function EmCriacao({ slug, onNavegar }) {
  const [videos, setVideos] = React.useState(null);
  React.useEffect(() => {
    let vivo = true;
    fetch(`/api/${slug}/criar/videos`, { headers: { 'X-Requested-With': 'NetScreenCriar' } })
      .then((r) => (r.ok ? r.json() : []))
      // As tentativas que não chegaram a vídeo ficam no Criar: aqui eram três linhas iguais «Não foi criado» (crítica visual, 3 Nov).
      .then((l) => { if (vivo) setVideos((l || []).filter((v) => !v.campanhaId && v.estado !== 'recusado' && v.estado !== 'interrompido')); })
      .catch(() => { if (vivo) setVideos([]); });
    return () => { vivo = false; };
  }, [slug]);
  if (!videos || !videos.length) return null;
  return (
    <section aria-labelledby="em-criacao" className="border border-border bg-card p-4">
      <h2 id="em-criacao" className="text-sm font-semibold text-foreground">Em criação</h2>
      <p className="mt-1 text-xs text-muted-foreground">Os vídeos que estás a criar. Depois de aprovado, cada um abre-se como campanha: é aí que se escreve o post e se escolhe quando sai. Não precisas de criar outra.</p>
      <ul className="mt-3 divide-y divide-border">
        {videos.slice(0, 6).map((v) => (
          <li key={v.id} className="flex flex-wrap items-center justify-between gap-3 py-2">
            <CapaDeVideo slug={slug} video={v} reservar className="w-10 shrink-0 rounded-sm border border-border" />
            <span className="min-w-0 flex-1 text-sm text-foreground">{v.tema || 'Sem tema'} <span className="text-xs text-muted-foreground">· {ESTADO_EM_CRIACAO[v.estado] || v.estado}</span></span>
            {/* Aprovado: abre já a campanha dele (o mesmo pedido do Criar, que não a duplica). */}
            <button type="button" onClick={() => (v.estado === 'aprovado'
              ? fetch(`/api/${slug}/criar/video/${v.id}/campanha`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'NetScreenCriar' }, body: '{}' })
                .then((r) => (r.ok ? r.json() : Promise.reject(r))).then((c) => onNavegar(`/${slug}/campanhas/${c.id}`))
                .catch(() => onNavegar(`/${slug}/criar?video=${v.id}`))
              : onNavegar(`/${slug}/criar?video=${v.id}`))}
              className="min-h-10 border border-border px-3 text-xs outline-none hover:bg-muted focus-visible:ring-1 focus-visible:ring-primary">{v.estado === 'aprovado' ? 'Abrir campanha' : 'Abrir'}</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

function ScreenCampanhas({ podeMexer = false, slug, idAberta, sub, onNavegar, criarActivo=false }) {
  const [campanhas, setCampanhas] = React.useState(null);
  // O briefing é uma página (28 Set): /campanhas/nova, e /campanhas/:id/briefing.
  const modoBriefing = idAberta === 'nova' ? 'nova' : sub === 'briefing' && idAberta ? 'editar' : null;
  const abertaId = idAberta && idAberta !== 'nova' ? idAberta : null;
  // A campanha repetida abre no briefing, a dizer de onde veio.
  const [repetidaDe, setRepetidaDe] = React.useState(null);
  const setAbertaId = React.useCallback(
    (id) => onNavegar(id ? `/${slug}/campanhas/${id}` : `/${slug}/campanhas`),
    [onNavegar, slug]
  );
  const [aEncerrarId, setAEncerrarId] = React.useState(null); // à espera de confirmação no modal
  const [aApagarId, setAApagarId] = React.useState(null); // idem, para apagar
  // Pedir a publicação cria o trabalho de envio para a NetScreen: confirma-se,
  // dizendo o que acontece (ensaio de ponta a ponta, 24 Set: num clique ficava
  // "Activa", e parecia já estar no painel).
  const [aPublicarId, setAPublicarId] = React.useState(null);
  const [toast, setToast] = React.useState(null);
  const [pesquisa, setPesquisa] = React.useState('');
  const [canaisFiltro, setCanaisFiltro] = React.useState([]);

  const [erro, setErro] = React.useState(null);
  const [ocupado, setOcupado] = React.useState(false);
  const [aGerar, setAGerar] = React.useState(false);
  const [info, setInfo] = React.useState(null);
  const podeGerir = Boolean(podeMexer && info && info.podes_gerir);

  const [detalhe, setDetalhe] = React.useState(null);
  // Cada leitura do detalhe tem uma vez; só a mais recente entra no ecrã. Uma resposta antiga, que
  // chegasse depois, repunha o detalhe de antes (parecer do Codex, 9 Out).
  const vezDoDetalhe = React.useRef(0);
  // A referência das entradas com que as miniaturas do vídeo se desenharam, e
  // um contador para as ler outra vez (ensaio de 3 Out).
  const [entradasVideo, setEntradasVideo] = React.useState(null);
  const [videoFalhou, setVideoFalhou] = React.useState(false);
  const [lerVideo, setLerVideo] = React.useState(0);
  // O detalhe relido depois de uma mudança ('a-ler' ou 'falhou'): traz a referência do que se viu
  // (`ref_composicao`). Enquanto não chega, ou se falhar, não se aprova com a de antes (Codex, 9 Out).
  const [releitura, setReleitura] = React.useState(null);
  // Abrir o briefing é navegar para a página dele.
  const setABriefar = React.useCallback((c) => {
    if (c === undefined) return;
    onNavegar(c ? `/${slug}/campanhas/${c.id}/briefing` : `/${slug}/campanhas/nova`);
  }, [onNavegar, slug]);
  const [criativos, setCriativos] = React.useState(null);
  const [erroCriativos, setErroCriativos] = React.useState(null);
  const [aAprovar, setAAprovar] = React.useState(false);
  // As decisões confirmadas na página (28 Set). As chaves levam o id do texto:
  // uma versão nova pede outra vez.
  const [confirmadas, setConfirmadas] = React.useState({});
  // Publicar sem o vídeo por agora, e o que o servidor disse dele (P5, 28 Set).
  const [semVideo, setSemVideo] = React.useState(false);
  const [videoProblema, setVideoProblema] = React.useState(null);
  const [adiarPost, setAdiarPost] = React.useState(false);
  React.useEffect(() => { setSemVideo(false); setAdiarPost(false); setVideoProblema(null); setEntradasVideo(null); setVideoFalhou(false); setReleitura(null); }, [abertaId]);

  const carregar = React.useCallback(
    () =>
      api
        .get(`/api/${slug}/campanhas`)
        .then((r) => {
          setCampanhas(r.campanhas);
          setInfo(r);
        })
        .catch((e) => setErro(e.message || 'Não foi possível ler as campanhas.')),
    [slug]
  );

  React.useEffect(() => {
    setCampanhas(null);
    setErro(null);
    carregar();
  }, [carregar]);

  // `?nova=1` (o endereço antigo do "Começar") leva à página do briefing novo.
  React.useEffect(() => {
    const q = new URLSearchParams(window.location.search);
    if (q.get('nova') !== '1') return;
    window.history.replaceState({}, '', window.location.pathname);
    onNavegar(`/${slug}/campanhas/nova`);
  }, []);

  // O cartao do kanban traz o essencial; o historico e as lojas por nome vem
  // do detalhe, que so se vai buscar quando alguem abre a gaveta. Carregar
  // tudo na lista era pagar em cada leitura por informacao que quase sempre
  // ninguem ve.
  React.useEffect(() => {
    if (!abertaId) return setDetalhe(null);
    let vivo = true;
    setDetalhe(null);
    setErroCriativos(null);
    setCriativos(null);
    const vez = ++vezDoDetalhe.current;
    api.get(`/api/${slug}/campanhas/${abertaId}`)
      .then((d) => { if (vivo && vez === vezDoDetalhe.current) setDetalhe(d); })
      .catch(() => { /* a gaveta mostra o que tem */ });
    api.get(`/api/${slug}/campanhas/${abertaId}/criativos`)
      .then((c) => { if (vivo) setCriativos(c); })
      // Um erro não é "ainda não há criativos" (revisão do Codex, 23 Set): a
      // lista fica vazia, mas o motivo aparece por cima.
      .catch((e) => { if (vivo) { setCriativos([]); setErroCriativos(e.message || 'sem resposta'); } });
    return () => { vivo = false; };
  }, [abertaId, slug]);

  // O que outra pessoa mudou chega sem recarregar (sessão de 24 Set): a lista,
  // e a campanha aberta com os criativos. Pára com o briefing ou a aprovação
  // abertos, e com um texto a ser corrigido, para não apagar o que não se
  // guardou.
  const [editando, setEditando] = React.useState(false);
  const lerTudo = React.useCallback(async () => {
    const r = await api.get(`/api/${slug}/campanhas`);
    setCampanhas(r.campanhas);
    setInfo(r);
    if (abertaId) {
      const vez = ++vezDoDetalhe.current;
      const [d, c] = await Promise.all([
        api.get(`/api/${slug}/campanhas/${abertaId}`),
        api.get(`/api/${slug}/campanhas/${abertaId}/criativos`),
      ]);
      if (vez === vezDoDetalhe.current) { setDetalhe(d); setReleitura(null); }
      setCriativos(c);
    }
  }, [slug, abertaId]);

  // Relê o detalhe da campanha aberta e diz se conseguiu (achado 5 do Codex, 9 Out).
  const abertaRef = React.useRef(abertaId);
  abertaRef.current = abertaId;
  const relerDetalhe = React.useCallback(async (id) => {
    if (!id || id !== abertaRef.current) return null;
    setReleitura('a-ler');
    const vez = ++vezDoDetalhe.current;
    try {
      const d = await api.get(`/api/${slug}/campanhas/${id}`);
      if (id === abertaRef.current && vez === vezDoDetalhe.current) { setDetalhe(d); setReleitura(null); }
      return d;
    } catch {
      if (id === abertaRef.current && vez === vezDoDetalhe.current) setReleitura('falhou');
      return null;
    }
  }, [slug]);
  const actualizacaoFalhou = useActualizar(lerTudo, {
    activo: !modoBriefing && !aAprovar && !editando && !ocupado,
  });

  // Depois de mudar a fotografia: o detalhe (os materiais) e a lista (o estado
  // pode ter voltado à revisão).
  const recarregarAberta = React.useCallback(() => {
    if (!abertaId) return;
    relerDetalhe(abertaId);
    // Os criativos também: um storyboard reescrito é o que se aprova a seguir (28 Set).
    api.get(`/api/${slug}/campanhas/${abertaId}/criativos`).then(setCriativos).catch(() => {});
    carregar();
    // O "N à tua espera" do cabeçalho só relia ao recarregar a página.
    window.dispatchEvent(new Event('fila-mudou'));
  }, [abertaId, slug, carregar, relerDetalhe]);

  const aprovarCriativo = async (campanha, criativo, aprovado, extra) => {
    setOcupado(true);
    try {
      await api.put(
        `/api/${slug}/campanhas/${campanha.id}/criativos/${criativo.id}/aprovar`,
        { aprovado, ...(extra || {}) }
      );
      setCriativos(await api.get(`/api/${slug}/campanhas/${campanha.id}/criativos`));
      // Retirar numa campanha aprovada devolve-a à revisão (24 Set).
      recarregarAberta();
      setToast({
        id: Date.now(),
        mensagem: aprovado ? 'Anúncio aprovado.'
          : campanha.estado === 'aprovada' ? 'Aprovação retirada. A campanha voltou à revisão.' : 'Aprovação retirada.',
      });
    } catch (e) {
      setToast({ id: Date.now(), mensagem: e.message, erro: true });
    } finally {
      setOcupado(false);
    }
  };

  const gerarCriativos = async (campanha, canais) => {
    setOcupado(true);
    setAGerar(canais || true);
    try {
      const r = await api.post(`/api/${slug}/campanhas/${campanha.id}/gerar`, canais ? { canais } : {});
      carregar();
      // O detalhe também: traz a referência do que se viu (`ref_composicao`), lida quando ainda
      // não havia criativo dos painéis. Sem a reler, «Aprovar e publicar» logo a seguir à escrita
      // era recusado com «a imagem para os painéis mudou» (verificação de 8 Out).
      // Se a releitura falhar, a aprovação fica presa com «Tentar outra vez» (Codex, 9 Out).
      const [criativosNovos] = await Promise.all([
        api.get(`/api/${slug}/campanhas/${campanha.id}/criativos`),
        relerDetalhe(campanha.id),
      ]);
      setCriativos(criativosNovos);
      const recusados = r.criativos.filter((c) => c.recusa).length;
      const avisos = r.criativos.filter((c) => c.conteudo && c.conteudo.aviso && notaPedeDecisao(c.conteudo.aviso)).length;
      setToast({
        id: Date.now(),
        mensagem: recusados
          ? `${r.criativos.length} anúncios, ${recusados} por refazer. Vê porquê por cima dos anúncios.`
          : avisos
          ? `${r.criativos.length} anúncios escritos. Há ${avisos === 1 ? 'uma coisa' : `${avisos} coisas`} a decidir antes de aprovar.`
          // "Outra vez" só quando se pediu um canal de uma campanha com vários (ensaio de 3 Out:
          // o estado dos criativos ainda podia ser o da campanha aberta antes).
          : r.criativos.length === 1 ? (canais && canais.length === 1 && (campanha.canais || []).length > 1 ? 'Anúncio escrito outra vez. Os outros canais ficaram como estavam.' : 'Anúncio escrito.')
          : `${r.criativos.length} anúncios escritos.`,
      });
    } catch (e) {
      setToast({ id: Date.now(), mensagem: e.message, erro: true });
    } finally {
      setOcupado(false);
      setAGerar(false);
    }
  };

  // Devolve true quando guardou; `{ alteracao_oferta, mensagem }` quando o
  // servidor pede a decisão de mudar a oferta (o editor mostra-a); false no resto.
  // O vídeo de acordo com o texto, sem IA, quando só mudou o texto legal (teste de raiz, 9 Out).
  const actualizarVideo = async () => {
    if (!abertaId) return;
    setOcupado(true);
    try {
      await api.post(`/api/${slug}/campanhas/${abertaId}/storyboard/actualizar`, {});
      setToast({ id: Date.now(), mensagem: 'O vídeo ficou de acordo com o texto, sem o escrever outra vez. Vê as cenas e aprova.' });
      recarregarAberta();
      setLerVideo((n) => n + 1);
    } catch (e) {
      setToast({ id: Date.now(), mensagem: e.message, erro: true });
    } finally {
      setOcupado(false);
    }
  };
  // As condições dos painéis na legenda: guardar o post junta as que faltam (lib/agentePm.js), e são as que o
  // cliente já aceitou nos painéis, por isso a alteração da oferta vai confirmada (teste de raiz, 9 Out).
  const juntarCondicoesALegenda = async (campanha, criativosAgora) => {
    const social = (criativosAgora || []).find((c) => c.canal === 'social');
    if (!social) return;
    const r = await editarCriativo(campanha, social, social.conteudo, { muda_a_oferta: true });
    if (r && r.erro) setToast({ id: Date.now(), mensagem: r.erro, erro: true });
  };

  const editarCriativo = async (campanha, criativo, conteudo, opcoes = {}) => {
    setOcupado(true);
    try {
      const r = await api.put(`/api/${slug}/campanhas/${campanha.id}/criativos/${criativo.id}`, { conteudo, ...opcoes });
      setCriativos(await api.get(`/api/${slug}/campanhas/${campanha.id}/criativos`));
      // O detalhe também: a contradição de preços e a frase obrigatória vêm dele
      // (verificação no browser, 28 Set: o bloqueio só saía ao recarregar).
      relerDetalhe(campanha.id);
      carregar();
      setToast({
        id: Date.now(),
        mensagem: r.estado !== campanha.estado
          ? 'Correcção guardada. A campanha estava aprovada e voltou à revisão.'
          : 'Correcção guardada, por aprovar. Os outros canais ficaram como estavam.',
      });
      return true;
    } catch (e) {
      if (e.corpo && e.corpo.alteracao_oferta) {
        return { alteracao_oferta: e.corpo.alteracao_oferta, mensagem: e.message };
      }
      // A recusa fica no editor, junto ao texto: um aviso passageiro no fundo do
      // ecrã sumia antes de se ler (ronda 8).
      return { erro: e.message };
    } finally {
      setOcupado(false);
    }
  };

  // Repetir: a nova abre já com o briefing à frente, porque as datas (e talvez
  // o preço) são o que falta decidir.
  const repetirCampanha = async (campanha) => {
    setOcupado(true);
    try {
      const nova = await api.post(`/api/${slug}/campanhas/${campanha.id}/repetir`, {});
      await carregar();
      setRepetidaDe({ id: nova.id, titulo: campanha.titulo, de: campanha.id });
      setABriefar(nova);
      setToast({
        id: Date.now(),
        // Mudar o briefing não muda os textos copiados; mas os textos passam a
        // dizer o que ficou para trás (reavaliação do Codex, ponto 4).
        mensagem: 'Campanha repetida. Falta decidir as datas e, se mudou, o preço.',
      });
    } catch (e) {
      setToast({ id: Date.now(), mensagem: e.message, erro: true });
    } finally {
      setOcupado(false);
    }
  };

  const fecharToast = React.useCallback(() => setToast(null), []);
  const cancelarEncerrar = React.useCallback(() => setAEncerrarId(null), []);

  const aplicarEstado = async (id, para) => {
    const antes = campanhas.find((c) => c.id === id);
    setOcupado(true);
    try {
      await api.put(`/api/${slug}/campanhas/${id}/estado`, { estado: para });
      // A lista e a campanha aberta: só a lista deixava o cabeçalho a dizer uma
      // coisa e o histórico outra (ensaio de ponta a ponta, 24 Set). Se reler
      // falha, o pedido já foi feito: não se diz que falhou.
      await lerTudo().catch(() => {});
      // "No ar" logo a seguir a "Publicar" dizia mais do que se sabe: os destinos
      // ainda estão por enviar (revisor, 28 Set).
      setToast({ id: Date.now(), mensagem: `${antes.titulo}: ${para === 'ativa' ? 'publicação pedida' : estadoVisivel({ estado: para }).rotulo}.` });
    } catch (e) {
      // A mensagem do servidor diz o que se pode fazer a partir daqui, e e
      // melhor do que qualquer coisa que se escrevesse aqui.
      setToast({ id: Date.now(), mensagem: e.message, erro: true });
    } finally {
      setOcupado(false);
    }
  };

  // Depois de aprovar (num clique ou no modal): lê tudo outra vez e diz o que aconteceu.
  const depoisDeAprovar = (publicacao) => {
    setAAprovar(false);
    setSemVideo(false);
    setAdiarPost(false);
    setVideoProblema(null);
    recarregarAberta();
    carregar();
    if (abertaId) api.get(`/api/${slug}/campanhas/${abertaId}/criativos`).then(setCriativos).catch(() => {});
    setToast({
      id: Date.now(),
      mensagem: publicacao && publicacao.erro
        ? `Aprovada, mas a publicação não foi pedida: ${publicacao.erro} Quando estiver resolvido, carrega em "Publicar".`
        : 'Aprovada, e a publicação está pedida. A NetScreen marca aqui cada destino quando chegar.',
    });
  };

  // Aprovar num clique (28 Set, advogado da simplicidade): a página já é a
  // revisão ("Antes de aprovar"). O modal só abre quando há o que confirmar
  // (promessas, datas depois do fim, a oferta que mudou), e só com isso.
  const aprovarDirecto = async (campanha, confirmacoes = {}) => {
    setOcupado(true);
    try {
      const r = await api.post(`/api/${slug}/campanhas/${campanha.id}/aprovar`, corpoDaAprovacao(campanha, criativos, { ...detalhe, entradas_video_vistas: entradasVideo }, confirmacoes, semVideo, adiarPost));
      depoisDeAprovar(r && r.publicacao);
    } catch (e) {
      const corpo = e.corpo || {};
      // Um problema do vídeo que só o servidor vê (não cabe): fica como bloqueio,
      // com a saída de publicar sem ele.
      if (corpo.video) setVideoProblema(e.message);
      // O servidor pede uma confirmação que o ecrã não previu: abre o modal com ela.
      if (corpo.promessas_por_confirmar || corpo.datas_depois_do_fim || corpo.desactualizado || corpo.avisos_por_confirmar || corpo.nota_por_ler) {
        setAAprovar(true);
      } else {
        setToast({ id: Date.now(), mensagem: e.message, erro: true });
        // Mudou alguma coisa desde que se leu: lê-se outra vez, e aprova-se o que está agora.
        // As miniaturas do vídeo também (ensaio de 3 Out: a mensagem pedia para rever e o ecrã não mudava).
        if (corpo.mudou) { lerTudo().catch(() => {}); setLerVideo((n) => n + 1); }
      }
    } finally {
      setOcupado(false);
    }
  };

  // Entrada única para mudar de estado, pelos botões da campanha. Encerrar pede confirmação.
  const pedirTransicao = (id, para) => {
    const campanha = campanhas.find(c => c.id === id);
    if (!campanha) return;
    if (para === 'aprovada' && !['ativa', 'encerrada'].includes(campanha.estado)) {
      const lido = id === abertaId && detalhe && detalhe.id === id && criativos;
      const pedidos = lido ? pedidosDaAprovacao(campanha, criativos, detalhe.lojas || [], detalhe.formatos_sem_obrigatoria, { semVideo, videoProblema, imagemDoPost: detalhe.imagem_do_post, adiarPost, redesSemPerfil: detalhe.redes_sem_perfil, videoCriarPorAprovar: detalhe.video_criar_por_aprovar, videoPorVer: !entradasVideo, videoFalhou, faixasTextoPequeno: detalhe.faixas_texto_pequeno, semCondicoes: detalhe.formatos_sem_condicoes, releitura, marca: marcaDoDetalhe(detalhe) }) : null;
      if (pedidos && !pedidos.bloqueios.length && pedidos.decisoes.every((d) => confirmadas[d.chave])) {
        aprovarDirecto(campanha, confirmacoesDe(pedidos.decisoes, confirmadas));
        return;
      }
      setAbertaId(id);
      setAAprovar(true);
      return;
    }
    if (para === 'apagar') return setAApagarId(id);
    if (!transicaoPermitida(campanha.estado, para)) return;
    if (para === 'encerrada') return setAEncerrarId(id);
    if (para === 'ativa') return setAPublicarId(id);
    aplicarEstado(id, para);
  };

  const aberta = campanhas && campanhas.find(c => c.id === abertaId);
  const aEncerrar = campanhas && campanhas.find(c => c.id === aEncerrarId);
  const aApagar = campanhas && campanhas.find(c => c.id === aApagarId);
  const aPublicar = campanhas && campanhas.find(c => c.id === aPublicarId);
  // Uma agendada (publicada, a começar mais tarde) não conta como no ar (agentes, 20 Out).
  const nActivas = campanhas ? campanhas.filter(c => c.estado === 'ativa' && rotuloDoCartao(c).rotulo !== 'Agendada').length : 0;
  const nAgendadas = campanhas ? campanhas.filter(c => c.estado === 'ativa' && rotuloDoCartao(c).rotulo === 'Agendada').length : 0;

  const termo = pesquisa.trim().toLowerCase();
  const filtrosActivos = termo !== '' || canaisFiltro.length > 0;
  const visiveis = (campanhas || []).filter(
    c =>
      (!termo || c.titulo.toLowerCase().includes(termo) || c.lojas.some(l => l.toLowerCase().includes(termo))) &&
      (!canaisFiltro.length || canaisFiltro.some(k => c.canais.includes(k)))
  );

  if (erro) {
    return (
      <div className="space-y-6">
        <header>
          <h1 className="mt-1 font-display text-4xl uppercase leading-none tracking-wide">Campanhas</h1>
        </header>
        <div className="border border-border bg-card px-6 py-12 text-center">
          <p className="font-display text-xl tracking-wide">Não deu para ler as campanhas</p>
          <p className="mt-2 text-sm text-muted-foreground">{erro}</p>
        </div>
      </div>
    );
  }

  return (
    <div className="space-y-6">
      {actualizacaoFalhou && (
        <p className="text-xs text-accent" role="status">
          Não deu para actualizar agora: o que vês pode não ser o mais recente. Voltamos a tentar sozinhos.
        </p>
      )}
      {modoBriefing ? (
        !campanhas ? <KanbanSkeleton /> : !podeGerir ? <div role="status" className="space-y-3"><p className="text-sm text-muted-foreground">O teu perfil permite consultar campanhas. Para criar ou alterar, fala com o responsável da conta.</p><button type="button" onClick={() => onNavegar(`/${slug}/campanhas`)} className="text-sm underline underline-offset-4">Voltar às campanhas</button></div> : modoBriefing === 'editar' && !aberta ? (
          <p className="text-sm text-muted-foreground">Esta campanha não existe nesta conta.</p>
        ) : (
          <PaginaBriefing
            criarActivo={criarActivo}
            key={modoBriefing === 'nova' ? 'nova' : abertaId}
            campanha={modoBriefing === 'editar' ? aberta : null}
            slug={slug}
            canaisDoPlano={info && info.canais_do_plano}
            ocupado={ocupado}
            repetidaDe={repetidaDe && aberta && repetidaDe.id === aberta.id ? repetidaDe.titulo : null}
            onFechar={async () => {
              // Desistir de uma repetição acabada de criar não deixa a cópia na lista (agentes, 20 Out:
              // ficava «Por aprovar», sem datas). Volta-se à campanha de onde se repetiu.
              const r = repetidaDe;
              // Só uma cópia ainda por escrever: quem saiu pelo menu e trabalhou nela não a perde num clique (revisor, 20 Out).
              if (modoBriefing === 'editar' && r && r.id === abertaId && aberta && aberta.estado === 'draft'
                && !(criativos || []).length) {
                // A pergunta das alterações por guardar vem antes de apagar, e não depois (app.jsx).
                if (window.__porGuardar && !window.confirm(`${window.__porGuardar} Sair sem guardar?`)) return;
                window.__porGuardar = null;
                setRepetidaDe(null);
                try { await api.del(`/api/${slug}/campanhas/${r.id}`); } catch (e) { setToast({ id: Date.now(), mensagem: e.message, erro: true }); }
                carregar();
                onNavegar(`/${slug}/campanhas/${r.de}`);
                return;
              }
              onNavegar(modoBriefing === 'editar' ? `/${slug}/campanhas/${abertaId}` : `/${slug}/campanhas`);
            }}
            onGuardado={(mensagem, falhou, nova) => {
              setToast({ id: Date.now(), mensagem });
              if (falhou) return;
              setRepetidaDe(null);
              carregar();
              if (nova) {
                // Nova: abre a campanha e escreve logo (28 Set). O «voltar» do browser leva à lista, e
                // não ao formulário «Nova campanha» vazio, que parecia ter perdido a campanha (agentes, 20 Out).
                window.history.replaceState({}, '', `/${slug}/campanhas`);
                setAbertaId(nova.id);
                if (nova.canais && nova.canais.length) gerarCriativos(nova);
                return;
              }
              // As datas e as lojas podem ter mudado, e os avisos dos criativos
              // vêm do servidor: lê-se outra vez a campanha aberta.
              api.get(`/api/${slug}/campanhas/${abertaId}/criativos`).then(setCriativos).catch(() => {});
              recarregarAberta();
              setAbertaId(abertaId);
            }}
          />
        )
      ) : abertaId ? (
        <PaginaCampanha
          podeGerir={podeGerir}
          onNavegar={onNavegar}
          onEditando={setEditando}
          lerVideo={lerVideo}
          onEntradasVideo={(ref, falhou) => { setEntradasVideo(ref); setVideoFalhou(Boolean(falhou)); }}
          entradasVideo={entradasVideo}
          videoFalhou={videoFalhou}
          releitura={releitura}
          onReler={() => relerDetalhe(abertaId)}
          onActualizarVideo={actualizarVideo}
          onJuntarCondicoes={juntarCondicoesALegenda}
          slug={slug}
          campanhas={campanhas}
          aberta={aberta}
          detalhe={detalhe}
          criativos={criativos}
          erroCriativos={erroCriativos}
          info={info}
          ocupado={ocupado}
          aGerar={aGerar}
          onVoltar={() => setAbertaId(null)}
          onPedirTransicao={pedirTransicao}
          onEditar={(c) => setABriefar(c)}
          onAprovar={aprovarCriativo}
          onGerar={gerarCriativos}
          onMudou={recarregarAberta}
          onEditarCriativo={editarCriativo}
          onRepetir={repetirCampanha}
          confirmadas={confirmadas}
          onConfirmar={(chave, sim) => setConfirmadas((c) => ({ ...c, [chave]: sim }))}
          semVideo={semVideo}
          videoProblema={videoProblema}
          onSemVideo={(sim) => {
            setSemVideo(sim);
            if (sim) setToast({ id: Date.now(), mensagem: 'O vídeo fica para depois: a campanha publica-se sem ele, e ele fica em «À tua espera».' });
          }}
          adiarPost={adiarPost}
          onAdiarPost={(sim) => {
            setAdiarPost(sim);
            if (sim) setToast({ id: Date.now(), mensagem: 'O post fica para depois: os painéis publicam-se já, e o post fica em «À tua espera».' });
          }}
        />
      ) : (
      <React.Fragment>
      <div className="flex flex-wrap items-end gap-4">
        <div>
          <h1 className="mt-1 font-display text-4xl uppercase leading-none tracking-wide">Campanhas</h1>
          <p className="mt-2 text-sm text-muted-foreground">
            {!campanhas
              ? 'A carregar campanhas'
              : filtrosActivos
                ? `${visiveis.length} de ${campanhas.length} ${campanhas.length === 1 ? 'campanha' : 'campanhas'}`
                : `${campanhas.length} ${campanhas.length === 1 ? 'campanha' : 'campanhas'} · ${nActivas} no ar${nAgendadas ? ` · ${nAgendadas} ${nAgendadas === 1 ? 'agendada' : 'agendadas'}` : ''}`}
          </p>
        </div>

        <div className="ml-auto flex flex-wrap gap-3">
          {criarActivo&&podeGerir&&<button type="button" disabled={ocupado} onClick={()=>onNavegar(`/${slug}/criar`)} className="flex items-center gap-2 bg-primary px-4 py-2 text-sm text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-1 focus-visible:ring-accent disabled:opacity-50"><IconMais size={14} />Criar vídeo</button>}
          {podeGerir && (
            <button
              type="button"
              disabled={ocupado}
              onClick={() => setABriefar(null)}
              className={criarActivo
                ? 'flex items-center gap-2 border border-border px-4 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50'
                : 'flex items-center gap-2 bg-primary px-4 py-2 text-sm text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-1 focus-visible:ring-accent disabled:bg-secondary disabled:text-muted-foreground'}
            >
              <IconMais size={14} />
              {/* Com o Criar, «Nova campanha» ao lado de «Criar vídeo» parecia o mesmo (utilizadores virgens, 4 Nov):
                  diz o que é: começa pelo texto, e o Agente PM escreve os posts e as imagens dos painéis. */}
              {criarActivo ? 'Campanha sem vídeo' : 'Nova campanha'}
            </button>
          )}
        </div>
        {/* Havia aqui um segundo "Nova campanha", desactivado, e uma etiqueta
            "Dados de exemplo" — restos da maqueta da Fase 0. Os dados deixaram
            de ser de exemplo a 22 Set, e a etiqueta passou a mentir. */}
      </div>

      {criarActivo && <EmCriacao slug={slug} onNavegar={onNavegar} />}

      <div className="flex flex-wrap items-center gap-3">
        <label htmlFor="pesquisa-campanhas" className="sr-only">
          Pesquisar campanhas
        </label>
        <input
          id="pesquisa-campanhas"
          type="search"
          value={pesquisa}
          onChange={e => setPesquisa(e.target.value)}
          placeholder="Pesquisar por título ou loja"
          className="h-9 w-full border border-border bg-transparent px-3 text-sm placeholder:text-muted-foreground sm:w-64"
        />
        <div className="flex items-center gap-1" role="group" aria-label="Filtrar por canal">
          {CANAIS_CAMPANHA.map(({ chave, curto, Icone }) => {
            const activo = canaisFiltro.includes(chave);
            return (
              <button
                key={chave}
                type="button"
                aria-pressed={activo}
                onClick={() => setCanaisFiltro(f => (activo ? f.filter(k => k !== chave) : [...f, chave]))}
                className="ns-chip h-9"
              >
                <Icone size={14} />
                {curto}
              </button>
            );
          })}
        </div>
        {filtrosActivos && (
          <button
            type="button"
            onClick={() => {
              setPesquisa('');
              setCanaisFiltro([]);
            }}
            className="text-xs text-muted-foreground hover:text-foreground hover:underline"
          >
            Limpar filtros
          </button>
        )}
      </div>

      {!campanhas ? <KanbanSkeleton /> : <ListaCampanhas campanhas={visiveis} onAbrir={setAbertaId} slug={slug} />}

      </React.Fragment>
      )}

      <Modal
        aberto={!!aPublicar}
        onFechar={() => setAPublicarId(null)}
        papel="alertdialog"
        titulo="Pedir a publicação?"
        acoes={
          <>
            <button type="button" data-foco-inicial onClick={() => setAPublicarId(null)} className="border border-border px-4 py-2 text-sm hover:bg-secondary">
              Cancelar
            </button>
            <button
              type="button"
              onClick={() => { const id = aPublicarId; setAPublicarId(null); aplicarEstado(id, 'ativa'); }}
              className="bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
            >
              Pedir a publicação
            </button>
          </>
        }
      >
        {aPublicar && (
          <p>
            A NetScreen passa a enviar <span className="text-foreground">{aPublicar.titulo}</span> para cada destino,
            e marca aqui quando chegou. Até lá, cada destino diz "Por enviar" ou "Enviado".
          </p>
        )}
      </Modal>

      <Modal
        aberto={!!aEncerrar}
        onFechar={cancelarEncerrar}
        papel="alertdialog"
        titulo="Encerrar campanha?"
        acoes={
          <>
            <button
              type="button"
              data-foco-inicial
              onClick={cancelarEncerrar}
              className="border border-border px-4 py-2 text-sm hover:bg-secondary"
            >
              Cancelar
            </button>
            <button
              type="button"
              onClick={() => {
                const id = aEncerrarId;
                setAEncerrarId(null);
                aplicarEstado(id, 'encerrada');
              }}
              className="bg-[hsl(var(--offline))] px-4 py-2 text-sm text-foreground hover:bg-[hsl(var(--offline)/0.85)]"
            >
              Encerrar campanha
            </button>
          </>
        }
      >
        {aEncerrar && (
          <p>
            <span className="text-foreground">{aEncerrar.titulo}</span> deixa de estar activa e fica
            disponível só para consulta.
          </p>
        )}
      </Modal>

      <Modal
        aberto={!!aApagar}
        onFechar={() => setAApagarId(null)}
        papel="alertdialog"
        titulo="Apagar campanha?"
        acoes={
          <>
            <button type="button" data-foco-inicial onClick={() => setAApagarId(null)} className="border border-border px-4 py-2 text-sm hover:bg-secondary">
              Cancelar
            </button>
            <button
              type="button"
              onClick={async () => {
                const id = aApagarId;
                setAApagarId(null);
                try {
                  await api.del(`/api/${slug}/campanhas/${id}`);
                  if (abertaId === id) setAbertaId(null);
                  carregar();
                  setToast({ id: Date.now(), mensagem: 'Campanha apagada.' });
                } catch (e) {
                  setToast({ id: Date.now(), mensagem: e.message || 'Não foi possível apagar.' });
                }
              }}
              className="bg-[hsl(var(--offline))] px-4 py-2 text-sm text-foreground hover:bg-[hsl(var(--offline)/0.85)]"
            >
              Apagar
            </button>
          </>
        }
      >
        {aApagar && (
          <p>
            <span className="text-foreground">{aApagar.titulo}</span> sai da lista. O que já foi entregue e o histórico ficam.
          </p>
        )}
      </Modal>

      <ModalAprovarCampanha
        aberto={podeGerir && aAprovar && Boolean(aberta)}
        entradasVideo={entradasVideo}
        slug={slug}
        campanha={aberta}
        criativos={criativos}
        onFechar={() => setAAprovar(false)}
        onEditarBriefing={() => { setAAprovar(false); setABriefar(aberta); }}
        onMudou={() => {
          recarregarAberta();
          if (abertaId) api.get(`/api/${slug}/campanhas/${abertaId}/criativos`).then(setCriativos).catch(() => {});
        }}
        onAprovada={depoisDeAprovar}
      />

      <Toast toast={toast} onFechar={fecharToast} />
    </div>
  );
}
