/* Fase 8 (Incremento 3a) do plano de migração Next.js — extração do
 * cacho Mapa+Sheets de dentro de `App()` (app.jsx) pra um componente
 * próprio, reusável. Ver `docs/relatorios/plano-migracao-nextjs-
 * strangler-fig.md` § "Fase 8" pro raciocínio completo de escopo — só
 * o resumo essencial aqui:
 *
 * - NÃO é "wrapper fino" como Tabela/Editor de Mapa — o Mapa nunca foi
 *   um componente isolado, é ~800 linhas de JSX inline dentro de
 *   `App()`. Esta extração é o trabalho de fundo que finalmente torna
 *   um wrapper fino possível (Incremento 3b, futuro: montar isto
 *   também em `/mapa`).
 * - `DetalheSheet`/`sheet`(`PilhaSheet`)/`mover`(`MoverModal`) NÃO
 *   entraram aqui — são cross-view (alcançáveis a partir da Tabela via
 *   a ponte da Fase 4, `app.jsx:285-294`, e o `DetalheSheet` pode abrir
 *   Mover/ir-pra-posição independente de qual `view` está ativa).
 *   Ficam no shell, intocados — mesma estratégia que `/mapa`
 *   (Incremento 2) já usa (bounce pra `/?pallet=` em vez de portar
 *   Sheets.jsx).
 * - Escrita: 3 handlers injetados (`onMover`/`onUnificar`/
 *   `onTrocarNivel`, cada um devolve uma Promise) — as únicas 3 ações
 *   que `PilhaPopup`/arrasto realmente disparam (confirmar/reverter
 *   ponta só existem em `PilhaSheet`/`DetalheSheet`, que ficaram no
 *   shell — nunca em `PilhaPopup`, nem antes da extração; achado
 *   revisando o contrato de props ao começar o Incremento 3b — as
 *   primeiras 2 rotas `/api/mapa/*` do Incremento 1, `confirmar-ponta`/
 *   `reverter-ponta`, ficam sem chamador nenhum por enquanto — só
 *   passam a servir algo se `PilhaSheet`/`DetalheSheet` forem portados
 *   um dia, fora de escopo aqui). O componente nunca sabe se por trás
 *   tem PIN (legado, via `garantirOperador`) ou token (`/mapa`, via
 *   fetch) — só chama, encadeia `onRefetch`/toast/erro.
 * - `modoEdicao` fica de propósito como PROP (`modoEdicao`/
 *   `onToggleModoEdicao`), não state local: reseta pra `false` sempre
 *   que o terminal é re-travado (`app.jsx`, `lockSessao`) — mover o
 *   state pra cá mataria esse reset silenciosamente.
 * - `showToast` também é prop — usado por Loja/EditarDados no shell
 *   também, não é exclusivo do Mapa.
 */
// Duplicadas de app.jsx (consts top-level pequenas, mesmo padrão já usado
// em app/mapa/page.js) — script separado (mesmo os dois sendo carregados
// juntos só no legado, script clássico compartilha o global lexical scope
// então tecnicamente daria pra ler direto de app.jsx, mas duplicar aqui
// deixa este arquivo autossuficiente/legível sem precisar ir ler outro
// arquivo pra achar de onde vêm, mesmo raciocínio de MapView.jsx nunca
// depender de const de fora do próprio arquivo).
const GHOST_CLICK_MS_MAPA = 900; // ver constante irmã em app.jsx pro histórico completo (2026-08-19)
const TOQUE_LIFT_PX_MAPA = 60; // ver constante irmã em app.jsx pro histórico completo (2026-08-24)
const APELIDO_GALPAO_MAPA = {
  2: 'Depósito Cimento Esquerdo', 3: 'Depósito Cimento Lado Direito',
  5: 'Depósito em Frente à Loja', 6: 'Depósito do Angelo',
};

// Busca do Mapa — estado do texto digitado fica LOCAL a este componente
// (não em `buscaMapa` de `MapaSheetsView`), só repassando pro pai
// (debounce de 200ms) depois que a digitação pausa. Sem isso, cada tecla
// mudava `buscaMapa` no componente inteiro, re-renderizando o Mapa do
// galpão atual por completo (até ~480 células sem memoização) a cada
// caractere — medido ao vivo (2026-09-01, CPU 4x throttle): ~90ms de
// trava por tecla. Precisão de diagnóstico: NÃO existe nenhum outro lugar
// que chame `setBuscaMapa` além deste componente (fora do clique de
// digitar) — então dá pra manter o texto 100% local sem risco de ficar
// dessincronizado se algo externo resetasse a busca.
function MapaBuscaInput({ onChange, placeholder }) {
  const { useState, useRef, useEffect } = React;
  const [local, setLocal] = useState('');
  const debounceRef = useRef(null);
  useEffect(() => () => clearTimeout(debounceRef.current), []);
  function mudar(v) {
    setLocal(v);
    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => onChange(v), 200);
  }
  function limpar() {
    setLocal('');
    clearTimeout(debounceRef.current);
    onChange('');
  }
  return (
    <React.Fragment>
      <EIcon name="search" size={17} />
      <input value={local} onChange={(e) => mudar(e.target.value)} placeholder={placeholder} />
      {local.trim().length > 0 && (
        <button className="mb-limpar" onClick={limpar} title="Limpar busca"><EIcon name="x" size={16} /></button>
      )}
    </React.Fragment>
  );
}

function MapaSheetsView({
  db, gal, papel, online, canMover, podeEntrada, showCodes,
  modoEdicao, onToggleModoEdicao,
  // `placing`/`onPlace`/`onCancelarPlacing`: o fluxo "escolher posição pro
  // Mover" (`escolherDestino`/`placeAt`, app.jsx) é cross-view — pode ser
  // disparado do `DetalheSheet` olhando um pallet de QUALQUER galpão, não
  // só do Mapa ativo — então o STATE (`placing`) fica no shell. Mas o
  // MapView interagido durante esse modo é o MESMO desta instância (zoom/
  // pan preservados), então o componente precisa saber renderizar o modo
  // "posicionando" quando o shell pede.
  placing, onPlace, onCancelarPlacing, justMovedSignal, subtabAlvoSignal,
  onMover, onUnificar, onTrocarNivel,
  showToast, onRefetch,
  onAbrirDetalhe, onIniciarMoverCelula, onIniciarMoverRascunho, onAbrirEntradaCelula,
  // O shell renderiza as abas de galpão (header, fora deste componente) e
  // precisa saber "tem busca ativa, e quantas células bateram em CADA
  // galpão" pra mostrar o badge certo nelas (rascunho vs. nº de achados)
  // — mas `buscaMapa` é state interno daqui agora. Reporta pra cima toda
  // vez que muda (efeito abaixo), o shell guarda numa state própria só
  // pro badge; a marcação das células em SI (`matchCells`/`buscaAtiva`
  // passados pro `<MapView>` abaixo) continua 100% local, não depende
  // disso.
  onBuscaChange,
}) {
  const { useState, useRef, useEffect } = React;

  const [subtab, setSubtab] = useState('mapa');
  const [buscaMapa, setBuscaMapa] = useState('');
  const [pilhaPopup, setPilhaPopup] = useState(null);
  // Popup de confirmação ao SOLTAR um arrasto em cima de um alvo válido
  // (célula ou Rascunho) — pedido do Rogger pra nunca mover um pallet sem
  // uma confirmação explícita, mesmo tendo acertado o alvo. Guarda o pallet/
  // origem/alvo e a posição de tela (âncora no card ou na barra do
  // Rascunho, via `getBoundingClientRect` — ver `prepararConfirmacao`) pra
  // desenhar o popup ali perto, com o alvo "iluminado" atrás.
  const [confirmMove, setConfirmMove] = useState(null);
  const [drag, setDrag] = useState(null);
  const dragInfo = useRef(null);
  // Ref imperativo pro card fantasma do arrasto (DragGhost, dentro de
  // MapView.jsx) — ver `moverArraste` abaixo pra entender por que a
  // posição do fantasma NUNCA passa por `setDrag`/React state.
  const ghostRef = useRef(null);
  // Guarda contra "ghost click": em toque real, o navegador sintetiza um
  // `click` de compatibilidade nas mesmas coordenadas ALGUM TEMPO depois do
  // pointerup — quando o tap já reagiu na hora (abrindo popup), esse click
  // atrasado cai em cima do que apareceu ali (backdrop que fecha tudo,
  // botão de outro produto, etc.), como se fosse um 2º toque acidental.
  // `preventDefault` no pointerdown (ver iniciarArraste) não bastou
  // sozinho ao testar em modo touch real — por isso também marcamos aqui o
  // instante de cada tap tratado e descartamos, na captura, qualquer click
  // "confiável" que chegue logo em seguida.
  const ultimoTapReal = useRef(0);
  const [justMoved, setJustMoved] = useState(null);
  const [shake, setShake] = useState(null);

  const canArrastar = canMover && modoEdicao;
  const emPosicionamento = !!placing;
  const placingPal = placing ? db.pallets[placing.palletId] : null;
  const placingProd = placing ? (ESTQ.PRODUTOS[placingPal.prod] || db.produtos[placingPal.prod]) : null;

  // Entrar em modo "posicionando" (gatilho cross-view, ver props acima)
  // sempre mostra o subtab Mapa — nunca faz sentido pedir pra tocar uma
  // célula com o Rascunho na tela. `subtab` continua local (não sobe pro
  // shell) — só reage quando `placing` liga.
  useEffect(() => {
    if (placing) setSubtab('mapa');
  }, [placing]);

  // `justMovedSignal`: o destaque "acabou de chegar aqui" numa célula
  // (`justMoved`) é state interno daqui, mas `placeAt` (shell, cross-view
  // — Mover pra OUTRO galpão) também precisa acioná-lo depois de
  // posicionar com sucesso. Mesmo padrão de `placing` acima: o shell manda
  // um sinal ({code,k}) como prop, este efeito repassa pro state local
  // quando o sinal muda (nunca lido por igualdade profunda — um novo `k`
  // sempre dispara, mesmo pro mesmo `code` de novo).
  useEffect(() => {
    if (justMovedSignal) setJustMoved(justMovedSignal);
  }, [justMovedSignal]);

  // `subtabAlvoSignal` ({value:'mapa'|'rascunho', k} ou null): 2 gatilhos
  // do shell precisam forçar o subtab local daqui — trocar de galpão pelas
  // abas do header (sempre volta pra 'mapa') e "ir pra esse galpão" da
  // visão geral de Rascunhos (`RascunhosGeralView`, sempre abre direto em
  // 'rascunho'). `k` garante que o efeito dispara mesmo pedindo o MESMO
  // valor 2x seguidas (ex.: clicar a aba do galpão já ativo).
  useEffect(() => {
    if (subtabAlvoSignal) setSubtab(subtabAlvoSignal.value);
  }, [subtabAlvoSignal]);

  // Descarta o click fantasma (ver `ultimoTapReal` acima): fica em fase de
  // captura no document, então roda ANTES de qualquer onClick de React
  // (inclusive o de fechar backdrop/scrim) — se o click "confiável" chegou
  // a menos de GHOST_CLICK_MS de um tap já tratado por soltarArraste, é o
  // eco do mesmo toque, não uma 2ª ação do usuário: mata ele ali
  // (stopPropagation + preventDefault) antes que alcance qualquer handler.
  // Janela de 900ms — mesma constante/motivo documentado em app.jsx desde
  // 2026-08-19 (achado do Rogger em G02-B07, toque real via CDP).
  useEffect(() => {
    function bloquearGhostClick(e) {
      if (e.isTrusted && Date.now() - ultimoTapReal.current < GHOST_CLICK_MS_MAPA) {
        e.stopPropagation();
        e.preventDefault();
      }
    }
    document.addEventListener('click', bloquearGhostClick, true);
    return () => document.removeEventListener('click', bloquearGhostClick, true);
  }, []);

  // ---- helpers ----
  function findCell(g, code) { return db.galpoes[g].cells.find((c) => c.code === code); }
  function palLabel(id) {
    const pal = db.pallets[id];
    return (ESTQ.PRODUTOS[pal.prod] || db.produtos[pal.prod]).curto + (pal.lote ? ' (lote ' + pal.lote + ')' : '');
  }

  // ---- mapa: mover / rascunho (ação real — RPC via prop injetada, PIN ou
  // token conforme quem monta o componente) ----
  function moverPallet(palletId, destino, onOk) {
    onMover(palletId, destino)
      .then(onRefetch)
      .then(() => { if (onOk) onOk(); })
      .catch((e) => showToast('Não foi possível mover: ' + e.message, null, true));
  }
  function dropMove(cell, toCode) {
    const from = findCell(gal, cell.code), to = findCell(gal, toCode);
    if (!from.stack.length || to.stack.length >= 5) return;
    const id = from.stack[from.stack.length - 1];
    const label = palLabel(id);
    moverPallet(id, { type: 'celula', celulaId: to.id }, () => {
      setJustMoved({ code: toCode, k: Date.now() });
      showToast(label + ' movido para ' + toCode);
    });
  }
  function dropRascunho(cell) {
    const from = findCell(gal, cell.code);
    if (!from.stack.length) return;
    const id = from.stack[from.stack.length - 1];
    const label = palLabel(id);
    moverPallet(id, { type: 'rascunho', gal }, () => {
      showToast(label + ' enviado para o Rascunho do G' + gal);
    });
  }
  function rejectDrop(code) { setShake({ code, k: Date.now() }); showToast('Posição cheia (5 de 5) — escolha outra', null, true); }

  // Achou um alvo válido soltando o arrasto — em vez de mover na hora,
  // abre o popup de confirmação ancorado perto do alvo (célula ou barra do
  // Rascunho). `dropMove`/`dropRascunho` só rodam de fato depois do
  // "Confirmar" (`confirmarMovimentacao`).
  const CONFIRM_POP_W = 280, CONFIRM_POP_H = 172;
  function prepararConfirmacao(fromCode, alvo) {
    const cell = findCell(gal, fromCode);
    const id = cell.stack[cell.stack.length - 1];
    const el = alvo.type === 'rascunho'
      ? document.querySelector('.rasc-bar')
      : document.querySelector('[data-drop="' + alvo.code + '"]');
    // `.confirm-mover-pop` é `position:fixed` — `left`/`top` em px reais de
    // tela, direto do `getBoundingClientRect` do alvo (sem conversão de
    // escala nenhuma; `.stage` não tem mais `transform`, ver CSS).
    const CONFIRM_GAP = 14;
    let x = window.innerWidth / 2 - CONFIRM_POP_W / 2, y = window.innerHeight / 2 - CONFIRM_POP_H / 2;
    if (el) {
      const rect = el.getBoundingClientRect();
      x = rect.left + rect.width / 2 - CONFIRM_POP_W / 2;
      // Abaixo do alvo por padrão — não mais centralizado em cima dele.
      // Achado do Rogger (2026-08-25): o popup CENTRALIZADO tampava exatamente
      // a célula/barra que acabou de pulsar em laranja (`.confirm-alvo`),
      // atrapalhando ver pra onde o pallet foi. Só sobe pra ACIMA do alvo se
      // não couber embaixo (alvo perto do rodapé da tela) — o alvo continua
      // 100% visível/iluminado nos dois casos.
      const cabeEmbaixo = rect.bottom + CONFIRM_GAP + CONFIRM_POP_H <= window.innerHeight - 8;
      y = cabeEmbaixo ? rect.bottom + CONFIRM_GAP : rect.top - CONFIRM_POP_H - CONFIRM_GAP;
    }
    x = Math.max(8, Math.min(x, window.innerWidth - CONFIRM_POP_W - 8));
    y = Math.max(8, Math.min(y, window.innerHeight - CONFIRM_POP_H - 8));
    // Mesma guarda do "ghost click" (ver `ultimoTapReal` acima): o soltar
    // do arrasto também pode gerar um click sintético atrasado nas mesmas
    // coordenadas — sem isso, ele podia cair em cima do botão Confirmar/
    // Cancelar recém-desenhado ali e disparar sozinho.
    ultimoTapReal.current = Date.now();
    const destinoLabel = alvo.type === 'rascunho' ? ('Rascunho do G' + gal) : alvo.code;
    setConfirmMove({ fromCode, alvo, palletId: id, label: palLabel(id), destinoLabel, x, y });
  }
  function confirmarMovimentacao() {
    const cm = confirmMove; setConfirmMove(null);
    if (!cm) return;
    if (cm.alvo.type === 'rascunho') dropRascunho({ code: cm.fromCode });
    else dropMove({ code: cm.fromCode }, cm.alvo.code);
  }
  function cancelarMovimentacao() { setConfirmMove(null); }

  // ---- arrastar pallet (mapa e popup da pilha compartilham o mesmo mecanismo,
  // pra dar pra arrastar direto de dentro do popup sem passar pelo grid) ----
  // `.drag-ghost` é `position:fixed`, então o ponto do fantasma é só o
  // ponteiro em px reais de tela — sem conversão de escala (`.stage` não
  // tem mais `transform`, ver CSS). Continua colado no dedo/cursor de
  // verdade (o CSS de `.drag-ghost` já levanta o card visualmente por
  // cima, igual sempre foi) — só a DETECÇÃO DE ALVO (`hitTestPoint`
  // abaixo) sobe mais em toque, que é o que resolve o "não dá pra ver a
  // célula" (o card em si sempre foi visível, a célula embaixo do dedo
  // é que sumia).
  function stagePoint(e) {
    return { x: e.clientX, y: e.clientY };
  }
  // Ponto usado só pra decidir qual célula é o alvo (`elementFromPoint`) —
  // em toque, sobe `TOQUE_LIFT_PX` acima do dedo de verdade (ver constante
  // global, app.jsx), porque o dedo cobre exatamente a célula que a
  // pessoa está tentando mirar; mouse/caneta usam o ponto exato (cursor
  // fino, nunca cobre nada).
  function hitTestPoint(e) {
    const lift = e.pointerType === 'touch' ? TOQUE_LIFT_PX_MAPA : 0;
    return { x: e.clientX, y: e.clientY - lift };
  }
  function iniciarArraste(e, cell, onTap, soRascunho) {
    // Achado ao vivo (tablet real): após um toque real (pointerdown+up sem
    // mover), o navegador ainda sintetiza um `click` de compatibilidade nas
    // MESMAS coordenadas, um instante depois. Como o tap já reage no
    // pointerup (abrindo popup), o DOM muda antes desse click sintético
    // chegar — ele acaba caindo em cima do elemento novo que apareceu ali
    // (ex.: o backdrop do popup recém-aberto), fechando o que acabou de
    // abrir ou disparando outra ação, como se fosse um segundo toque
    // aleatório. `preventDefault` no pointerdown suprime esse click
    // fantasma pela spec de Pointer Events — seguro aqui porque `.cell` e o
    // card do topo do popup nunca dependem de `onClick` pra agir, só desse
    // mecanismo de tap/drag.
    if (e.cancelable) e.preventDefault();
    // Célula vazia não tem pallet pra arrastar (`vazio: true` abaixo barra o
    // arrasto em moverArraste), mas ainda precisa do mesmo mecanismo de tap
    // — é assim que o clique numa posição livre abre o cadastro rápido
    // (ver onTapCell/abrirEntradaNaCelula). Sem isso o toque numa célula
    // vazia nunca chegava a soltarArraste (retornava aqui direto).
    dragInfo.current = {
      sx: e.clientX, sy: e.clientY, cell, active: false, over: null, onTap,
      soRascunho: !!soRascunho, vazio: !cell.stack.length,
      // touch: mesma leitura de pointerType que hitTestPoint já usa pro lift
      // — o DragGhost (MapView.jsx) usa isso pra saber se deve se afastar do
      // dedo pra BAIXO (ver `.drag-ghost.touch`, estoque.css) em vez de pra
      // cima como no mouse.
      touch: e.pointerType === 'touch',
      // rafId/lastEvent/drawn/x0/y0: ver moverArraste — controlam o
      // throttling por frame do hit-test e a posição inicial (congelada)
      // do card fantasma. avisouModoConsulta: ver moverArraste — 1 aviso
      // só por gesto, não 1 por pointermove.
      rafId: null, lastEvent: null, drawn: false, x0: 0, y0: 0, avisouModoConsulta: false,
    };
    // Captura sempre no `.cell`, nunca no elemento que disparou o pointerdown:
    // no arrasto pelo nome (soRascunho), o <span> do nome desmonta assim que
    // `active` vira true (o topo da pilha some do array renderizado, ver
    // `isOrigin` em CellView/MapView.jsx) — remover um elemento do DOM libera
    // a captura de ponteiro dele automaticamente, e o gesto morre no meio
    // (pointermove/up nunca mais chegam). `.cell` nunca desmonta durante o
    // arrasto, então a captura sempre sobrevive até o soltar.
    const alvo = (e.currentTarget.closest && e.currentTarget.closest('.cell')) || e.currentTarget;
    try { alvo.setPointerCapture(e.pointerId); } catch (err) { /* eventos sintéticos */ }
  }
  // Mover rápido pelo nome, na lista do card do Mapa (CellLista/MapView.jsx):
  // pressionar e arrastar o nome do pallet do TOPO só permite soltar no
  // Rascunho — não em outra célula do grid. É de propósito mais restrito
  // que o arrasto normal (`iniciarArraste`): o pedido foi um atalho rápido
  // "tirar da posição", não um substituto do Mover completo (que já existe
  // via botão) — restringir evita o operador errar o alvo tentando mirar
  // numa célula minúscula enquanto a lista/popup cobre parte do mapa.
  function iniciarArrasteRascunho(e, cell, onTap) { iniciarArraste(e, cell, onTap, true); }
  function moverParaRascunhoRapido(code) {
    const cell = findCell(gal, code);
    if (cell) dropRascunho(cell);
  }
  function mesmoAlvo(a, b) {
    if (a === b) return true;
    if (!a || !b) return false;
    return a.type === b.type && a.code === b.code;
  }
  // Hit-test em torno de `elementFromPoint` (força layout — por isso só
  // roda 1x por frame durante o arrasto, ver moverArraste). Extraído numa
  // função própria pra `soltarArraste` poder rodar a MESMA lógica de forma
  // síncrona no instante exato do soltar, quando ainda houver um frame
  // pendente — sem isso, soltar bem no meio de um frame agendado usaria o
  // `over` calculado ~1 frame atrás (até 16ms de defasagem), podendo achar
  // a célula errada num arrasto bem rápido.
  function calcularOver(di, ev) {
    const ht = hitTestPoint(ev);
    const el = document.elementFromPoint(ht.x, ht.y);
    const dropEl = el && el.closest('[data-drop]');
    if (!dropEl) return null;
    const d = dropEl.getAttribute('data-drop');
    if (d === 'rascunho') return { type: 'rascunho' };
    if (di.soRascunho) return null;
    const c = db.galpoes[gal].cells.find((c) => c.code === d);
    if (!c || c.code === di.cell.code) return null;
    return c.stack.length < 5 ? { type: 'celula', code: d } : { type: 'cheia', code: d };
  }
  // Otimização de performance (2026-08-24, pedido do Rogger — Mapa
  // "parecendo 15fps" ao arrastar): antes, CADA pointermove chamava
  // `setDrag` — um state do componente inteiro — o que re-renderizava
  // TODAS as células do galpão (até 160+) a cada pixel de movimento do
  // dedo/mouse. Agora a posição do card fantasma é escrita direto no DOM
  // via `ghostRef` (imperativo, sem passar pelo React) a cada evento — é
  // barato, não mexe em nada além do próprio card. O hit-test
  // (`elementFromPoint`, que força layout do navegador) e o resto da
  // lógica (`over`) rodam no máximo 1x por frame (`requestAnimationFrame`),
  // e `setDrag` só é chamado quando a célula-alvo realmente muda — não a
  // cada frame. Resultado: o Mapa só re-renderiza quando algo VISÍVEL
  // precisa mudar (origem/alvo), não a cada pixel.
  function moverArraste(e) {
    const di = dragInfo.current;
    if (!di) return;
    if (di.vazio) return; // nada pra arrastar numa célula vazia — só o tap importa
    if (!di.active) {
      if (Math.hypot(e.clientX - di.sx, e.clientY - di.sy) < 10) return;
      if (!canArrastar) {
        // Pedido do Rogger (2026-08-24): quem TEM permissão de mover
        // (`canMover`) mas está em modo consulta só via o arrasto "não
        // fazer nada" — nenhum feedback do porquê. Avisa 1x por gesto
        // (`avisouModoConsulta`, não a cada pointermove) e só quando é de
        // fato uma TENTATIVA de arrastar (já passou dos 10px de threshold
        // aqui em cima) — nunca num toque simples/leve tremor de dedo, que
        // continua caindo pro popup normal sem aviso nenhum. Offline ou
        // sem papel pra mover (`!canMover`) não mostra esse aviso — a
        // causa ali é outra, não "esqueceu de ligar o modo edição".
        if (canMover && !modoEdicao && !di.avisouModoConsulta) {
          di.avisouModoConsulta = true;
          showToast('Modo edição desativado — ligue para mover pallets', null, true);
        }
        return;
      }
      di.active = true;
      const pt0 = stagePoint(e);
      di.x0 = pt0.x; di.y0 = pt0.y; // posição inicial do fantasma — congelada, só pro mount (ver DragGhost)
    }
    const pt = stagePoint(e);
    if (ghostRef.current) ghostRef.current.mover(pt.x, pt.y);

    di.lastEvent = e;
    if (di.rafId) return; // já tem um frame agendado, este evento só atualiza lastEvent
    di.rafId = requestAnimationFrame(() => {
      di.rafId = null;
      if (dragInfo.current !== di) return; // arrasto já terminou enquanto o frame esperava
      const over = calcularOver(di, di.lastEvent);
      const mudou = !mesmoAlvo(di.over, over);
      di.over = over;
      if (!mudou && di.drawn) return; // alvo não mudou — nada de novo pra re-renderizar
      di.drawn = true;
      setDrag({ palletId: di.cell.stack[di.cell.stack.length - 1], fromCode: di.cell.code, over, soRascunho: di.soRascunho, x0: di.x0, y0: di.y0, touch: di.touch });
    });
  }
  function soltarArraste() {
    const di = dragInfo.current;
    // Frame pendente descartado — recalcula síncrono aqui embaixo (`over`),
    // com o `lastEvent` mais recente possível, pra não soltar com o alvo de
    // ~1 frame atrás (ver nota em `calcularOver`).
    if (di && di.rafId) { cancelAnimationFrame(di.rafId); di.rafId = null; }
    dragInfo.current = null;
    if (!di) return;
    if (!di.active) { setDrag(null); ultimoTapReal.current = Date.now(); di.onTap && di.onTap(di.cell); return; }
    const over = di.lastEvent ? calcularOver(di, di.lastEvent) : di.over;
    setDrag(null);
    // Arrasto que virou "de verdade" (passou dos 10px) mas soltou sem achar
    // um alvo válido (voltou pra própria origem, saiu pra fora de qualquer
    // célula, etc.) — achado ao vivo: isso ficava mudo, o card só "voltava
    // no lugar" sem abrir nada. Um toque só um pouco impreciso (dedo/mouse
    // real quase nunca fica 100% parado) virava um arrasto fantasma que não
    // fazia nada — o usuário via "só o arrasto funciona, o clique não".
    // Tratar como um tap normal aqui cobre os dois casos com uma regra só.
    if (!over) { ultimoTapReal.current = Date.now(); di.onTap && di.onTap(di.cell); return; }
    if (over.type === 'rascunho') prepararConfirmacao(di.cell.code, { type: 'rascunho' });
    else if (over.type === 'cheia') rejectDrop(over.code);
    else prepararConfirmacao(di.cell.code, { type: 'celula', code: over.code });
  }

  // ---- ações rápidas do PilhaPopup (unificar/trocar nível — confirmar/
  // reverter ponta NÃO existem aqui, só em PilhaSheet/DetalheSheet,
  // shell) ----
  function unificarPallets(principalId, secundariosIds) {
    onUnificar(principalId, secundariosIds)
      .then(onRefetch)
      .then(() => showToast('Pallets unificados.'))
      .catch((e) => showToast('Não foi possível unificar: ' + e.message, null, true));
  }
  // Setas ↑/↓ do popup de zoom da pilha (ver PilhaPopup) — troca DIRETA
  // (sem confirmação extra, pedido do Rogger) entre dois níveis vizinhos da
  // MESMA célula, sem passar por Rascunho. Ver `trocar_nivel_pallets`
  // (migration 20260903140000) e docs/relatorios/plano-uxui-pilha-card-2026-09-03.md.
  function trocarNivelPallets(palletIdA, palletIdB) {
    onTrocarNivel(palletIdA, palletIdB)
      .then(onRefetch)
      .catch((e) => showToast('Não foi possível reordenar: ' + e.message, null, true));
  }

  // Extraído do JSX do `<MapView>` (era uma arrow inline) pra poder passar
  // por identidade estável (ver `useEstavel`, app.jsx) — necessário pro
  // `React.memo` de `CellView` (MapView.jsx) funcionar de verdade.
  function onTapCellMapa(cell) {
    // Mesma guarda de `ultimoTapReal` que soltarArraste já arma (ver
    // `bloquearGhostClick` acima) — o toque RÁPIDO numa célula
    // (MapView.jsx: onCellPointerUp, gesto decidido em <CELL_LONGPRESS_MS)
    // chama este onTapCell direto, sem passar por soltarArraste, então sem
    // isto aqui o click fantasma sintético do navegador ficava livre pra
    // cair em qualquer botão do header/nav que estivesse embaixo do dedo.
    ultimoTapReal.current = Date.now();
    if (!cell.stack.length) { if (podeEntrada && online) onAbrirEntradaCelula(cell); return; }
    setPilhaPopup(cell.code);
  }

  // ---- identidade estável dos handlers que chegam até `CellView`
  // (MapView.jsx) — mesmo mecanismo/motivo de `useEstavel` em app.jsx:
  // sem isso, `React.memo(CellView)` não teria efeito nenhum (uma prop de
  // função nova a cada render derrota a comparação rasa do memo pra TODAS
  // as células). Hooks vêm incondicionalmente aqui em cima, antes de
  // qualquer lógica condicional do componente.
  function useEstavel(fn) {
    const fnRef = useRef(fn);
    fnRef.current = fn;
    const wrapperRef = useRef((...args) => fnRef.current(...args));
    return wrapperRef.current;
  }
  const iniciarArrasteStable = useEstavel(iniciarArraste);
  const moverArrasteStable = useEstavel(moverArraste);
  const soltarArrasteStable = useEstavel(soltarArraste);
  const iniciarArrasteRascunhoStable = useEstavel(iniciarArrasteRascunho);
  const onTapCellStable = useEstavel(onTapCellMapa);

  // Busca no mapa: acha o pallet em qualquer nível da pilha (não só o topo,
  // que é tudo que a grade mostra hoje) — motivo: com 4 níveis empilhados,
  // um piso nos níveis 1-3 fica invisível pro gestor sem isso.
  const buscaAtiva = buscaMapa.trim().length > 0;
  const matchCellsPorGalpao = {};
  if (buscaAtiva) {
    const termo = buscaMapa.trim().toLowerCase();
    Object.keys(db.galpoes).forEach((g) => {
      const set = new Set();
      db.galpoes[g].cells.forEach((c) => {
        const bate = c.stack.some((palId) => {
          const pal = db.pallets[palId];
          const prod = pal && (ESTQ.PRODUTOS[pal.prod] || db.produtos[pal.prod]);
          if (!prod) return false;
          return (prod.nome + ' ' + prod.marca + ' ' + (prod.codigo || '')).toLowerCase().includes(termo);
        });
        if (bate) set.add(c.code);
      });
      matchCellsPorGalpao[g] = set;
    });
  }

  // Reporta pro shell (badges das abas de galpão no header) — ver prop
  // `onBuscaChange` acima. `matchCellsPorGalpao` é um objeto NOVO a cada
  // render (recém-computado ali em cima), então o efeito roda a cada
  // render onde `buscaMapa`/`db` mudaram de verdade — sem `useMemo` aqui,
  // não vale a pena pra um objeto pequeno (no máx. 4 Sets).
  useEffect(() => {
    onBuscaChange(buscaAtiva, matchCellsPorGalpao);
    // eslint-disable-next-line
  }, [buscaMapa, db]);

  const rascCount = db.galpoes[gal].rascunho.length;

  return (
    <React.Fragment>
      {emPosicionamento ? (
        <div className="place-banner">
          <div className="msg">
            Toque numa posição livre do G{gal} para posicionar
            <span className="pal-chip">
              <span className="sw" style={{ background: placingProd.cor }}></span>
              {placingProd.curto} · {ESTQ.qtdCurto(placingPal)}
            </span>
          </div>
          <button className="btn-cancel-place" onClick={onCancelarPlacing}>Cancelar</button>
        </div>
      ) : (
        <div className="subbar">
          <div className="subtabs">
            <button className={'subtab' + (subtab === 'mapa' ? ' active' : '')} onClick={() => setSubtab('mapa')}>Mapa</button>
            <button className={'subtab' + (subtab === 'rascunho' ? ' active' : '')} onClick={() => { setSubtab('rascunho'); setPilhaPopup(null); }}>
              Rascunho <span className="n">{rascCount}</span>
            </button>
          </div>
          <span className="gal-name">
            Galpão {gal}
            {APELIDO_GALPAO_MAPA[gal] && <span className="gal-apelido"> · {APELIDO_GALPAO_MAPA[gal]}</span>}
          </span>
          <div className="mapa-busca">
            <MapaBuscaInput onChange={setBuscaMapa} placeholder="Achar piso no mapa (nome, marca, código)…" />
          </div>
          {buscaAtiva && (
            <span className="mb-resumo">
              {matchCellsPorGalpao[gal] ? matchCellsPorGalpao[gal].size : 0} {(matchCellsPorGalpao[gal] ? matchCellsPorGalpao[gal].size : 0) === 1 ? 'posição' : 'posições'} neste galpão
            </span>
          )}
          {/* Modo edição (pedido do Rogger, 2026-08-21): abrir o Mapa é
              sempre só-consulta, mesmo pra gestor — arrastar só é
              permitido depois de ligar isso aqui. Só aparece pra quem
              teria permissão de mover de qualquer forma (`canMover` —
              papel certo + online); state fica no shell (`app.jsx`), ver
              comentário no topo deste arquivo. */}
          {canMover && (
            <button className={'btn-modo-edicao' + (modoEdicao ? ' on' : '')} onClick={onToggleModoEdicao}
              title={modoEdicao ? 'Desligar modo edição — voltar a só consultar' : 'Ligar modo edição para arrastar pallets no mapa'}
              role="switch" aria-checked={modoEdicao}>
              <span className="me-txt">{modoEdicao ? 'Modo edição' : 'Modo consulta'}</span>
              <span className="me-switch"><span className="me-knob"></span></span>
            </button>
          )}
          {podeEntrada && (
            <button className="btn-entrada" disabled={!online} onClick={() => { location.href = '/entrada'; }}>
              <EIcon name="plus" size={18} strokeWidth={2.2} /> Entrada
            </button>
          )}
        </div>
      )}

      {subtab === 'mapa' ? (
        <MapView
          db={db} gal={gal} drag={drag} placing={emPosicionamento}
          showCodes={showCodes} justMoved={justMoved} shake={shake}
          buscaAtiva={buscaAtiva} matchCells={matchCellsPorGalpao[gal]} podeEntrada={podeEntrada}
          confirmAlvo={confirmMove && confirmMove.alvo} ghostRef={ghostRef}
          onTapCell={onTapCellStable}
          onDown={iniciarArrasteStable} onMove={moverArrasteStable} onUp={soltarArrasteStable}
          onPlace={onPlace} onOpenRascunho={() => setSubtab('rascunho')}
          onVerDetalhe={onAbrirDetalhe}
          onNomeDown={iniciarArrasteRascunhoStable} onMoverRascunho={moverParaRascunhoRapido} canMover={canArrastar}
        />
      ) : (
        <RascunhoView db={db} gal={gal} canWrite={online} papel={papel}
          onPosicionar={onIniciarMoverRascunho} onDetalhe={onAbrirDetalhe} />
      )}

      {pilhaPopup && (
        // Os 3 handoffs pro shell (Mover/Adicionar/Detalhe) fecham o popup
        // ANTES de entregar pro shell — o shell não tem mais como alcançar
        // `pilhaPopup` (state interno daqui) pra fechá-lo de fora quando o
        // overlay que ele abriu (MoverModal/DetalheSheet) for fechado
        // depois; original `app.jsx` fazia isso no `onFechar` do
        // DetalheSheet (`setPilhaPopup(null)` junto de `setDetail(null)`)
        // — fechar aqui, no instante da transição, cobre o mesmo caso sem
        // precisar de um canal de volta shell→componente.
        <PilhaPopup db={db} gal={gal} cellCode={pilhaPopup} online={online} papel={papel} drag={drag}
          onClose={() => setPilhaPopup(null)}
          onDown={iniciarArrasteStable} onMove={moverArrasteStable} onUp={soltarArrasteStable}
          onMover={(id, code) => { setPilhaPopup(null); onIniciarMoverCelula(id, code); }}
          onAdicionar={(cell) => { setPilhaPopup(null); onAbrirEntradaCelula(cell); }}
          onAbrirDetalhe={(palletId) => { setPilhaPopup(null); onAbrirDetalhe(palletId); }}
          onUnificar={unificarPallets} onTrocarNivel={trocarNivelPallets} />
      )}

      {confirmMove && (
        <ConfirmMoverPopup db={db} confirmMove={confirmMove}
          onConfirmar={confirmarMovimentacao} onCancelar={cancelarMovimentacao} />
      )}
    </React.Fragment>
  );
}
