// Transactions table view with filters and editing
function Transactions({ ws, setWs, activeWs, accent, currentMonth, setCurrentMonth, rangeMode, setRangeMode, customRange, setCustomRange, isPro, showUpgrade }) {
  // Free: limite de 50 lançamentos manuais/mês
  const txnsThisMonth = isPro ? 0 : window.PlanilhaPlan.countManualTxnsThisMonth(ws);
  const FREE_TXN_LIMIT = window.PlanilhaPlan.FREE_LIMITS.manualTxnsPerMonth;
  const txnLimitHit = !isPro && txnsThisMonth >= FREE_TXN_LIMIT;
  const invoicesThisMonth = isPro ? 0 : window.PlanilhaPlan.getInvoiceImportsThisMonth(ws.key);
  const FREE_INVOICE_LIMIT = window.PlanilhaPlan.FREE_LIMITS.invoiceImportsPerMonth;
  const invoiceLimitHit = !isPro && invoicesThisMonth >= FREE_INVOICE_LIMIT;
  const [filter, setFilter] = React.useState({ search: "", status: "TODOS", category: "TODAS", type: "TODOS", payment: "TODAS" });
  const [showAdd, setShowAdd] = React.useState(false);
  const [showInvoice, setShowInvoice] = React.useState(false);
  const [showIncomeImport, setShowIncomeImport] = React.useState(false);
  const [editingTxn, setEditingTxn] = React.useState(null);
  const isPessoal = activeWs === "pessoal";

  const period = usePeriod(currentMonth, rangeMode, customRange);
  const inPeriod = (dateStr) => {
    const d = new Date(dateStr);
    return d >= period.from && d <= period.to;
  };

  // Replicar mês anterior: pega todas as transações + receitas do mês anterior e duplica para o mês atual
  const replicateLastMonth = () => {
    const prevDate = new Date(currentMonth.year, currentMonth.month - 1, 1);
    const prevY = prevDate.getFullYear(), prevM = prevDate.getMonth();
    const prevTxns = ws.transactions.filter(t => {
      const d = new Date(t.date);
      return d.getFullYear() === prevY && d.getMonth() === prevM;
    });
    const prevRecs = (ws.receitas || []).filter(r => {
      const d = new Date(r.date);
      return d.getFullYear() === prevY && d.getMonth() === prevM;
    });
    const totalPrev = prevTxns.length + prevRecs.length;
    if (totalPrev === 0) {
      alert("Nenhum lançamento no mês anterior para replicar.");
      return;
    }
    // Sem confirm() — lança direto com undo toast
    const lastDay = new Date(currentMonth.year, currentMonth.month + 1, 0).getDate();
    const newTxns = prevTxns.map(t => {
      const d = new Date(t.date);
      const day = Math.min(d.getDate(), lastDay);
      const newDate = new Date(currentMonth.year, currentMonth.month, day);
      return { ...t, id: crypto.randomUUID(), date: newDate.toISOString().slice(0, 10), status: "PENDENTE" };
    });
    const newRecs = prevRecs.map(r => {
      const d = new Date(r.date);
      const day = Math.min(d.getDate(), lastDay);
      const newDate = new Date(currentMonth.year, currentMonth.month, day);
      return { ...r, id: crypto.randomUUID(), date: newDate.toISOString().slice(0, 10), status: "A RECEBER" };
    });
    const newOutIds = newTxns.map(t => t.id);
    const newInIds = newRecs.map(r => r.id);
    setWs(prev => ({
      ...prev,
      transactions: [...newTxns, ...prev.transactions],
      receitas: [...newRecs, ...(prev.receitas || [])],
    }));
    if (window.toast) {
      window.toast({
        message: `✓ Replicado: ${totalPrev} lançamentos do mês anterior`,
        action: {
          label: "Desfazer",
          onClick: () => {
            setWs(prev => ({
              ...prev,
              transactions: (prev.transactions || []).filter(t => !newOutIds.includes(t.id)),
              receitas: (prev.receitas || []).filter(r => !newInIds.includes(r.id)),
            }));
          },
        },
        duration: 8000,
      });
    }
  };

  // Merge OUT + IN — memoizado pra não recriar a cada render (importante com 20k+ items)
  const allRows = React.useMemo(() => [
    ...ws.transactions.map(t => ({ ...t, type: "OUT" })),
    ...(ws.receitas || []).map(r => ({ ...r, type: "IN" })),
  ], [ws.transactions, ws.receitas]);

  // Filtros memoizados — só refaz se mudou algo relevante
  const filtered = React.useMemo(() => {
    let f = allRows.filter(t => inPeriod(t.date));
    if (filter.type !== "TODOS") f = f.filter(t => t.type === filter.type);
    if (filter.search) {
      const q = filter.search.toLowerCase();
      f = f.filter(t => (t.desc || "").toLowerCase().includes(q) || (t.subcategory || "").toLowerCase().includes(q));
    }
    if (filter.status !== "TODOS") f = f.filter(t => t.status === filter.status);
    if (filter.category !== "TODAS") f = f.filter(t => (t.category || "") === filter.category);
    if (filter.payment !== "TODAS") f = f.filter(t => (t.payment || "") === filter.payment);
    return f;
  }, [allRows, filter, rangeMode, currentMonth, customRange]);

  // Sort memoizado (sort em 22k items é pesado — cacheia)
  const sortedFiltered = React.useMemo(() => {
    return [...filtered].sort((a, b) => new Date(b.date) - new Date(a.date));
  }, [filtered]);

  // Totais memoizados — somar 22k items a cada render trava
  const { totalOut, totalIn, net } = React.useMemo(() => {
    let out = 0, inn = 0;
    for (const r of sortedFiltered) {
      if (r.type === "OUT") out += (Number(r.value) || 0);
      else inn += (Number(r.value) || 0);
    }
    return { totalOut: out, totalIn: inn, net: inn - out };
  }, [sortedFiltered]);

  // LAZY RENDERING — só renderiza primeiros N items, carrega mais conforme rola.
  // Sem isso, 22k itens no DOM trava qualquer browser.
  const INITIAL_LIMIT = 200;
  const LOAD_MORE_STEP = 200;
  const [displayLimit, setDisplayLimit] = React.useState(INITIAL_LIMIT);
  // Reset displayLimit quando filtros mudam (volta pro topo)
  React.useEffect(() => { setDisplayLimit(INITIAL_LIMIT); }, [filter, rangeMode]);

  const visibleRows = React.useMemo(
    () => sortedFiltered.slice(0, displayLimit),
    [sortedFiltered, displayLimit]
  );
  const hasMore = sortedFiltered.length > displayLimit;

  // IntersectionObserver detecta quando QUALQUER sentinela aparece (mobile OU desktop)
  // → carrega mais 200 items. Uso uma sentinela em cada view (só uma é visível por vez via CSS).
  const sentinelMobileRef = React.useRef(null);
  const sentinelDesktopRef = React.useRef(null);
  React.useEffect(() => {
    if (!hasMore) return;
    const obs = new IntersectionObserver((entries) => {
      if (entries.some(e => e.isIntersecting)) {
        setDisplayLimit(prev => prev + LOAD_MORE_STEP);
      }
    }, { rootMargin: "300px" });
    if (sentinelMobileRef.current) obs.observe(sentinelMobileRef.current);
    if (sentinelDesktopRef.current) obs.observe(sentinelDesktopRef.current);
    return () => obs.disconnect();
  }, [hasMore, visibleRows.length]);

  const parseVal = (v) => parseFloat(String(v ?? "0").replace(",", ".")) || 0;

  // Status badge: cycle rápido (sem abrir modal)
  const quickStatus = (row, newStatus) => {
    const arrKey = row.type === "IN" ? "receitas" : "transactions";
    setWs(prev => ({
      ...prev,
      [arrKey]: (prev[arrKey] || []).map(t => t.id === row.id ? { ...t, status: newStatus } : t)
    }));
  };
  const deleteTxn = (row) => {
    // Sem confirm() — exclui direto com undo toast (8s pra desfazer)
    const arrKey = row.type === "IN" ? "receitas" : "transactions";
    const snapshot = row; // guarda o objeto completo pra reinserir se desfizer
    setWs(prev => ({ ...prev, [arrKey]: (prev[arrKey] || []).filter(t => t.id !== row.id) }));
    if (window.toast) {
      window.toast({
        message: `✓ Excluído: ${row.desc || "Lançamento"}`,
        action: {
          label: "Desfazer",
          onClick: () => {
            setWs(prev => ({ ...prev, [arrKey]: [snapshot, ...(prev[arrKey] || [])] }));
          },
        },
        duration: 8000,
      });
    }
    // Comprovante só é deletado de fato depois do timeout — se user desfizer
    // antes, mantém o arquivo. Se passar do toast, deleta lazily na sync.
    if (row.receiptUrl) {
      setTimeout(() => {
        // Confirma que ainda está deletado antes de apagar o blob
        try { window.db.deleteReceipt(row.receiptUrl); } catch (e) {}
      }, 8500);
    }
  };

  // saveTxn: handles both add (existing=null) and edit (existing=row).
  // Returns id (always — useful for add+upload-receipt flow).
  // If type changed during edit, MOVE the row between transactions/receitas arrays.
  const saveTxn = (draft, existing) => {
    const newIsIn = draft.type === "IN";
    const newArrKey = newIsIn ? "receitas" : "transactions";
    const { type, ...cleanDraft } = draft;
    const normalized = { ...cleanDraft, value: parseVal(cleanDraft.value) };

    if (existing) {
      // EDIT
      const oldArrKey = existing.type === "IN" ? "receitas" : "transactions";
      const id = existing.id;
      if (oldArrKey === newArrKey) {
        // Mesmo array — atualiza in-place
        setWs(prev => ({
          ...prev,
          [oldArrKey]: (prev[oldArrKey] || []).map(t => t.id === id ? { ...t, ...normalized } : t),
        }));
      } else {
        // Mudou tipo — remove do array antigo, insere no novo (mantém id)
        setWs(prev => ({
          ...prev,
          [oldArrKey]: (prev[oldArrKey] || []).filter(t => t.id !== id),
          [newArrKey]: [{ ...normalized, id }, ...(prev[newArrKey] || [])],
        }));
      }
      setEditingTxn(null);
      return id;
    }

    // ADD
    const id = crypto.randomUUID();
    setWs(prev => ({ ...prev, [newArrKey]: [{ ...normalized, id }, ...(prev[newArrKey] || [])] }));
    // Pula pro mês do lançamento se cair fora do período visível
    const d = new Date(normalized.date);
    if (!isNaN(d) && !inPeriod(normalized.date)) {
      setRangeMode("MES");
      setCurrentMonth({ year: d.getFullYear(), month: d.getMonth() });
    }
    setShowAdd(false);
    return id;
  };

  const importInvoice = (preview) => {
    // Incrementa contador Free (1 importação/mês)
    if (!isPro && ws.key) window.PlanilhaPlan.incrementInvoiceImports(ws.key);
    // Mantém só as classificações existentes — não adiciona novas no import
    // Separa entradas (receitas) e saídas (transactions) pelo type
    const newOut = [];
    const newIn = [];
    preview.txns.forEach(t => {
      const { type, ...rest } = t;
      if (type === "IN") newIn.push(rest);
      else newOut.push(rest);
    });

    // Mapeia updates pra atualizar status de pendentes em vez de duplicar
    const updateMap = { transactions: {}, receitas: {} };
    (preview.pendingUpdates || []).forEach(u => {
      updateMap[u.arr][u.id] = { status: u.newStatus, payment: u.payment };
    });

    setWs(prev => ({
      ...prev,
      transactions: [
        ...newOut,
        ...((prev.transactions || []).map(t => updateMap.transactions[t.id]
          ? { ...t, ...updateMap.transactions[t.id] }
          : t
        )),
      ],
      receitas: [
        ...newIn,
        ...((prev.receitas || []).map(r => updateMap.receitas[r.id]
          ? { ...r, ...updateMap.receitas[r.id] }
          : r
        )),
      ],
    }));

    // Expande o período pra COBRIR o range importado (incluindo datas dos updates)
    const allDates = [
      ...preview.txns.map(t => t.date),
      ...(preview.pendingUpdates || []).map(u => {
        const arr = u.arr === "transactions" ? (ws.transactions || []) : (ws.receitas || []);
        return arr.find(t => t.id === u.id)?.date;
      }),
    ].filter(Boolean).sort();
    if (allDates.length && setRangeMode && setCustomRange) {
      const newFrom = allDates[0];
      const newTo = allDates[allDates.length - 1];
      setRangeMode("PERSONALIZADO");
      setCustomRange(prev => {
        if (!prev || !prev.from || !prev.to) return { from: newFrom, to: newTo };
        return {
          from: prev.from < newFrom ? prev.from : newFrom,
          to: prev.to > newTo ? prev.to : newTo,
        };
      });
    }
  };

  const clearAll = async () => {
    const totalCount = ws.transactions.length + (ws.receitas || []).length;
    if (totalCount === 0) {
      alert("Esta conta já está vazia.");
      return;
    }
    const wsName = ws.name || "esta conta";
    const ok = confirm(
      `Excluir TODOS os ${totalCount} lançamentos de "${wsName}"?\n\n` +
      `• Entradas: ${(ws.receitas || []).length}\n` +
      `• Saídas: ${ws.transactions.length}\n\n` +
      `Esta ação não pode ser desfeita.`
    );
    if (!ok) return;
    // 3 confirmações pra reduzir ainda mais a chance de acidente.
    const ok2 = confirm(`Tem certeza? Digite OK para confirmar a exclusão de ${totalCount} lançamentos.`);
    if (!ok2) return;
    const phrase = `LIMPAR ${wsName}`;
    const typed = prompt(`Confirmação final: digite exatamente\n\n${phrase}\n\npra excluir tudo.`);
    if (typed !== phrase) {
      alert("Texto não confere. Operação cancelada — nada foi excluído.");
      return;
    }
    // Caminho EXPLÍCITO no DB com tokens literais — bypassa a sync (que é blindada).
    // Reload força fetchAll fresco e impede que o diff veja "N→0" depois.
    try {
      await window.db.deleteAllTransactions(activeWs, "I_REALLY_WANT_TO_DELETE_ALL_TRANSACTIONS");
      await window.db.deleteAllIncomes(activeWs, "I_REALLY_WANT_TO_DELETE_ALL_INCOMES");
      alert(`${totalCount} lançamentos excluídos. Recarregando…`);
      window.location.reload();
    } catch (e) {
      alert("Erro ao limpar: " + (e?.message || e));
    }
  };

  // Lista de categorias pra filtro: cadastradas em Settings + usadas nos lançamentos
  // (cobre o caso de imports antigos onde a categoria não foi adicionada ao ws.categories)
  const allFilterCategories = React.useMemo(() => {
    const set = new Set(ws.categories || []);
    (ws.transactions || []).forEach(t => { if (t.category) set.add(t.category); });
    (ws.receitas || []).forEach(r => { if (r.category) set.add(r.category); });
    return [...set].sort();
  }, [ws.categories, ws.transactions, ws.receitas]);

  // Menu kebab mobile com ações secundárias (Replicar / Importar / Limpar)
  const [menuOpen, setMenuOpen] = React.useState(false);

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      {/* Period bar — mesmo seletor do Dashboard, estado compartilhado */}
      <PeriodBar rangeMode={rangeMode} setRangeMode={setRangeMode} customRange={customRange} setCustomRange={setCustomRange} period={period} />

      {/* Filters bar */}
      <div className="card tx-filter-bar" style={{ padding: 14, display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
        <div style={{ position: "relative", flex: 1, minWidth: 220 }}>
          <span style={{ position: "absolute", left: 10, top: "50%", transform: "translateY(-50%)", color: "var(--fg-3)" }}>
            <Icon name="search" size={14} />
          </span>
          <input
            value={filter.search}
            onChange={(e) => setFilter(f => ({ ...f, search: e.target.value }))}
            placeholder="Buscar descrição..."
            className="input"
            style={{ paddingLeft: 32, width: "100%" }}
          />
        </div>
        <Pill
          label="Tipo"
          value={filter.type}
          options={["TODOS", "IN", "OUT"]}
          optionLabels={{ TODOS: "Tudo", IN: "Entradas", OUT: "Saídas" }}
          onChange={v => setFilter(f => ({ ...f, type: v }))}
        />
        <Pill
          label="Status"
          value={filter.status}
          options={["TODOS", "PAGO", "PENDENTE", "AGENDADO", "RECEBIDO", "A RECEBER"]}
          optionLabels={{ TODOS: "Todos", PAGO: "Pago", PENDENTE: "Pendente", AGENDADO: "Agendado", RECEBIDO: "Recebido", "A RECEBER": "A receber" }}
          onChange={v => setFilter(f => ({ ...f, status: v }))}
        />
        <Pill
          label="Classificação"
          value={filter.category}
          options={["TODAS", ...allFilterCategories]}
          optionLabels={{ TODAS: "Todas", ...Object.fromEntries(allFilterCategories.map(c => [c, c.charAt(0) + c.slice(1).toLowerCase()])) }}
          onChange={v => setFilter(f => ({ ...f, category: v }))}
        />
        <Pill
          label="Pagamento"
          value={filter.payment}
          options={["TODAS", ...(ws.payments || [])]}
          optionLabels={{ TODAS: "Todos", ...Object.fromEntries((ws.payments || []).map(p => [p, p])) }}
          onChange={v => setFilter(f => ({ ...f, payment: v }))}
        />
        {/* Ações secundárias — desktop: inline; mobile: dentro do menu kebab */}
        <div className="tx-secondary-actions" style={{ display: "contents" }}>
          <button className="btn-ghost" onClick={replicateLastMonth} title="Duplica todos os lançamentos do mês anterior para este mês">
            <Icon name="spark" size={13} /> Replicar mês anterior
          </button>
          <button
            className="btn-ghost"
            onClick={clearAll}
            title="Apagar TODOS os lançamentos desta conta (entradas e saídas)"
            style={{ color: "var(--danger)", borderColor: "color-mix(in oklab, var(--danger) 25%, var(--border))" }}
          >
            <Icon name="trash" size={13} /> Limpar tudo
          </button>
          <button
            className="btn-ghost"
            onClick={() => {
              if (invoiceLimitHit) {
                showUpgrade(`Você usou sua importação de fatura grátis deste mês (${invoicesThisMonth}/${FREE_INVOICE_LIMIT}). No Pro são ilimitadas.`);
                return;
              }
              setShowInvoice(true);
            }}
            title={invoiceLimitHit ? "Limite Free atingido — faça upgrade no Pro" : "Importa fatura de cartão (PDF/Word/TXT/CSV)"}
            style={invoiceLimitHit ? { opacity: 0.6 } : undefined}
          >
            <Icon name="spark" size={13} /> Importar fatura{invoiceLimitHit ? " · Pro" : ""}
          </button>
          <button
            className="btn-ghost"
            onClick={() => setShowIncomeImport(true)}
            title="Importa planilha de relatório de vendas/receitas (Excel ou CSV) — tudo entra como receita"
            style={{ color: "var(--success)", borderColor: "color-mix(in oklab, var(--success) 30%, var(--border))" }}
          >
            <Icon name="arrowUp" size={13} /> Importar receitas
          </button>
        </div>

        {/* Kebab mobile — só visível em mobile via CSS */}
        <div className="tx-kebab-wrap" style={{ position: "relative", display: "none" }}>
          <button
            className="icon-btn"
            onClick={() => setMenuOpen(o => !o)}
            title="Mais ações"
            style={{ border: "1px solid var(--border)" }}
          >
            <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
              <circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" />
            </svg>
          </button>
          {menuOpen && (
            <>
              <div onClick={() => setMenuOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 50 }} />
              <div className="tx-kebab-menu" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 60, background: "var(--surface-1)", border: "1px solid var(--border)", borderRadius: 12, boxShadow: "0 6px 24px rgba(0,0,0,0.12)", padding: 6, minWidth: 220, maxWidth: "calc(100vw - 24px)", whiteSpace: "nowrap" }}>
                <button onClick={() => { setMenuOpen(false); replicateLastMonth(); }} className="menu-item" style={{ width: "100%", textAlign: "left" }}>
                  <Icon name="spark" size={12} /> Replicar mês anterior
                </button>
                <button
                  onClick={() => {
                    setMenuOpen(false);
                    if (invoiceLimitHit) { showUpgrade(`Você usou sua importação grátis (${invoicesThisMonth}/${FREE_INVOICE_LIMIT}).`); return; }
                    setShowInvoice(true);
                  }}
                  className="menu-item" style={{ width: "100%", textAlign: "left" }}
                >
                  <Icon name="spark" size={12} /> Importar com IA{invoiceLimitHit ? " · Pro" : ""}
                </button>
                <button onClick={() => { setMenuOpen(false); clearAll(); }} className="menu-item" style={{ width: "100%", textAlign: "left", color: "var(--danger)" }}>
                  <Icon name="trash" size={12} /> Limpar tudo
                </button>
              </div>
            </>
          )}
        </div>
        <button
          className="btn-primary"
          onClick={() => {
            if (txnLimitHit) {
              showUpgrade(`Você atingiu o limite de ${FREE_TXN_LIMIT} lançamentos manuais/mês do plano Free. No Pro é ilimitado.`);
              return;
            }
            setShowAdd(true);
          }}
          title={txnLimitHit ? "Limite Free atingido — faça upgrade no Pro" : ""}
          style={txnLimitHit ? { opacity: 0.6 } : undefined}
        >
          <Icon name="plus" size={14} /> Nova{txnLimitHit ? ` (${txnsThisMonth}/${FREE_TXN_LIMIT})` : ""}
        </button>
      </div>
      {!isPro && (txnsThisMonth >= FREE_TXN_LIMIT * 0.6 || invoicesThisMonth > 0) && (
        <div style={{ padding: "10px 14px", background: txnLimitHit ? "var(--warn-bg)" : "var(--surface-2)", border: `1px solid ${txnLimitHit ? "color-mix(in oklab, var(--warn) 30%, transparent)" : "var(--border)"}`, borderRadius: 12, fontSize: 12.5, color: "var(--fg-2)", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
          <span>
            <strong style={{ color: txnLimitHit ? "var(--warn)" : "var(--fg-1)" }}>Plano Free:</strong> {txnsThisMonth}/{FREE_TXN_LIMIT} lançamentos manuais este mês · {invoicesThisMonth}/{FREE_INVOICE_LIMIT} importação de fatura
          </span>
          <button onClick={() => showUpgrade()} className="btn-ghost" style={{ fontSize: 11.5, padding: "5px 12px" }}>
            <Icon name="spark" size={12} /> Fazer upgrade
          </button>
        </div>
      )}

      {/* Summary */}
      <div style={{ display: "flex", gap: 14, fontSize: 13, color: "var(--fg-2)", padding: "0 4px", flexWrap: "wrap", alignItems: "center" }}>
        <span><strong style={{ color: "var(--fg-1)" }}>{sortedFiltered.length.toLocaleString("pt-BR")}</strong> lançamentos{sortedFiltered.length > displayLimit ? ` (${visibleRows.length} visíveis)` : ""}</span>
        <span>•</span>
        <span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
          <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--success)" }} />
          Entradas: <strong style={{ color: "var(--success)", fontVariantNumeric: "tabular-nums" }}>{fmtBRL(totalIn)}</strong>
        </span>
        <span style={{ display: "inline-flex", alignItems: "center", gap: 5 }}>
          <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--danger)" }} />
          Saídas: <strong style={{ color: "var(--danger)", fontVariantNumeric: "tabular-nums" }}>{fmtBRL(totalOut)}</strong>
        </span>
        <span>•</span>
        <span>Saldo líquido: <strong style={{ color: net >= 0 ? "var(--success)" : "var(--danger)", fontVariantNumeric: "tabular-nums" }}>{fmtBRL(net)}</strong></span>
      </div>

      {/* Lista mobile — cards verticais (escondida em desktop via CSS) */}
      <div className="tx-mobile-list" style={{ display: "none", flexDirection: "column", gap: 8 }}>
        {sortedFiltered.length === 0 && (
          <div className="card" style={{ padding: 24, textAlign: "center", color: "var(--fg-3)" }}>Nenhum lançamento encontrado</div>
        )}
        {visibleRows.map(t => (
          <MobileTxCard
            key={t.type + "-" + t.id}
            t={t}
            isPessoal={isPessoal}
            onEdit={() => setEditingTxn(t)}
            onDelete={() => deleteTxn(t)}
            onQuickStatus={(s) => quickStatus(t, s)}
          />
        ))}
        {hasMore && (
          <div ref={sentinelMobileRef} style={{ padding: "16px 12px", textAlign: "center", fontSize: 12, color: "var(--fg-3)" }}>
            Carregando mais lançamentos…
          </div>
        )}
      </div>

      {/* Tabela desktop — escondida em mobile via CSS */}
      <div className="card tx-desktop-table" style={{ padding: 0, overflow: "hidden" }}>
        <div style={{ overflowX: "auto" }}>
          <table className="tx-table">
            <thead>
              <tr>
                <th style={{ width: 60 }}>TIPO</th>
                <th style={{ width: 90 }}>DATA</th>
                <th>{isPessoal ? "DESPESA" : "DESCRIÇÃO"}</th>
                <th style={{ width: 130, textAlign: "right" }}>VALOR</th>
                <th style={{ width: 110 }}>SITUAÇÃO</th>
                <th style={{ width: 130 }}>{isPessoal ? "PAGAMENTO" : "FORMA DE PAGAMENTO"}</th>
                <th style={{ width: 150 }}>CLASSIFICAÇÃO</th>
                {!isPessoal && <th style={{ width: 150 }}>ENQUADRAMENTO</th>}
                {!isPessoal && <th style={{ width: 150 }}>ESPECIFICAÇÃO</th>}
                {isPessoal && <th style={{ width: 140 }}>DESCRIÇÃO</th>}
                <th style={{ width: 70 }}></th>
              </tr>
            </thead>
            <tbody>
              {visibleRows.map(t => (
                <Row key={t.type + "-" + t.id} t={t} ws={ws} isPessoal={isPessoal} accent={accent}
                  onEdit={() => setEditingTxn(t)}
                  onDelete={() => deleteTxn(t)}
                  onQuickStatus={(s) => quickStatus(t, s)}
                />
              ))}
              {sortedFiltered.length === 0 && (
                <tr><td colSpan={isPessoal ? "9" : "10"} style={{ padding: 40, textAlign: "center", color: "var(--fg-3)" }}>Nenhum lançamento encontrado</td></tr>
              )}
              {hasMore && (
                <tr>
                  <td ref={sentinelDesktopRef} colSpan={isPessoal ? "9" : "10"} style={{ padding: "16px", textAlign: "center", fontSize: 12, color: "var(--fg-3)" }}>
                    Carregando mais lançamentos… ({visibleRows.length.toLocaleString("pt-BR")} de {sortedFiltered.length.toLocaleString("pt-BR")})
                  </td>
                </tr>
              )}
            </tbody>
          </table>
        </div>
      </div>

      {showAdd && <AddModal ws={ws} accent={accent} onClose={() => setShowAdd(false)} onSave={saveTxn} />}
      {editingTxn && <AddModal ws={ws} accent={accent} onClose={() => setEditingTxn(null)} onSave={saveTxn} existing={editingTxn} />}
      {showInvoice && <InvoiceImportModal ws={ws} setWs={setWs} accent={accent} onClose={() => setShowInvoice(false)} onImport={importInvoice} />}
      {showIncomeImport && <IncomeImportModal ws={ws} setWs={setWs} activeWs={activeWs} accent={accent} onClose={() => setShowIncomeImport(false)} />}
    </div>
  );
}

function TypeBadge({ type, onClick }) {
  const isIn = type === "IN";
  return (
    <button onClick={onClick} title={isIn ? "Entrada (clique para alternar)" : "Saída (clique para alternar)"}
      style={{
        display: "inline-flex", alignItems: "center", gap: 4,
        padding: "3px 7px", borderRadius: 5,
        background: isIn ? "color-mix(in oklab, var(--success) 14%, transparent)" : "color-mix(in oklab, var(--danger) 12%, transparent)",
        color: isIn ? "var(--success)" : "var(--danger)",
        fontSize: 10.5, fontWeight: 600, letterSpacing: 0.4, textTransform: "uppercase",
        border: "none", cursor: onClick ? "pointer" : "default", fontFamily: "inherit",
      }}>
      <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
        {isIn ? <><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></> : <><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></>}
      </svg>
      {isIn ? "Entrada" : "Saída"}
    </button>
  );
}

function QuickAddRow({ ws, isPessoal, accent, onAdd }) {
  const today = new Date().toISOString().slice(0, 10);
  const buildEmpty = (type = "OUT") => ({
    type, date: today, desc: "", value: "",
    status: type === "IN" ? "RECEBIDO" : "PAGO",
    payment: ws.payments[0],
    category: type === "IN" ? "" : ws.categories[0],
    enquadramento: "", subcategory: ""
  });
  const [draft, setDraft] = React.useState(() => buildEmpty("OUT"));
  const descRef = React.useRef(null);

  const setType = (newType) => {
    setDraft(d => ({
      ...d,
      type: newType,
      status: newType === "IN" ? "RECEBIDO" : "PAGO",
      category: newType === "IN" ? "" : (ws.categories[0]),
    }));
  };

  const submit = () => {
    if (!draft.desc.trim()) return;
    onAdd({ ...draft, value: parseFloat(draft.value) || 0 });
    setDraft({ ...buildEmpty(draft.type), date: draft.date, payment: draft.payment, category: draft.type === "IN" ? "" : draft.category });
    setTimeout(() => descRef.current?.focus(), 50);
  };
  const onKey = (e) => { if (e.key === "Enter") submit(); };

  const statusOptions = draft.type === "IN" ? ["RECEBIDO", "A RECEBER"] : ["PAGO", "PENDENTE", "AGENDADO"];

  return (
    <tr className="tx-row quick-add">
      <td>
        <TypeBadge type={draft.type} onClick={() => setType(draft.type === "IN" ? "OUT" : "IN")} />
      </td>
      <td><input type="date" value={draft.date} onChange={e => setDraft({ ...draft, date: e.target.value })} onKeyDown={onKey} className="input small" /></td>
      <td>
        <input
          ref={descRef}
          value={draft.desc}
          onChange={e => setDraft({ ...draft, desc: e.target.value })}
          onKeyDown={onKey}
          placeholder={draft.type === "IN" ? "+ Novo recebimento — digite e tecle Enter" : "+ Novo lançamento — digite e tecle Enter"}
          className="input small"
          style={{ width: "100%", border: "1px dashed var(--border-strong)", background: "transparent" }}
        />
      </td>
      <td>
        <input type="text" inputMode="decimal" value={draft.value} onChange={e => setDraft({ ...draft, value: e.target.value })} onKeyDown={onKey}
          placeholder="0,00" className="input small" style={{ textAlign: "right", width: "100%" }} />
      </td>
      <td>
        <select value={draft.status} onChange={e => setDraft({ ...draft, status: e.target.value })} onKeyDown={onKey} className="input small" style={{ width: "100%" }}>
          {statusOptions.map(s => <option key={s}>{s}</option>)}
        </select>
      </td>
      <td>
        <select value={draft.payment} onChange={e => setDraft({ ...draft, payment: e.target.value })} onKeyDown={onKey} className="input small" style={{ width: "100%" }}>
          {ws.payments.map(p => <option key={p}>{p}</option>)}
        </select>
      </td>
      <td>
        <select value={draft.category} onChange={e => setDraft({ ...draft, category: e.target.value })} onKeyDown={onKey} className="input small" style={{ width: "100%" }}>
          <option value="">—</option>
          {ws.categories.map(c => <option key={c}>{c}</option>)}
        </select>
      </td>
      {!isPessoal && <td><input value={draft.enquadramento} onChange={e => setDraft({ ...draft, enquadramento: e.target.value })} onKeyDown={onKey} placeholder="—" className="input small" style={{ width: "100%" }} /></td>}
      <td><input value={draft.subcategory} onChange={e => setDraft({ ...draft, subcategory: e.target.value })} onKeyDown={onKey} placeholder={isPessoal ? "Descrição" : "Especificação"} className="input small" style={{ width: "100%" }} /></td>
      <td>
        <div style={{ display: "flex", gap: 4, justifyContent: "flex-end", alignItems: "center" }}>
          <button
            className="btn-primary"
            onClick={submit}
            disabled={!draft.desc.trim()}
            style={{ padding: "5px 10px", fontSize: 12 }}
            title="Adicionar (Enter)"
          >
            <Icon name="plus" size={12} /> Add
          </button>
        </div>
      </td>
    </tr>
  );
}

function Pill({ label, value, options, onChange, optionLabels }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  React.useEffect(() => {
    const close = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", close);
    return () => document.removeEventListener("mousedown", close);
  }, []);
  const labelOf = (o) => (optionLabels && optionLabels[o]) || o;
  return (
    <div ref={ref} style={{ position: "relative" }}>
      <button className="pill" onClick={() => setOpen(o => !o)}>
        <span style={{ color: "var(--fg-3)", fontSize: 11, marginRight: 6 }}>{label}:</span>
        <span style={{ color: "var(--fg-1)", fontWeight: 500 }}>{labelOf(value)}</span>
        <span style={{ marginLeft: 6, fontSize: 9, color: "var(--fg-3)" }}>▼</span>
      </button>
      {open && (
        <div style={{
          position: "absolute", top: "calc(100% + 4px)", left: 0, zIndex: 10,
          background: "var(--surface-1)", border: "1px solid var(--border)",
          borderRadius: 8, padding: 4, minWidth: 140, boxShadow: "0 8px 24px rgba(0,0,0,0.12)"
        }}>
          {options.map(o => (
            <button key={o} className="menu-item" onClick={() => { onChange(o); setOpen(false); }}
              style={{ background: o === value ? "var(--surface-2)" : "transparent" }}>
              {labelOf(o)}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function StatusBadge({ status, onClick }) {
  const styles = {
    PAGO:        { bg: "#dcfce7", fg: "#166534" },
    RECEBIDO:    { bg: "#dcfce7", fg: "#166534" },
    PENDENTE:    { bg: "#fef3c7", fg: "#92400e" },
    AGENDADO:    { bg: "#dbeafe", fg: "#1e40af" },
    "A RECEBER": { bg: "#ede9fe", fg: "#5b21b6" },
  }[status] || { bg: "#e5e7eb", fg: "#374151" };
  return (
    <button onClick={onClick} className="status-badge" data-status={status} style={{
      background: `var(--badge-bg-${status}, ${styles.bg})`,
      color: `var(--badge-fg-${status}, ${styles.fg})`,
    }}>
      <span style={{ width: 6, height: 6, borderRadius: "50%", background: "currentColor" }} />
      {status}
    </button>
  );
}

function Row({ t, ws, isPessoal, accent, onEdit, onDelete, onQuickStatus }) {
  const isIn = t.type === "IN";
  const cycleStatus = () => {
    const order = isIn ? ["RECEBIDO", "A RECEBER"] : ["PAGO", "PENDENTE", "AGENDADO"];
    const idx = order.indexOf(t.status);
    onQuickStatus(order[(idx + 1) % order.length]);
  };

  const d = new Date(t.date);
  const dateStr = d.toLocaleDateString("pt-BR", { day: "2-digit", month: "short" });
  const valueColor = isIn ? "var(--success)" : "var(--fg-1)";
  const valuePrefix = isIn ? "+ " : "";

  const openReceipt = async () => {
    if (!t.receiptUrl) return;
    const url = await window.db.getReceiptUrl(t.receiptUrl);
    if (url) window.open(url, "_blank", "noopener");
  };

  return (
    <tr className="tx-row" onDoubleClick={onEdit}>
      <td><TypeBadge type={t.type} /></td>
      <td style={{ color: "var(--fg-2)", fontVariantNumeric: "tabular-nums" }}>{dateStr}</td>
      <td>
        <div style={{ display: "inline-flex", alignItems: "center", gap: 6, fontWeight: 500, color: "var(--fg-1)" }}>
          {t.desc}
          {t.receiptUrl && (
            <button onClick={openReceipt} title="Ver comprovante" style={{
              border: "none", background: "transparent", cursor: "pointer", padding: 0,
              color: "var(--fg-3)", display: "inline-flex",
            }}>
              <Icon name="paperclip" size={12} />
            </button>
          )}
        </div>
      </td>
      <td style={{ textAlign: "right", fontVariantNumeric: "tabular-nums", fontWeight: 500, color: valueColor }}>{valuePrefix}{fmtBRL(t.value)}</td>
      <td><StatusBadge status={t.status} onClick={cycleStatus} /></td>
      <td style={{ color: "var(--fg-2)", fontSize: 13 }}>{t.payment || "—"}</td>
      <td>{t.category ? <span className="cat-chip">{t.category}</span> : <span style={{ color: "var(--fg-3)" }}>—</span>}</td>
      {!isPessoal && <td style={{ color: "var(--fg-2)", fontSize: 12.5 }}>{t.enquadramento || "—"}</td>}
      <td style={{ color: "var(--fg-2)", fontSize: 12.5 }}>{t.subcategory || "—"}</td>
      <td>
        <div style={{ display: "flex", gap: 4, justifyContent: "flex-end", alignItems: "center" }}>
          <button className="icon-btn" onClick={onEdit}><Icon name="edit" size={13} /></button>
          <button className="icon-btn danger" onClick={onDelete}><Icon name="trash" size={13} /></button>
        </div>
      </td>
    </tr>
  );
}

// Modal único pra adicionar OU editar (passa `existing` pra entrar em modo edição).
function AddModal({ ws, accent, onClose, onSave, existing }) {
  const isEdit = !!existing;
  const [draft, setDraft] = React.useState(isEdit ? {
    type: existing.type || "OUT",
    date: existing.date || "",
    desc: existing.desc || "",
    value: existing.value != null ? String(existing.value).replace(".", ",") : "",
    status: existing.status || "",
    payment: existing.payment || "",
    category: existing.category || "",
    subcategory: existing.subcategory || "",
    receiptUrl: existing.receiptUrl || "",
  } : {
    type: "OUT",
    date: "", desc: "", value: "", status: "",
    payment: "", category: "", subcategory: "",
    receiptUrl: "",
  });
  const [receiptFile, setReceiptFile] = React.useState(null);
  const [uploading, setUploading] = React.useState(false);
  const [saving, setSaving] = React.useState(false);
  const [err, setErr] = React.useState(null);

  const setType = (newType) => {
    setDraft(d => {
      if (newType === d.type) return d;
      // Quando muda o tipo, reseta status e classificação (listas são distintas)
      return { ...d, type: newType, status: "", category: "" };
    });
  };
  const statusOptions = draft.type === "IN" ? ["RECEBIDO", "A RECEBER"] : ["PAGO", "PENDENTE", "AGENDADO"];

  const canSubmit = !!(draft.desc.trim() && draft.date && draft.value && draft.status && draft.payment);

  // Em edit mode, comprovante é gerenciado direto (sobe na hora). Em add mode, fica no `receiptFile` até salvar.
  const txId = existing?.id;
  const handleAttach = async (file) => {
    if (!file) return;
    if (!isEdit) { setReceiptFile(file); return; }
    setUploading(true); setErr(null);
    try {
      if (draft.receiptUrl) await window.db.deleteReceipt(draft.receiptUrl);
      const path = await window.db.uploadReceipt(file, txId);
      setDraft(d => ({ ...d, receiptUrl: path }));
    } catch (ex) {
      setErr("Erro ao anexar: " + (ex?.message || ex));
    }
    setUploading(false);
  };
  const handleViewReceipt = async () => {
    if (!draft.receiptUrl) return;
    const url = await window.db.getReceiptUrl(draft.receiptUrl);
    if (url) window.open(url, "_blank", "noopener");
  };
  const handleRemoveReceipt = async () => {
    if (!draft.receiptUrl && !receiptFile) return;
    if (!confirm("Remover comprovante anexado?")) return;
    if (draft.receiptUrl) {
      try { await window.db.deleteReceipt(draft.receiptUrl); } catch (e) {}
    }
    setDraft(d => ({ ...d, receiptUrl: "" }));
    setReceiptFile(null);
  };

  const handleSave = async () => {
    setSaving(true); setErr(null);
    try {
      const id = onSave(draft, existing);
      if (!isEdit && receiptFile && id) {
        const path = await window.db.uploadReceipt(receiptFile, id);
        const isIn = draft.type === "IN";
        const updater = isIn ? window.db.updateIncome : window.db.updateTransaction;
        await updater(id, { ...draft, receiptUrl: path });
      }
    } catch (ex) {
      setErr("Erro ao salvar comprovante: " + (ex?.message || ex));
      setSaving(false);
    }
  };

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal" onClick={e => e.stopPropagation()}>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18 }}>
          <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{isEdit ? "Editar lançamento" : "Novo lançamento"}</h3>
          <button className="icon-btn" onClick={onClose}><Icon name="x" size={16} /></button>
        </div>
        <div style={{ display: "grid", gap: 12 }}>
          <Field label="Tipo">
            <div style={{ display: "flex", gap: 4, padding: 3, background: "var(--surface-2)", borderRadius: 8, width: "fit-content" }}>
              {[
                { k: "OUT", l: "Saída", c: "var(--danger)" },
                { k: "IN", l: "Entrada", c: "var(--success)" },
              ].map(o => {
                const active = draft.type === o.k;
                return (
                  <button key={o.k} type="button" onClick={() => setType(o.k)}
                    style={{
                      padding: "6px 14px", border: "none", cursor: "pointer", borderRadius: 6,
                      fontFamily: "inherit", fontSize: 12.5, fontWeight: active ? 600 : 400,
                      background: active ? "var(--surface-1)" : "transparent",
                      color: active ? o.c : "var(--fg-2)",
                      boxShadow: active ? "var(--shadow-sm)" : "none",
                    }}>{o.l}</button>
                );
              })}
            </div>
          </Field>
          <Field label="Descrição">
            <input className="input" value={draft.desc} onChange={e => setDraft({ ...draft, desc: e.target.value })} autoFocus
              placeholder={draft.type === "IN" ? "Ex: Faturamento — Cliente XYZ" : "Ex: Aluguel"} />
          </Field>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
            <Field label="Data">
              <input type="date" className="input" value={draft.date} onChange={e => setDraft({ ...draft, date: e.target.value })} />
            </Field>
            <Field label="Valor (R$)">
              <input type="text" inputMode="decimal" className="input" value={draft.value} placeholder="0,00" onChange={e => setDraft({ ...draft, value: e.target.value })} />
            </Field>
          </div>
          <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
            <Field label="Status">
              <select className="input" value={draft.status} onChange={e => setDraft({ ...draft, status: e.target.value })}>
                <option value="">— selecione —</option>
                {statusOptions.map(s => <option key={s}>{s}</option>)}
              </select>
            </Field>
            <Field label={draft.type === "IN" ? "Forma de recebimento" : "Forma de pagamento"}>
              <select className="input" value={draft.payment} onChange={e => setDraft({ ...draft, payment: e.target.value })}>
                <option value="">— selecione —</option>
                {ws.payments.map(p => <option key={p}>{p}</option>)}
              </select>
            </Field>
          </div>
          <Field label="Classificação (opcional)">
            <select className="input" value={draft.category} onChange={e => setDraft({ ...draft, category: e.target.value })}>
              <option value="">— selecione —</option>
              {(draft.type === "IN" ? (ws.incomeCategories || []) : (ws.categories || [])).map(c => <option key={c}>{c}</option>)}
            </select>
          </Field>
          <Field label={draft.type === "IN" ? "Observação (opcional)" : "Especificação (opcional)"}>
            <input className="input" value={draft.subcategory} onChange={e => setDraft({ ...draft, subcategory: e.target.value })} />
          </Field>

          {/* Comprovante: anexar / ver / remover */}
          <Field label="Comprovante (opcional)">
            {draft.receiptUrl ? (
              <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", background: "color-mix(in oklab, var(--success) 6%, var(--surface-2))", borderRadius: 8, fontSize: 12.5, border: "1px solid color-mix(in oklab, var(--success) 20%, transparent)" }}>
                <Icon name="paperclip" size={13} style={{ color: "var(--success)" }} />
                <span style={{ flex: 1, color: "var(--fg-1)" }}>Comprovante anexado</span>
                <button type="button" className="btn-ghost" onClick={handleViewReceipt} style={{ padding: "4px 8px", fontSize: 11.5 }}>Ver</button>
                <label className="btn-ghost" style={{ padding: "4px 8px", fontSize: 11.5, cursor: "pointer" }} title="Trocar por outro arquivo">
                  Trocar
                  <input type="file" accept="application/pdf,image/png,image/jpeg,image/jpg,image/webp" style={{ display: "none" }}
                    onChange={(e) => { const f = e.target.files?.[0]; if (f) handleAttach(f); e.target.value = ""; }}
                    disabled={uploading} />
                </label>
                <button type="button" className="icon-btn" onClick={handleRemoveReceipt} title="Remover">
                  <Icon name="x" size={12} />
                </button>
              </div>
            ) : receiptFile ? (
              <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", background: "var(--surface-2)", borderRadius: 8, fontSize: 12.5 }}>
                <Icon name="check" size={13} style={{ color: "var(--success)" }} />
                <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: "var(--fg-1)" }}>
                  {receiptFile.name}
                </span>
                <span style={{ color: "var(--fg-3)", fontSize: 11 }}>
                  {(receiptFile.size / 1024).toFixed(0)} KB
                </span>
                <button type="button" className="icon-btn" onClick={() => setReceiptFile(null)} title="Remover">
                  <Icon name="x" size={12} />
                </button>
              </div>
            ) : (
              <label style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 8, padding: "10px 12px", border: "1.5px dashed var(--border-strong)", borderRadius: 8, cursor: uploading ? "wait" : "pointer", fontSize: 12.5, color: "var(--fg-2)" }}>
                <Icon name="paperclip" size={13} />
                <span>{uploading ? "Subindo…" : "Anexar PDF, JPG ou PNG"}</span>
                <input
                  type="file"
                  accept="application/pdf,image/png,image/jpeg,image/jpg,image/webp"
                  style={{ display: "none" }}
                  onChange={(e) => { const f = e.target.files?.[0]; if (f) handleAttach(f); e.target.value = ""; }}
                  disabled={uploading}
                />
              </label>
            )}
          </Field>

          {err && (
            <div style={{ padding: "8px 12px", background: "color-mix(in oklab, var(--danger) 8%, transparent)", border: "1px solid color-mix(in oklab, var(--danger) 25%, transparent)", borderRadius: 8, fontSize: 12.5, color: "var(--danger)" }}>
              {err}
            </div>
          )}
        </div>
        <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 20 }}>
          <button className="btn-ghost" onClick={onClose} disabled={saving}>Cancelar</button>
          <button className="btn-primary" style={{ opacity: (!canSubmit || saving) ? 0.6 : 1 }} onClick={handleSave} disabled={!canSubmit || saving}>
            {saving ? "Salvando…" : (isEdit ? "Salvar alterações" : "Adicionar")}
          </button>
        </div>
      </div>
    </div>
  );
}

// ---------- InvoiceImportModal — IA lê fatura de cartão e cria lançamentos ----------
function InvoiceImportModal({ ws, setWs, accent, onClose, onImport }) {
  const [step, setStep] = React.useState("init"); // "init" | "key" | "upload" | "loading" | "password" | "preview" | "done"
  const [filename, setFilename] = React.useState("");
  const [progress, setProgress] = React.useState("Lendo fatura…");
  const [preview, setPreview] = React.useState(null);
  const [err, setErr] = React.useState(null);
  const [paymentName, setPaymentName] = React.useState("");
  // Senha de PDF protegido — Promise resolve quando usuário submete
  const [pdfPassword, setPdfPassword] = React.useState("");
  const [pdfPasswordError, setPdfPasswordError] = React.useState(false);
  const passwordResolveRef = React.useRef(null);

  React.useEffect(() => {
    const fn = e => { if (e.key === "Escape" && step !== "loading") onClose(); };
    window.addEventListener("keydown", fn);
    return () => window.removeEventListener("keydown", fn);
  }, [onClose, step]);

  React.useEffect(() => {
    window.db.getAiConfig().then(({ key }) => {
      setStep(key ? "upload" : "key");
    });
    // Default: primeiro cartão cadastrado (vazio se não houver — usuário precisa adicionar)
    const firstCard = (ws.payments || []).find(p => /cart/i.test(p));
    setPaymentName(firstCard || "");
  }, [ws]);

  // Lista só de cartões (filtra Pix/Boleto/Dinheiro/Transferência)
  const cards = (ws.payments || []).filter(p => /cart/i.test(p));

  // Callback pedida pelo extractFileText quando o PDF tem senha
  const askPassword = (isRetry) => {
    console.log("[Modal] askPassword chamado, isRetry:", isRetry);
    setPdfPasswordError(isRetry);
    setPdfPassword("");
    setStep("password");
    return new Promise((resolve, reject) => {
      passwordResolveRef.current = { resolve, reject };
      console.log("[Modal] Promise da senha criada, aguardando submit do usuário");
    });
  };

  const submitPassword = () => {
    console.log("[Modal] submitPassword — pdfPassword length:", pdfPassword.length, "ref:", !!passwordResolveRef.current);
    if (passwordResolveRef.current) {
      passwordResolveRef.current.resolve(pdfPassword);
      passwordResolveRef.current = null;
      setStep("loading");
      setProgress("Verificando senha…");
    }
  };

  const cancelPassword = () => {
    if (passwordResolveRef.current) {
      passwordResolveRef.current.reject(new Error("Cancelado"));
      passwordResolveRef.current = null;
    }
    setStep("upload");
  };

  const handleFile = async (file) => {
    if (!file) return;
    setFilename(file.name);
    setErr(null);
    setStep("loading");
    setProgress("Extraindo texto…");
    try {
      const text = await extractFileText(file, msg => setProgress(msg), askPassword);
      if (!text || text.trim().length < 50) throw new Error("Não consegui extrair texto. Arquivo pode estar protegido ou ser só imagem.");

      console.log(`[Fatura] Texto extraído (${text.length} chars):`, text.slice(0, 2000) + (text.length > 2000 ? "\n…" : ""));
      setProgress("Analisando com IA…");
      const result = await aiParseInvoice(text, ws);
      const newPreview = buildPreviewFromAi(result, paymentName, ws);
      logReconcile(newPreview);
      // Dedup contra o que já existe na workspace
      // - kept: novas que vão ser criadas
      // - skipped: já existem (duplicatas)
      // - updates: pendentes existentes que serão marcados como pagos
      const { kept, skipped, updates } = dedupAgainstWorkspace(newPreview.txns, ws);
      newPreview.txns = kept;
      newPreview.skippedCount = skipped.length;
      newPreview.pendingUpdates = updates;
      console.log(`[Fatura] Dedup: ${kept.length} novas, ${skipped.length} duplicadas, ${updates.length} pendentes que serão marcados como pagos`);
      setPreview(newPreview);
      setStep("preview");
    } catch (ex) {
      console.error(ex);
      // Tradução de erros conhecidos
      const msg = ex.message || String(ex);
      if (ex?.name === "PasswordException" || /password/i.test(msg)) {
        setErr("PDF protegido por senha — não foi possível abrir.");
      } else if (msg === "Cancelado") {
        setErr(null); // usuário cancelou voluntariamente
      } else {
        setErr(msg);
      }
      setStep("upload");
    }
  };

  const doImport = () => {
    onImport(preview);
    setStep("done");
  };

  // Remove uma transação da lista (somente do preview, antes de importar)
  const removeTxn = (idx) => {
    setPreview(p => ({ ...p, txns: p.txns.filter((_, i) => i !== idx) }));
  };

  return (
    <div className="modal-backdrop" onClick={() => (step !== "loading" && step !== "password") && onClose()}>
      <div className="modal" onClick={e => e.stopPropagation()} style={{ width: 540, maxWidth: "calc(100vw - 24px)", maxHeight: "calc(100vh - 40px)", overflowY: "auto", padding: 24 }}>
        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 18 }}>
          <div>
            <div style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: 0.8, textTransform: "uppercase", color: "var(--fg-3)", marginBottom: 3 }}>
              <Icon name="spark" size={11} /> Importar com IA
            </div>
            <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
              {step === "key" && "Configure a IA primeiro"}
              {step === "upload" && "Importar fatura de cartão"}
              {step === "loading" && "Processando…"}
              {step === "password" && "PDF protegido"}
              {step === "preview" && "Confira as transações"}
              {step === "done" && "Concluído!"}
            </h3>
          </div>
          {step !== "loading" && <button className="icon-btn" onClick={onClose}><Icon name="x" size={16} /></button>}
        </div>

        {step === "key" && (
          <div>
            <p style={{ fontSize: 13, color: "var(--fg-2)", marginBottom: 14, lineHeight: 1.5 }}>
              Pra ler fatura você precisa configurar uma chave de IA primeiro. Vai em <strong>Configurações → Inteligência Artificial</strong>.
            </p>
            <button className="btn-primary" onClick={onClose}>Entendi</button>
          </div>
        )}

        {step === "upload" && (
          <div>
            <p style={{ fontSize: 13, color: "var(--fg-2)", marginBottom: 14, lineHeight: 1.5 }}>
              Sobe a <strong>fatura do cartão</strong> em PDF, Word, TXT ou CSV. A IA lê todas as compras e classifica automaticamente. No preview, você confere e pode apagar o que não quiser.
            </p>

            <div style={{ marginBottom: 14 }}>
              <div style={{ fontSize: 11.5, color: "var(--fg-3)", textTransform: "uppercase", letterSpacing: 0.5, fontWeight: 500, marginBottom: 6, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
                <span>De qual cartão é essa fatura?</span>
                <button
                  onClick={() => {
                    const name = prompt("Nome do cartão (ex: Inter, Itaú, Nubank):");
                    if (!name || !name.trim()) return;
                    const finalName = /cart[ãa]o/i.test(name) ? name.trim() : `Cartão ${name.trim()}`;
                    setWs(prev => ({ ...prev, payments: [...(prev.payments || []), finalName] }));
                    setPaymentName(finalName);
                  }}
                  style={{ background: "transparent", border: "none", color: accent, cursor: "pointer", fontSize: 11, fontFamily: "inherit", fontWeight: 600, padding: 0, textTransform: "none", letterSpacing: 0 }}
                >
                  + Adicionar cartão
                </button>
              </div>
              {cards.length === 0 ? (
                <div style={{ padding: "14px 16px", background: "color-mix(in oklab, var(--warn) 8%, transparent)", border: "1px dashed color-mix(in oklab, var(--warn) 35%, transparent)", borderRadius: 10, fontSize: 12.5, color: "var(--fg-2)", lineHeight: 1.5 }}>
                  Você ainda não cadastrou nenhum cartão. Clique em <strong style={{ color: accent }}>+ Adicionar cartão</strong> acima pra cadastrar (ex: Cartão Inter, Cartão Itaú).
                </div>
              ) : (
                <>
                  <select className="input" value={paymentName} onChange={e => setPaymentName(e.target.value)} style={{ width: "100%" }}>
                    {cards.map(p => <option key={p}>{p}</option>)}
                  </select>
                  <div style={{ fontSize: 11, color: "var(--fg-3)", marginTop: 6, lineHeight: 1.4 }}>
                    Todas as compras dessa fatura vão receber esse cartão como forma de pagamento.
                  </div>
                </>
              )}
            </div>

            <label style={{
              display: "flex", flexDirection: "column", alignItems: "center", gap: 12,
              padding: "36px 24px", border: "2px dashed var(--border)", borderRadius: 14,
              background: "var(--surface-2)",
              cursor: cards.length === 0 ? "not-allowed" : "pointer",
              opacity: cards.length === 0 ? 0.5 : 1,
              pointerEvents: cards.length === 0 ? "none" : "auto",
            }}>
              <div style={{ width: 52, height: 52, borderRadius: 14, background: "color-mix(in oklab, var(--accent) 12%, transparent)", color: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center" }}>
                <Icon name="paperclip" size={24} />
              </div>
              <div style={{ textAlign: "center" }}>
                <div style={{ fontSize: 14, fontWeight: 600, color: "var(--fg-1)", marginBottom: 5 }}>
                  {cards.length === 0 ? "Cadastre um cartão antes" : "Clique para selecionar o arquivo"}
                </div>
                <div style={{ fontSize: 12, color: "var(--fg-3)" }}>PDF · DOCX · TXT · CSV — PDFs digitais (não imagem escaneada)</div>
              </div>
              <input type="file"
                accept="application/pdf,.pdf,.docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.txt,text/plain,.csv,text/csv"
                style={{ display: "none" }}
                disabled={cards.length === 0}
                onChange={e => { if (e.target.files[0]) handleFile(e.target.files[0]); }} />
            </label>

            {err && (
              <div style={{ padding: "10px 12px", background: "color-mix(in oklab, var(--danger) 8%, transparent)", border: "1px solid color-mix(in oklab, var(--danger) 25%, transparent)", borderRadius: 8, fontSize: 12.5, color: "var(--danger)", marginTop: 14 }}>
                {err}
              </div>
            )}
          </div>
        )}

        {step === "loading" && (
          <div style={{ padding: "40px 0", textAlign: "center" }}>
            <div style={{ width: 56, height: 56, borderRadius: 16, background: "color-mix(in oklab, var(--accent) 12%, transparent)", color: "var(--accent)", display: "flex", alignItems: "center", justifyContent: "center", margin: "0 auto 18px", animation: "spin 1.4s linear infinite" }}>
              <Icon name="spark" size={26} />
            </div>
            <div style={{ fontSize: 14, fontWeight: 600, color: "var(--fg-1)", marginBottom: 6 }}>{progress}</div>
            <div style={{ fontSize: 12, color: "var(--fg-3)" }}>{filename}</div>
            <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
          </div>
        )}

        {step === "password" && (
          <div>
            <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 14px", background: "color-mix(in oklab, var(--warn) 8%, transparent)", border: "1px solid color-mix(in oklab, var(--warn) 25%, transparent)", borderRadius: 10, marginBottom: 16 }}>
              <div style={{ width: 32, height: 32, borderRadius: 8, background: "color-mix(in oklab, var(--warn) 15%, transparent)", color: "var(--warn)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
                <Icon name="alert" size={16} />
              </div>
              <div>
                <div style={{ fontSize: 13, fontWeight: 600, color: "var(--fg-1)" }}>Este PDF está protegido</div>
                <div style={{ fontSize: 11.5, color: "var(--fg-3)", marginTop: 2 }}>{filename}</div>
              </div>
            </div>

            <p style={{ fontSize: 13, color: "var(--fg-2)", marginBottom: 12, lineHeight: 1.5 }}>
              Digite a senha do PDF (a mesma que você usaria pra abrir no Adobe Reader). A senha é processada só no seu navegador — não vai pra nenhum servidor.
            </p>

            <form onSubmit={(e) => { e.preventDefault(); submitPassword(); }}>
              <input
                type="password"
                className="input"
                value={pdfPassword}
                onChange={e => { setPdfPassword(e.target.value); setPdfPasswordError(false); }}
                placeholder="Senha do PDF"
                autoFocus
                style={{ width: "100%", boxSizing: "border-box", fontFamily: "inherit", fontSize: 14 }}
              />
              {pdfPasswordError && (
                <div style={{ fontSize: 12, color: "var(--danger)", marginTop: 8 }}>
                  Senha incorreta — tente de novo.
                </div>
              )}

              <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 16 }}>
                <button type="button" className="btn-ghost" onClick={cancelPassword}>Cancelar</button>
                <button type="submit" className="btn-primary" disabled={!pdfPassword.trim()} style={{ opacity: !pdfPassword.trim() ? 0.5 : 1 }}>
                  Abrir PDF
                </button>
              </div>
            </form>
          </div>
        )}

        {step === "preview" && preview && (() => {
          const rec = reconcileInvoice(preview);
          const outs = preview.txns.filter(t => t.type !== "IN");
          const ins = preview.txns.filter(t => t.type === "IN");
          const totalOut = outs.reduce((s, t) => s + t.value, 0);
          const totalIn = ins.reduce((s, t) => s + t.value, 0);
          const recColor = rec.ok ? "var(--success)" : "var(--danger)";
          const recBg = rec.hasDeclared
            ? `color-mix(in oklab, ${recColor} 8%, var(--surface-1))`
            : "var(--surface-2)";
          const recBorder = rec.hasDeclared
            ? `1px solid color-mix(in oklab, ${recColor} 28%, transparent)`
            : "1px solid var(--border)";
          return (
          <div>
            {(preview.cardName || preview.dueDate) && (
              <div style={{ padding: "10px 14px", background: "var(--surface-2)", borderRadius: 8, fontSize: 12.5, color: "var(--fg-2)", marginBottom: 12, display: "flex", flexDirection: "column", gap: 4 }}>
                {preview.cardName && <div><Icon name="wallet" size={11} /> Cartão/banco: <strong style={{ color: "var(--fg-1)" }}>{preview.cardName}</strong></div>}
                {preview.dueDate && <div><Icon name="calendar" size={11} /> Vencimento: <strong style={{ color: "var(--fg-1)" }}>{preview.dueDate}</strong></div>}
              </div>
            )}

            {/* Cards de saídas e entradas (extrato bancário pode ter os dois) */}
            <div style={{ display: "grid", gridTemplateColumns: ins.length > 0 ? "1fr 1fr" : "1fr", gap: 10, marginBottom: 12 }}>
              <div style={{ padding: "12px 14px", background: "color-mix(in oklab, var(--danger) 8%, var(--surface-1))", borderRadius: 10, textAlign: "center" }}>
                <div style={{ fontSize: 22, fontWeight: 700, color: "var(--danger)" }}>{outs.length}</div>
                <div style={{ fontSize: 11.5, color: "var(--fg-2)" }}>saída{outs.length !== 1 ? "s" : ""}</div>
                <div style={{ fontSize: 12.5, color: "var(--fg-1)", fontVariantNumeric: "tabular-nums", marginTop: 2 }}>
                  <strong>{fmtBRL(totalOut)}</strong>
                </div>
              </div>
              {ins.length > 0 && (
                <div style={{ padding: "12px 14px", background: "color-mix(in oklab, var(--success) 8%, var(--surface-1))", borderRadius: 10, textAlign: "center" }}>
                  <div style={{ fontSize: 22, fontWeight: 700, color: "var(--success)" }}>{ins.length}</div>
                  <div style={{ fontSize: 11.5, color: "var(--fg-2)" }}>entrada{ins.length !== 1 ? "s" : ""}</div>
                  <div style={{ fontSize: 12.5, color: "var(--fg-1)", fontVariantNumeric: "tabular-nums", marginTop: 2 }}>
                    <strong>{fmtBRL(totalIn)}</strong>
                  </div>
                </div>
              )}
            </div>

            {/* Total declarado na fatura — informativo, sem comparar com o extraído */}
            {rec.hasDeclared && (
              <div style={{ padding: "12px 14px", background: "var(--surface-2)", border: "1px solid var(--border)", borderRadius: 10, marginBottom: 12 }}>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
                  <span style={{ fontSize: 12, color: "var(--fg-2)" }}>Total das compras (declarado)</span>
                  <span style={{ fontSize: 14, fontWeight: 700, color: "var(--fg-1)", fontVariantNumeric: "tabular-nums" }}>{fmtBRL(rec.declared)}</span>
                </div>
                {preview.declaredTotalLabel && (
                  <div style={{ fontSize: 10.5, color: "var(--fg-3)", marginTop: 2, textAlign: "right", fontStyle: "italic" }}>
                    "{preview.declaredTotalLabel}"
                  </div>
                )}
              </div>
            )}

            {(preview.pendingUpdates?.length > 0) && (
              <div style={{ padding: "10px 14px", background: "color-mix(in oklab, var(--success) 8%, var(--surface-1))", border: "1px solid color-mix(in oklab, var(--success) 25%, transparent)", borderRadius: 8, marginBottom: 12, fontSize: 12, display: "flex", alignItems: "flex-start", gap: 8 }}>
                <Icon name="check" size={13} style={{ color: "var(--success)", marginTop: 2 }} />
                <div>
                  <strong style={{ color: "var(--success)" }}>{preview.pendingUpdates.length} pendente{preview.pendingUpdates.length !== 1 ? "s" : ""}</strong> que você já tinha vai{preview.pendingUpdates.length !== 1 ? "o" : ""} ser marcad{preview.pendingUpdates.length !== 1 ? "os" : "o"} como pag{preview.pendingUpdates.length !== 1 ? "os" : "o"}/recebid{preview.pendingUpdates.length !== 1 ? "os" : "o"} (não duplica).
                </div>
              </div>
            )}

            {preview.skippedCount > 0 && (preview.skippedCount - (preview.pendingUpdates?.length || 0) > 0) && (
              <div style={{ padding: "10px 14px", background: "color-mix(in oklab, var(--info) 8%, var(--surface-1))", border: "1px solid color-mix(in oklab, var(--info) 25%, transparent)", borderRadius: 8, marginBottom: 12, fontSize: 12, display: "flex", alignItems: "flex-start", gap: 8 }}>
                <Icon name="check" size={13} style={{ color: "var(--info)", marginTop: 2 }} />
                <div>
                  <strong style={{ color: "var(--info)" }}>{preview.skippedCount - (preview.pendingUpdates?.length || 0)} transaç{(preview.skippedCount - (preview.pendingUpdates?.length || 0)) !== 1 ? "ões" : "ão"} duplicada{(preview.skippedCount - (preview.pendingUpdates?.length || 0)) !== 1 ? "s" : ""}</strong> ignorada{(preview.skippedCount - (preview.pendingUpdates?.length || 0)) !== 1 ? "s" : ""} (já existem na conta).
                </div>
              </div>
            )}

            {/* Lista de transações com lixeira por linha (pra apagar antes de importar) */}
            <div style={{ marginBottom: 14, border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden" }}>
              <div style={{ padding: "8px 12px", background: "var(--surface-2)", fontSize: 11, fontWeight: 600, color: "var(--fg-3)", textTransform: "uppercase", letterSpacing: 0.5 }}>
                Transações detectadas — clique na lixeira pra excluir antes de importar
              </div>
              <div style={{ maxHeight: 360, overflowY: "auto" }}>
                {preview.txns.map((t, i) => {
                  const isIn = t.type === "IN";
                  return (
                    <div key={t.id || i} style={{ display: "flex", gap: 8, padding: "7px 10px", borderBottom: i < preview.txns.length - 1 ? "1px solid var(--border)" : "none", fontSize: 12, alignItems: "center" }}>
                      <span style={{ fontSize: 9, fontWeight: 700, padding: "2px 5px", borderRadius: 3, color: isIn ? "var(--success)" : "var(--danger)", background: isIn ? "color-mix(in oklab, var(--success) 12%, transparent)" : "color-mix(in oklab, var(--danger) 12%, transparent)", letterSpacing: 0.4 }}>
                        {isIn ? "ENT" : "SAI"}
                      </span>
                      <span style={{ width: 50, color: "var(--fg-3)", fontVariantNumeric: "tabular-nums" }}>{t.date?.slice(5)}</span>
                      <span style={{ flex: 1, color: "var(--fg-1)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.desc}</span>
                      <span style={{ color: "var(--fg-3)", fontSize: 10.5, maxWidth: 90, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{t.subcategory || t.category || "—"}</span>
                      <span style={{ width: 80, textAlign: "right", fontVariantNumeric: "tabular-nums", fontWeight: 500, color: isIn ? "var(--success)" : "var(--fg-1)" }}>{isIn ? "+" : ""}{fmtBRL(t.value)}</span>
                      <button onClick={() => removeTxn(i)} title="Excluir desta importação" style={{ width: 22, height: 22, borderRadius: 5, border: "none", cursor: "pointer", background: "color-mix(in oklab, var(--danger) 8%, transparent)", color: "var(--danger)", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "inherit", padding: 0 }}>
                        <Icon name="trash" size={11} />
                      </button>
                    </div>
                  );
                })}
                {preview.txns.length === 0 && (
                  <div style={{ padding: 24, textAlign: "center", fontSize: 12, color: "var(--fg-3)" }}>
                    Nenhuma transação detectada.
                  </div>
                )}
              </div>
            </div>

            <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
              <button className="btn-ghost" onClick={() => setStep("upload")}>Voltar</button>
              <button
                className="btn-primary"
                onClick={doImport}
                disabled={preview.txns.length === 0}
                style={{ opacity: preview.txns.length === 0 ? 0.4 : 1 }}
              >
                <Icon name="check" size={13} /> Importar {preview.txns.length} transaç{preview.txns.length !== 1 ? "ões" : "ão"}
              </button>
            </div>
          </div>
          );
        })()}

        {step === "done" && preview && (
          <div style={{ textAlign: "center", padding: "16px 0" }}>
            <div style={{ width: 60, height: 60, borderRadius: 16, background: "color-mix(in oklab, var(--success) 12%, transparent)", color: "var(--success)", display: "flex", alignItems: "center", justifyContent: "center", margin: "0 auto 16px" }}>
              <Icon name="check" size={30} />
            </div>
            <div style={{ fontSize: 19, fontWeight: 600, color: "var(--fg-1)", marginBottom: 8 }}>Fatura importada!</div>
            <div style={{ fontSize: 13.5, color: "var(--fg-2)", marginBottom: 24 }}>
              {preview.txns.length} compra{preview.txns.length !== 1 ? "s" : ""} adicionada{preview.txns.length !== 1 ? "s" : ""}.
            </div>
            <button className="btn-primary" onClick={onClose}>Ver lançamentos</button>
          </div>
        )}
      </div>
    </div>
  );
}

// Extrai texto de PDF, DOCX, TXT ou CSV no browser.
// `askPassword(isRetry)` é uma callback opcional pra solicitar senha quando o PDF for protegido.
async function extractFileText(file, onProgress, askPassword) {
  const ext = (file.name.split(".").pop() || "").toLowerCase();

  if (ext === "pdf") {
    if (!window.pdfjsLib) throw new Error("Biblioteca de PDF não carregada — recarregue a página (F5).");
    const buf = await file.arrayBuffer();
    const loadingTask = window.pdfjsLib.getDocument({ data: buf });
    // pdf.js chama onPassword sempre que precisar de senha (NEED_PASSWORD = 1, INCORRECT_PASSWORD = 2)
    loadingTask.onPassword = (updatePassword, reason) => {
      console.log("[PDF] Senha solicitada — reason:", reason, "(1=precisa senha, 2=senha incorreta)");
      if (!askPassword) {
        console.log("[PDF] askPassword indefinido, cancelando");
        updatePassword(null);
        return;
      }
      askPassword(reason === 2 /* isRetry */)
        .then(pwd => {
          console.log("[PDF] Senha digitada (length:", (pwd || "").length, "), enviando pra pdf.js…");
          updatePassword(pwd);
        })
        .catch(e => {
          console.log("[PDF] Cancelado:", e?.message);
          updatePassword(null);
        });
    };
    const pdf = await loadingTask.promise;
    const parts = [];
    for (let i = 1; i <= pdf.numPages; i++) {
      onProgress?.(`Lendo página ${i}/${pdf.numPages}…`);
      const page = await pdf.getPage(i);
      const content = await page.getTextContent();
      parts.push(content.items.map(it => it.str).join(" "));
    }
    return parts.join("\n\n");
  }

  if (ext === "docx") {
    if (!window.mammoth) throw new Error("Biblioteca DOCX não carregada — recarregue a página (F5).");
    onProgress?.("Lendo documento Word…");
    const buf = await file.arrayBuffer();
    const result = await window.mammoth.extractRawText({ arrayBuffer: buf });
    return result.value || "";
  }

  if (ext === "txt" || ext === "csv") {
    onProgress?.(`Lendo arquivo ${ext.toUpperCase()}…`);
    return await file.text();
  }

  throw new Error(`Formato .${ext} não suportado. Use PDF, DOCX, TXT ou CSV.`);
}

// Manda texto da fatura pra IA e parsea as compras (foco em fatura de cartão)
async function aiParseInvoice(rawText, ws) {
  const expenseCats = (ws.categories || []).join(", ") || "(nenhuma)";
  const userMsg = `TIPO DE CONTA: ${ws.type === "personal" ? "Pessoal" : "Empresarial"}

CLASSIFICAÇÕES DE DESPESA DISPONÍVEIS — você DEVE escolher uma destas (NUNCA invente nova):
${expenseCats}

Se nenhuma encaixar perfeitamente, use "OUTROS" (sempre existe na lista). NUNCA crie classificação nova.

TEXTO BRUTO DA FATURA DE CARTÃO:
═══════════════
${rawText.slice(0, 60000)}
═══════════════

INSTRUÇÃO: Extraia TODAS as compras individuais da fatura. Cada linha de estabelecimento vira UMA transação.`;

  const systemMsg = `Você extrai compras de FATURAS DE CARTÃO DE CRÉDITO brasileiras (Nubank, Itaú, BB, Bradesco, Santander, Inter, C6, Caixa, etc.).

═══ EXTRAIA AS COMPRAS ═══

- Cada linha de compra/estabelecimento vira UMA transação com type: "expense"
- Inclua compras parceladas (PARC 1/12, PARC 2/12 etc.) — cada parcela é uma transação separada
- Inclua IOF de compra internacional como expense

IGNORE:
- "Total da fatura" / "Total das compras" / "Valor a pagar" / "Pagamento mínimo"
- Pagamentos da fatura anterior, estornos, créditos
- Juros, multa por atraso (não conta como gasto novo do mês)
- Anuidade do cartão (a menos que seja do período corrente)
- Cabeçalhos, totalizadores

Se "Valor a pagar" for R$ 0,00 (pagamento antecipado), AINDA ASSIM extraia cada compra.

═══ TOTAL DECLARADO (para reconciliação) ═══

Localize "Total das compras do período" / "Subtotal de compras" / "Total dos lançamentos" → coloque em declaredTotal.
declaredTotalLabel = rótulo exato encontrado no documento.

═══ DESCRIÇÃO LIMPA ═══

Limpe descrições mantendo o nome principal do estabelecimento:
- "UBER DO BRASIL TECNOLOGIA LTDA" → "Uber"
- "99 TECNOLOGIA LTDA" → "99"
- "HIPER FEIRAO DA FAMILI GOIANIA BRA" → "Hiper Feirão da Família"
- "MP *ESTACIONAMENT" → "Estacionamento"
- "PAG*MercadoLivre" → "Mercado Livre"
- "AMZN MKTP BR" → "Amazon"

═══ PARSING ═══

- Datas YYYY-MM-DD. Use o cabeçalho do dia (ex: "6 de Abril de 2026" → "2026-04-06")
- Valores como número POSITIVO: "1.500,00" → 1500.00
- Status: SEMPRE PAGO (fatura é compra já realizada)
- Forma de pagamento: NÃO se preocupe com este campo — o sistema vai preencher com o cartão que o usuário selecionou ao importar.

═══ CATEGORIA (deve ser EXATAMENTE uma das classificações disponíveis acima) ═══

REGRA: escolha a classificação MAIS PRÓXIMA da lista do usuário pra cada transação.
Se nenhuma encaixa, use "OUTROS". NUNCA invente nome novo.

Mapeamento por contexto (use SÓ se a categoria correspondente existir na lista — caso não exista, caia em OUTROS):
- Uber, 99 Tecnologia, Cabify, Posto, Combustível, Estacionamento, Pedágio, ônibus, metrô → TRANSPORTE
- iFood, Rappi, 99 Food, restaurantes, lanches, mercados, supermercados, padarias → ALIMENTAÇÃO
- Netflix, Spotify, Disney+, HBO, Apple Music, YouTube Premium → ASSINATURAS (assinaturas mensais)
- Cinema, jogos, viagens de lazer, eventos, shows, parques → LAZER
- Drogasil, farmácia, hospital, clínica, laboratório, médico, plano de saúde → SAÚDE
- Escola, faculdade, cursos online, livros didáticos, mensalidade educacional → EDUCAÇÃO
- TIM/VIVO/CLARO/OI (telefonia), CEMIG/ENEL/CPFL (energia), SABESP (água), aluguel, condomínio, IPTU → MORADIA
- Google Ads, Meta Ads, Facebook Ads, anúncios → MARKETING (em conta empresarial)
- Adobe, Microsoft 365, Google Workspace, AWS, Notion, Slack, Figma, hospedagem, software → TECNOLOGIA (empresa)
- Salário pago, pró-labore, FGTS, INSS → FOLHA (em conta empresarial)
- DARF, ICMS, ISS, IPTU empresarial, taxas, tributos → TRIBUTOS (empresa)
- Aluguel pago, contador, contas fixas mensais → CUSTOS FIXOS (empresa)
- Compras, fornecedores, materiais pontuais → CUSTOS VARIÁVEIS (empresa)
- Quando não couber em nenhuma → "OUTROS"

═══ SUBCATEGORIA (livre — pode criar nomes novos) ═══

Crie um nome curto e normalizado pra agrupar compras similares:
- "UBER DO BRASIL TECNOLOGIA LTDA" → subcategory: "Uber"
- "99 TECNOLOGIA LTDA" → subcategory: "99"
- "99 FOOD" → subcategory: "99 Food"
- "IFOOD" → "iFood"
- "POSTO SHELL" / "POSTO IPIRANGA" → "Combustível"
- "DROGASIL" → "Farmácia"
- "TIM S A" → "Telefonia"
- "NETFLIX" → "Netflix"
- "MP *ESTACIONAMENT" → "Estacionamento"
- "AMZN MKTP BR" → "Amazon"
- "PAG*MercadoLivre" → "Mercado Livre"

REGRA: olhe o nome da empresa, ignore CNPJ e códigos. Reconheça por razão social ("UBER DO BRASIL TECNOLOGIA" = Uber, "99 TECNOLOGIA" = 99 corrida, "99 FOOD" = 99 delivery).

═══ OUTPUT (JSON estrito, sem markdown) ═══

{
  "cardName": "Nome do cartão/banco se identificável ou vazio",
  "dueDate": "DD/MM/YYYY se aparecer ou vazio",
  "declaredTotal": 0.00,
  "declaredTotalLabel": "rótulo exato encontrado",
  "transactions": [
    {"type":"expense","date":"YYYY-MM-DD","desc":"...","value":0,"status":"PAGO","category":"<UMA das classificações disponíveis>","subcategory":"<nome livre>"}
  ]
}`;

  const text = await window.db.callAi({
    system: systemMsg,
    user: userMsg,
    maxTokens: 16000,
  });

  const fenced = text.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
  const jsonStr = fenced ? fenced[1] : (text.match(/\{[\s\S]+\}/)?.[0] || text);
  let parsed;
  try { parsed = JSON.parse(jsonStr); }
  catch (e) {
    console.error("[IA] Fatura — resposta inválida:", text.slice(0, 500));
    throw new Error("IA retornou resposta inválida. Veja o console.");
  }
  return {
    cardName: parsed.cardName || "",
    dueDate: parsed.dueDate || "",
    declaredTotal: parsed.declaredTotal != null ? Number(parsed.declaredTotal) : null,
    declaredTotalLabel: parsed.declaredTotalLabel || "",
    transactions: parsed.transactions || [],
  };
}

// Calcula soma das compras vs total declarado pela IA. Tolerância de R$ 0,02 (arredondamento).
function reconcileInvoice(preview) {
  if (!preview) return { sum: 0, declared: 0, diff: 0, ok: true, hasDeclared: false };
  const sum = (preview.txns || []).reduce((s, t) => s + (Number(t.value) || 0), 0);
  const declared = Number(preview.declaredTotal) || 0;
  const diff = sum - declared;
  const hasDeclared = declared > 0;
  const ok = !hasDeclared || Math.abs(diff) <= 0.02;
  return { sum, declared, diff, ok, hasDeclared };
}

// Regras de categorização pra empresas brasileiras conhecidas (rede de segurança contra erros da IA).
// A primeira regra que casar com a descrição vence — ordem importa (mais específico primeiro).
const MERCHANT_RULES = [
  // Delivery (vem ANTES de "99" pra não bater no rideshare)
  { rx: /\b99\s*food\b|\b99\s*-\s*food\b/i, category: "ALIMENTAÇÃO", subcategory: "99 Food" },
  { rx: /\bifood\b|\bi[\s\-]food\b/i, category: "ALIMENTAÇÃO", subcategory: "iFood" },
  { rx: /\brappi\b/i, category: "ALIMENTAÇÃO", subcategory: "Rappi" },

  // Rideshare
  { rx: /\buber\b/i, category: "TRANSPORTE", subcategory: "Uber" },
  { rx: /\b99\s*(pop|t[aá]xi|taxis|tecnologia)\b/i, category: "TRANSPORTE", subcategory: "99" },
  { rx: /^\s*99\b|\bcp\s*:\s*\d+[\s\-]*99\s+/i, category: "TRANSPORTE", subcategory: "99" },
  { rx: /\bcabify\b/i, category: "TRANSPORTE", subcategory: "Cabify" },

  // Combustível
  { rx: /\b(shell|ipiranga|petrobras|br distribuidora|raizen|ale combust)\b/i, category: "TRANSPORTE", subcategory: "Combustível" },
  { rx: /\b(posto|auto posto)\b/i, category: "TRANSPORTE", subcategory: "Combustível" },

  // Estacionamento / pedágio
  { rx: /\b(estacionament|park\s|parking)\b/i, category: "TRANSPORTE", subcategory: "Estacionamento" },
  { rx: /\b(autopista|ccr|ecorodovias|sem parar|ped[aá]gio)\b/i, category: "TRANSPORTE", subcategory: "Pedágio" },

  // Streaming / assinaturas (mensais recorrentes)
  { rx: /\bnetflix\b/i, category: "ASSINATURAS", subcategory: "Netflix" },
  { rx: /\bspotify\b/i, category: "ASSINATURAS", subcategory: "Spotify" },
  { rx: /\b(amazon prime|prime video)\b/i, category: "ASSINATURAS", subcategory: "Amazon Prime" },
  { rx: /\b(disney|globoplay|hbo|max|deezer|youtube premium|paramount|apple\s*tv|apple\s*one|apple\s*music)\b/i, category: "ASSINATURAS", subcategory: "Streaming" },

  // SaaS / Tecnologia (geralmente uso empresarial mas pode aparecer em pessoal)
  { rx: /\b(google\s*workspace|google\s*one|microsoft\s*365|office\s*365|adobe|notion|slack|figma|github|linear|aws|amazon\s*web\s*services|digital\s*ocean|vercel|cloudflare|hostinger|hostgator|godaddy)\b/i, category: "TECNOLOGIA", subcategory: "Software" },

  // Marketing digital (anúncios)
  { rx: /\b(google\s*ads|facebook\s*ads|meta\s*ads|instagram\s*ads|linkedin\s*ads|tiktok\s*ads)\b/i, category: "MARKETING", subcategory: "Anúncios" },

  // Saúde
  { rx: /\b(drogasil|drogaria|farm[áa]cia|farmacia|panvel|pague menos|raia|pacheco)\b/i, category: "SAÚDE", subcategory: "Farmácia" },
  { rx: /\b(hospital|cl[íi]nica|clinica|laborat[óo]rio|laboratorio)\b/i, category: "SAÚDE", subcategory: "Consulta" },

  // Telefonia / serviços
  { rx: /\b(tim|vivo|claro|oi)\s*(s\s*a|s\.\s*a|telecom)\b/i, category: "MORADIA", subcategory: "Telefonia" },
  { rx: /\b(60701190-tim)|\b(02558157-vivo)|\b(40432544-claro)|\b(76535764-oi)\b/i, category: "MORADIA", subcategory: "Telefonia" },

  // Energia / água
  { rx: /\b(cemig|enel|equatorial|cpfl|copel|light|coelba|elektro|energisa)\b/i, category: "MORADIA", subcategory: "Energia" },
  { rx: /\b(sabesp|saneago|caesb|cedae|sanepar|saneamento)\b/i, category: "MORADIA", subcategory: "Água" },

  // Mercados
  { rx: /\b(p[aã]o de a[çc]ucar|carrefour|extra|big bompre[çc]o|atacad[aã]o|assa[íi]|sams club|hiper|hipermercado)\b/i, category: "ALIMENTAÇÃO", subcategory: "Mercado" },
  { rx: /\b(mercado|supermercado|merc\.|sup\.|feirao|salli alimentos|sol e mar|supercouto)\b/i, category: "ALIMENTAÇÃO", subcategory: "Mercado" },

  // Padarias / cafés
  { rx: /\b(panificad|panificadora|padaria|jim\.com|joy panificad|emporio)\b/i, category: "ALIMENTAÇÃO", subcategory: "Padaria" },

  // Pet
  { rx: /\b(petz|cobasi|petshop|pet shop)\b/i, category: "OUTROS", subcategory: "Pet" },

  // Lojas / varejo
  { rx: /\b(daiso|americanas|magazine luiza|magalu|casas bahia|ponto frio|lojas renner|riachuelo|c&a)\b/i, category: "OUTROS", subcategory: "Lojas" },
];

// Aplica regras hard-coded pra empresas conhecidas (sobrepõe categoria/subcategory da IA)
function applyMerchantRules(t) {
  if (t.type === "income") return t; // só pra despesas
  const text = `${t.desc || ""} ${t.subcategory || ""}`.toLowerCase();
  for (const rule of MERCHANT_RULES) {
    if (rule.rx.test(text)) {
      return { ...t, category: rule.category, subcategory: rule.subcategory };
    }
  }
  return t;
}

// Constrói o objeto preview a partir da resposta da IA.
// Valida categoria contra as classificações existentes — se IA retornar uma que
// não existe, troca por "OUTROS" (ou primeira da lista).
function buildPreviewFromAi(result, defaultPayment, ws) {
  const expenseSet = new Set((ws.categories || []).map(c => c.toUpperCase()));
  const incomeSet = new Set((ws.incomeCategories || []).map(c => c.toUpperCase()));
  const expenseFallback = expenseSet.has("OUTROS") ? "OUTROS" : (ws.categories?.[0] || "");
  const incomeFallback = incomeSet.has("OUTROS") ? "OUTROS" : (ws.incomeCategories?.[0] || "");

  const txns = (result.transactions || []).map(t => {
    const isIncome = t.type === "income";
    const base = {
      id: crypto.randomUUID(),
      date: t.date,
      desc: t.desc,
      value: Number(t.value) || 0,
      status: t.status || (isIncome ? "RECEBIDO" : "PAGO"),
      // Fatura: payment é SEMPRE o cartão selecionado pelo usuário (sobrepõe o que a IA devolver)
      payment: defaultPayment || t.payment || "",
      category: (t.category || "").toUpperCase(),
      subcategory: t.subcategory || "",
      who: t.who || "",
      type: isIncome ? "IN" : "OUT",
    };
    // Pós-processamento de empresas conhecidas (Uber, 99, iFood, etc.)
    const withRules = applyMerchantRules(base);
    // Valida categoria contra a lista do usuário — fallback pra OUTROS se não existir
    const validSet = isIncome ? incomeSet : expenseSet;
    const fallback = isIncome ? incomeFallback : expenseFallback;
    if (!validSet.has(withRules.category)) {
      withRules.category = fallback;
    }
    return withRules;
  });
  return {
    txns,
    cardName: result.cardName || "",
    dueDate: result.dueDate || "",
    declaredTotal: result.declaredTotal,
    declaredTotalLabel: result.declaredTotalLabel || "",
  };
}

function logReconcile(preview) {
  const r = reconcileInvoice(preview);
  console.log(
    `[Fatura] ${preview.txns.length} compras. ` +
    `Extraído=${r.sum.toFixed(2)} Declarado=${r.declared.toFixed(2)} Diff=${r.diff.toFixed(2)} ` +
    `OK=${r.ok} Label="${preview.declaredTotalLabel || "—"}" ` +
    `Cartão="${preview.cardName || "—"}"`
  );
}

// Normaliza descrição pra comparar duplicatas — remove ruído de bancos e prefixos
function normalizeDescForDedup(s) {
  if (!s) return "";
  return String(s).toLowerCase()
    // Prefixos de tipo de transação (bank statements, boletos, etc.)
    .replace(/no estabelecimento\s+/gi, "")
    .replace(/pix (enviado|recebido)( devolvido)?\s*[—\-:]\s*/gi, "")
    .replace(/compra (no |com )?d[ée]bito\s*[—\-:]\s*/gi, "")
    .replace(/pagamento (efetuado|fatura)\s*[—\-:]\s*/gi, "")
    .replace(/^(boleto|cobranca|cobrança|fatura|recebimento|deposito|dep[óo]sito|transferência|transferencia|ted|doc)\s*[—\-:]?\s*/gi, "")
    // Códigos
    .replace(/cp\s*:\s*\d+-?/gi, "")
    .replace(/\d{8,}/g, "") // CPFs, CNPJs, IDs longos
    .replace(/\d+\s*\/\s*\d+/g, "") // PARC 02/06
    // Cidades brasileiras comuns
    .replace(/\b(goiania|aparecida de|uberlandia|sao paulo|s[ãa]o paulo|brasilia|bras[íi]lia|rio de janeiro|belo horizonte|curitiba|salvador|recife|fortaleza|porto alegre|manaus|belem|bel[ée]m)\b/gi, "")
    .replace(/\bbra\b/gi, "")
    // Pontuação e espaços
    .replace(/[^\p{L}\p{N}\s]/gu, " ")
    .replace(/\s+/g, " ")
    .trim();
}

// Chave de dedup: data + valor + descrição normalizada (até 24 chars)
function makeDedupKey(t) {
  const date = (t.date || "").trim();
  const val = (Number(t.value) || 0).toFixed(2);
  const desc = normalizeDescForDedup(t.desc).slice(0, 24);
  return `${date}|${val}|${desc}`;
}

// Status que indicam "pendente / a receber" (existente ainda não realizado)
const PENDING_STATUSES = ["PENDENTE", "AGENDADO", "A RECEBER"];

// Filtra transações:
// - kept: novas que não existem ainda (vão ser criadas)
// - skipped: duplicatas exatas (já existem pagas/recebidas)
// - updates: pendentes existentes que casam — vão ser marcadas como PAGAS/RECEBIDAS (não cria duplicata)
function dedupAgainstWorkspace(newTxns, ws) {
  const existing = [
    ...((ws.transactions || []).map(t => ({ ...t, _arr: "transactions" }))),
    ...((ws.receitas || []).map(r => ({ ...r, _arr: "receitas" }))),
  ];
  const existingByKey = new Map();
  existing.forEach(t => {
    const key = makeDedupKey(t);
    // Se já há um match, prefere o pendente (pra resolver pendentes prioritariamente)
    const prev = existingByKey.get(key);
    if (!prev || PENDING_STATUSES.includes(t.status)) {
      existingByKey.set(key, t);
    }
  });

  const kept = [];
  const skipped = [];
  const updates = [];
  for (const t of newTxns) {
    const key = makeDedupKey(t);
    const match = existingByKey.get(key);
    if (match) {
      const isPending = PENDING_STATUSES.includes(match.status);
      if (isPending && t.status && !PENDING_STATUSES.includes(t.status)) {
        // Pendente existente + nova já realizada → marca pendente como pago/recebido
        updates.push({ id: match.id, arr: match._arr, newStatus: t.status, payment: t.payment || match.payment });
      }
      skipped.push(t);
    } else {
      kept.push(t);
    }
  }
  return { kept, skipped, updates };
}

function Field({ label, children }) {
  return (
    <label style={{ display: "flex", flexDirection: "column", gap: 6 }}>
      <span style={{ fontSize: 11.5, color: "var(--fg-3)", textTransform: "uppercase", letterSpacing: 0.5, fontWeight: 500 }}>{label}</span>
      {children}
    </label>
  );
}

// Card mobile pra cada transaction — substitui a row da tabela em telas < 820px
function MobileTxCard({ t, isPessoal, onEdit, onDelete, onQuickStatus }) {
  const isIn = t.type === "IN";
  const d = new Date(t.date);
  const dateStr = d.toLocaleDateString("pt-BR", { day: "2-digit", month: "short" });
  const valueColor = isIn ? "var(--success)" : "var(--fg-1)";
  const valuePrefix = isIn ? "+ " : "";

  const cycleStatus = () => {
    const order = isIn ? ["RECEBIDO", "A RECEBER"] : ["PAGO", "PENDENTE", "AGENDADO"];
    const idx = order.indexOf(t.status);
    onQuickStatus(order[(idx + 1) % order.length]);
  };

  return (
    <div
      className="card tx-mobile-card"
      onClick={onEdit}
      style={{
        padding: 14,
        cursor: "pointer",
        display: "flex",
        flexDirection: "column",
        gap: 8,
      }}
    >
      {/* Header: tipo + data à esquerda, valor à direita */}
      <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
          <TypeBadge type={t.type} />
          <span style={{ fontSize: 11.5, color: "var(--fg-3)", fontVariantNumeric: "tabular-nums" }}>{dateStr}</span>
        </div>
        <div style={{ fontSize: 16, fontWeight: 600, color: valueColor, fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }}>
          {valuePrefix}{fmtBRL(t.value)}
        </div>
      </div>

      {/* Descrição */}
      <div style={{ fontSize: 14, fontWeight: 500, color: "var(--fg-1)", wordBreak: "break-word" }}>
        {t.desc || "—"}
      </div>

      {/* Footer: categoria, pagamento, status, ação delete */}
      <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap", fontSize: 11.5, color: "var(--fg-3)" }}>
        {t.category && <span className="cat-chip" style={{ fontSize: 10.5 }}>{t.category}</span>}
        {t.payment && <span>· {t.payment}</span>}
        <span style={{ marginLeft: "auto", display: "flex", gap: 4, alignItems: "center" }} onClick={e => e.stopPropagation()}>
          <StatusBadge status={t.status} onClick={cycleStatus} />
          <button
            className="icon-btn danger"
            onClick={(e) => { e.stopPropagation(); onDelete(); }}
            title="Apagar"
            style={{ width: 32, height: 32 }}
          >
            <Icon name="trash" size={13} />
          </button>
        </span>
      </div>
    </div>
  );
}

// ===================================================================
// IncomeImportModal — importa planilha de RECEITAS/VENDAS
// Tudo entra como receita. Modal separado do InvoiceImportModal pra
// não confundir o user e pra a IA não precisar adivinhar o tipo.
// ===================================================================
function IncomeImportModal({ ws, setWs, activeWs, accent, onClose }) {
  const [step, setStep] = React.useState("upload"); // upload | loading | preview | done
  const [filename, setFilename] = React.useState("");
  const [progress, setProgress] = React.useState("Lendo planilha…");
  const [preview, setPreview] = React.useState(null); // { txns: [...] }
  const [err, setErr] = React.useState(null);

  React.useEffect(() => {
    const fn = e => { if (e.key === "Escape" && step !== "loading") onClose(); };
    window.addEventListener("keydown", fn);
    return () => window.removeEventListener("keydown", fn);
  }, [onClose, step]);

  const handleFile = async (file) => {
    if (!file) return;
    setFilename(file.name);
    setErr(null);
    setStep("loading");
    setProgress("Lendo planilha…");
    try {
      // Lê XLSX ou CSV
      let rawData;
      if (/\.(xlsx|xls|ods)$/i.test(file.name)) {
        if (!window.XLSX) throw new Error("Biblioteca Excel não carregada — recarregue a página (F5).");
        const buf = await file.arrayBuffer();
        const wb = window.XLSX.read(buf, { cellDates: true, cellNF: true });
        const sheets = {};
        for (const name of wb.SheetNames) {
          const sheet = wb.Sheets[name];
          const mat = window.XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: "" });
          sheets[name] = mat.filter(r => r.some(c => c !== "" && c != null));
        }
        rawData = { filename: file.name, sheets };
      } else {
        const text = await file.text();
        const sep = (text.match(/;/g) || []).length > (text.match(/,/g) || []).length ? ";" : ",";
        const lines = text.split(/\r?\n/).filter(l => l.trim());
        if (!lines.length) throw new Error("Arquivo vazio.");
        const rows = lines.map(l => l.split(sep).map(c => c.replace(/^["']|["']$/g, "").trim()));
        const sheetName = file.name.replace(/\.[^.]+$/, "");
        rawData = { filename: file.name, sheets: { [sheetName]: rows } };
      }

      // 1) Tenta parser DETERMINÍSTICO primeiro (sem custo de IA).
      //    Detecta formatos padrão (ex: relatório de vendas por produto).
      let result = parseStandardIncomeFormat(rawData, ws, msg => setProgress(msg));
      // 2) Se não bateu nenhum formato padrão, cai pro AI com chunking.
      if (!result) {
        setProgress("Analisando com IA (formato livre)…");
        result = await aiParseIncomeSpreadsheet(rawData, ws, msg => setProgress(msg));
      }
      if (!result.transactions || result.transactions.length === 0) {
        throw new Error("Nenhuma receita identificada na planilha. Confira se tem coluna de valor + descrição.");
      }
      setPreview({ txns: result.transactions, deterministic: result.deterministic });
      setStep("preview");
    } catch (ex) {
      console.error("[IncomeImport]", ex);
      setErr(ex.message || String(ex));
      setStep("upload");
    }
  };

  const importNow = async () => {
    if (!preview || !preview.txns?.length) return;
    const today = new Date().toISOString().slice(0, 10);

    setStep("saving");
    setProgress(`Preparando ${preview.txns.length.toLocaleString("pt-BR")} receitas…`);

    // Transforma TODOS os items em formato UI primeiro (rápido em memória)
    const allIncomes = preview.txns.map(t => ({
      id: crypto.randomUUID(),
      date: t.date || today,
      desc: (t.desc || "Receita").trim(),
      value: Number(t.value) || 0,
      status: "RECEBIDO",
      payment: t.payment || "Pix",
      category: (t.category || "OUTRAS RECEITAS").toUpperCase(),
      subcategory: t.subcategory || "",
      who: "",
    }));

    // SAVE DIRETO no Supabase (bypassa diff sync que estava inserindo em
    // paralelo e perdendo dados por rate limit). Chunks sequenciais de 100
    // com retry, com progresso REAL refletindo o que de fato persistiu.
    const { saved, total } = await window.db.insertIncomes(activeWs, allIncomes, ({ saved, total }) => {
      setProgress(`Salvando ${saved.toLocaleString("pt-BR")}/${total.toLocaleString("pt-BR")} no banco…`);
    });

    // Atualiza estado local APÓS persistir no banco. Os mesmos items vão
    // ser comparados pela diff sync — mas como insertIncomes usa upsert,
    // não há erro (no-op em conflito de ID).
    setProgress("Atualizando tela…");
    setWs(prev => ({ ...prev, receitas: [...allIncomes, ...(prev.receitas || [])] }));

    const lostCount = total - saved;
    if (window.toast) {
      if (lostCount > 0) {
        window.toast({
          message: `⚠ Salvou ${saved.toLocaleString("pt-BR")} de ${total.toLocaleString("pt-BR")} receitas. ${lostCount.toLocaleString("pt-BR")} falharam — tente importar de novo.`,
          duration: 12000,
        });
      } else {
        window.toast({
          message: `✓ ${saved.toLocaleString("pt-BR")} receita${saved === 1 ? "" : "s"} importada${saved === 1 ? "" : "s"} com sucesso`,
          duration: 8000,
        });
      }
    }
    setStep("done");
    setTimeout(onClose, 1200);
  };

  return (
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal" onClick={e => e.stopPropagation()} style={{ width: 560, maxWidth: "calc(100vw - 32px)", maxHeight: "85vh", overflow: "auto" }}>
        {/* Header */}
        <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 16, gap: 12 }}>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div className="eyebrow" style={{ fontSize: 10.5, color: "var(--success)" }}>// importar receitas</div>
            <h2 style={{ margin: "6px 0 4px", fontSize: 20, fontWeight: 600, letterSpacing: "-0.02em" }}>Planilha de receitas/vendas</h2>
            <p style={{ margin: 0, fontSize: 12.5, color: "var(--fg-3)", lineHeight: 1.5 }}>
              Envie Excel ou CSV — TODOS os lançamentos entrarão como RECEITA. Pra despesas, use "Importar fatura" ou Configurações.
            </p>
          </div>
          <button className="icon-btn" onClick={onClose} title="Fechar"><Icon name="x" size={16} /></button>
        </div>

        {step === "upload" && (
          <div>
            <label style={{
              display: "block", padding: "32px 24px",
              border: "2px dashed color-mix(in oklab, var(--success) 35%, var(--border))",
              borderRadius: 16, background: "color-mix(in oklab, var(--success) 4%, var(--surface-1))",
              cursor: "pointer", textAlign: "center", transition: "border-color .15s ease, background .15s ease",
            }}>
              <input type="file" accept=".xlsx,.xls,.ods,.csv,.txt" style={{ display: "none" }} onChange={e => { if (e.target.files[0]) handleFile(e.target.files[0]); }} />
              <div style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 52, height: 52, borderRadius: 14, background: "color-mix(in oklab, var(--success) 12%, transparent)", color: "var(--success)", marginBottom: 10 }}>
                <Icon name="arrowUp" size={22} />
              </div>
              <div style={{ fontSize: 14, fontWeight: 600, color: "var(--fg-1)", marginBottom: 4 }}>Clique para selecionar a planilha</div>
              <div style={{ fontSize: 11.5, color: "var(--fg-3)" }}>.xlsx, .xls, .ods, .csv</div>
            </label>
            {err && (
              <div style={{ marginTop: 12, padding: "10px 14px", background: "var(--danger-bg, color-mix(in oklab, var(--danger) 8%, transparent))", border: "1px solid color-mix(in oklab, var(--danger) 25%, transparent)", borderRadius: 10, fontSize: 12.5, color: "var(--danger)" }}>
                {err}
              </div>
            )}
            <div style={{ marginTop: 16, padding: "10px 12px", background: "var(--surface-2)", borderRadius: 8, fontSize: 11.5, color: "var(--fg-2)", lineHeight: 1.6 }}>
              <strong style={{ color: "var(--fg-1)" }}>💡 Como funciona:</strong> A IA lê as colunas da planilha automaticamente (data, descrição, valor) e lança como receita. Use isso pra relatórios de venda, faturamento, comissões recebidas — qualquer tipo de entrada de caixa.
            </div>
          </div>
        )}

        {step === "loading" && (
          <div style={{ padding: "40px 24px", textAlign: "center" }}>
            <div style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 48, height: 48, borderRadius: 14, background: "color-mix(in oklab, var(--success) 12%, transparent)", color: "var(--success)", marginBottom: 16, animation: "spin 1.4s linear infinite" }}>
              <Icon name="spark" size={22} />
            </div>
            <div style={{ fontSize: 14, fontWeight: 600, color: "var(--fg-1)", marginBottom: 4 }}>{progress}</div>
            <div style={{ fontSize: 12, color: "var(--fg-3)" }}>{filename}</div>
            <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
          </div>
        )}

        {step === "preview" && preview && (() => {
          const PREVIEW_LIMIT = 50;
          const total = preview.txns.length;
          const showRows = preview.txns.slice(0, PREVIEW_LIMIT);
          const totalValue = preview.txns.reduce((s, t) => s + (Number(t.value) || 0), 0);
          const hidden = total - showRows.length;
          const isLarge = total > 10000;
          return (
          <div>
            <div style={{ padding: "10px 14px", background: "color-mix(in oklab, var(--success) 8%, transparent)", border: "1px solid color-mix(in oklab, var(--success) 25%, transparent)", borderRadius: 10, fontSize: 13, color: "var(--fg-1)", marginBottom: 14, display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
              <span>
                <strong>{total.toLocaleString("pt-BR")}</strong> receita{total === 1 ? "" : "s"} · total <strong style={{ color: "var(--success)" }}>R$ {totalValue.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>
              </span>
              {preview.deterministic && (
                <span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: 0.4, textTransform: "uppercase", color: "var(--success)", background: "color-mix(in oklab, var(--success) 14%, transparent)", padding: "3px 8px", borderRadius: 4, whiteSpace: "nowrap" }}>
                  ⚡ Formato padrão · sem IA
                </span>
              )}
            </div>

            {isLarge && (
              <div style={{ padding: "10px 14px", background: "var(--warn-bg, color-mix(in oklab, var(--warn) 10%, transparent))", border: "1px solid color-mix(in oklab, var(--warn) 30%, transparent)", borderRadius: 10, fontSize: 12.5, color: "var(--fg-1)", marginBottom: 12, lineHeight: 1.5 }}>
                <strong>⚠️ Volume alto:</strong> {total.toLocaleString("pt-BR")} lançamentos. Vai levar alguns segundos pra salvar e a aba Transações pode ficar lenta com muitos itens. Considere agregar por dia/produto se for exagero.
              </div>
            )}

            <div style={{ maxHeight: 320, overflowY: "auto", border: "1px solid var(--border)", borderRadius: 10 }}>
              <table style={{ width: "100%", fontSize: 12.5, borderCollapse: "collapse" }}>
                <thead style={{ background: "var(--surface-2)", position: "sticky", top: 0 }}>
                  <tr>
                    <th style={{ padding: "8px 10px", textAlign: "left", fontWeight: 600, color: "var(--fg-2)" }}>Data</th>
                    <th style={{ padding: "8px 10px", textAlign: "left", fontWeight: 600, color: "var(--fg-2)" }}>Descrição</th>
                    <th style={{ padding: "8px 10px", textAlign: "right", fontWeight: 600, color: "var(--fg-2)" }}>Valor</th>
                  </tr>
                </thead>
                <tbody>
                  {showRows.map((t, i) => (
                    <tr key={i} style={{ borderTop: i > 0 ? "1px solid var(--border)" : "none" }}>
                      <td style={{ padding: "6px 10px", color: "var(--fg-2)", fontVariantNumeric: "tabular-nums" }}>{t.date || "—"}</td>
                      <td style={{ padding: "6px 10px", color: "var(--fg-1)" }}>{t.desc}</td>
                      <td style={{ padding: "6px 10px", textAlign: "right", fontVariantNumeric: "tabular-nums", color: "var(--success)", fontWeight: 500 }}>R$ {(Number(t.value) || 0).toFixed(2).replace(".", ",")}</td>
                    </tr>
                  ))}
                  {hidden > 0 && (
                    <tr style={{ borderTop: "1px solid var(--border)", background: "var(--surface-2)" }}>
                      <td colSpan={3} style={{ padding: "10px", textAlign: "center", color: "var(--fg-3)", fontSize: 12, fontStyle: "italic" }}>
                        + {hidden.toLocaleString("pt-BR")} lançamentos não mostrados (preview limitado a 50 pra não travar)
                      </td>
                    </tr>
                  )}
                </tbody>
              </table>
            </div>
            <div style={{ marginTop: 16, display: "flex", justifyContent: "flex-end", gap: 8 }}>
              <button className="btn-ghost" onClick={onClose}>Cancelar</button>
              <button className="btn-primary" onClick={importNow} style={{ background: "var(--success)" }}>
                <Icon name="check" size={13} /> Importar {total.toLocaleString("pt-BR")} receita{total === 1 ? "" : "s"}
              </button>
            </div>
          </div>
          );
        })()}

        {step === "saving" && (
          <div style={{ padding: "40px 24px", textAlign: "center" }}>
            <div style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 48, height: 48, borderRadius: 14, background: "color-mix(in oklab, var(--success) 12%, transparent)", color: "var(--success)", marginBottom: 16, animation: "spin 1.4s linear infinite" }}>
              <Icon name="spark" size={22} />
            </div>
            <div style={{ fontSize: 14, fontWeight: 600, color: "var(--fg-1)", marginBottom: 4 }}>{progress}</div>
            <div style={{ fontSize: 12, color: "var(--fg-3)" }}>Não feche essa janela</div>
            <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
          </div>
        )}

        {step === "done" && (
          <div style={{ padding: "40px 24px", textAlign: "center" }}>
            <div style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 56, height: 56, borderRadius: 16, background: "color-mix(in oklab, var(--success) 14%, transparent)", color: "var(--success)", marginBottom: 12 }}>
              <Icon name="check" size={26} />
            </div>
            <div style={{ fontSize: 15, fontWeight: 600, color: "var(--fg-1)" }}>Receitas importadas!</div>
          </div>
        )}
      </div>
    </div>
  );
}

// ===================================================================
// Parser DETERMINÍSTICO de formatos padrão de receita.
// Reconhece: "Total de vendas por produto" (e variantes).
// Retorna null se não bater nenhum padrão (fallback pra IA).
// CUSTO ZERO — não chama IA. Aguenta milhares de linhas sem travar.
// ===================================================================
function parseStandardIncomeFormat(rawData, ws, onProgress) {
  const fileName = rawData.filename || "";
  // Tenta extrair data do nome do arquivo (ex: "Total de vendas por produto 2026-06-09 22_20_00.xlsx")
  const dateMatch = fileName.match(/(\d{4})[-_](\d{2})[-_](\d{2})/);
  const fileDate = dateMatch
    ? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`
    : new Date().toISOString().slice(0, 10);

  // Normaliza header: lowercase + trim
  const norm = (s) => String(s || "").toLowerCase().trim();

  // Patterns reconhecidos
  // 1) Total de vendas por produto: ID | Código | Descrição | Categoria | Preço de referência | Preço médio | Qtd. vendida | Valor Vendido
  const isVendasProdutoHeader = (row) => {
    const cells = row.map(norm);
    return cells.includes("descrição") && (cells.includes("valor vendido") || cells.includes("qtd. vendida"));
  };

  const allTransactions = [];

  for (const sheetName of Object.keys(rawData.sheets)) {
    const rows = rawData.sheets[sheetName] || [];
    if (rows.length === 0) continue;
    onProgress?.(`Lendo aba "${sheetName}"…`);

    // Procura a linha de header (primeiras 5)
    let headerIdx = -1;
    for (let i = 0; i < Math.min(5, rows.length); i++) {
      if (isVendasProdutoHeader(rows[i])) { headerIdx = i; break; }
    }
    if (headerIdx === -1) continue; // não reconhece esse formato

    const header = rows[headerIdx].map(norm);
    // Índices das colunas
    const idxDesc = header.indexOf("descrição");
    const idxCat = header.indexOf("categoria");
    const idxQtd = header.indexOf("qtd. vendida");
    const idxValor = header.indexOf("valor vendido");
    const idxPrecoMedio = header.indexOf("preço médio");
    const idxCodigo = header.indexOf("código");

    if (idxDesc < 0 || idxValor < 0) continue;

    const dataRows = rows.slice(headerIdx + 1);
    onProgress?.(`Processando ${dataRows.length} produtos…`);

    // Parse números BR: "5.500,66" → 5500.66 | "5,5" → 5.5 | "5.500" → 5500
    const parseBR = (v) => {
      if (v == null || v === "") return 0;
      if (typeof v === "number") return v;
      let s = String(v).replace(/[R$\s]/g, "").trim();
      const hasComma = s.includes(",");
      const hasDot = s.includes(".");
      if (hasComma && hasDot) {
        // formato BR: ponto = milhar, vírgula = decimal
        s = s.replace(/\./g, "").replace(",", ".");
      } else if (hasComma) {
        // só vírgula → decimal
        s = s.replace(",", ".");
      }
      // só ponto → pode ser milhar OU decimal. Heurística: se tem 3 dígitos depois → milhar
      const n = parseFloat(s);
      return isNaN(n) ? 0 : n;
    };

    let processed = 0;
    for (const row of dataRows) {
      processed++;
      if (processed % 100 === 0) onProgress?.(`Processando ${processed}/${dataRows.length} produtos…`);

      const desc = String(row[idxDesc] || "").trim();
      const valor = parseBR(row[idxValor]);
      if (!desc || valor <= 0) continue; // linha vazia ou sem valor

      const qtd = idxQtd >= 0 ? Math.max(1, Math.floor(parseBR(row[idxQtd]))) : 1;
      const cat = idxCat >= 0 ? String(row[idxCat] || "").trim() : "";

      // Categoria: tenta encontrar match na lista do workspace; senão OUTRAS RECEITAS
      const incCats = ws.incomeCategories || [];
      const matchedCat = incCats.find(c => norm(c) === norm(cat) || norm(c).includes(norm(cat)) || norm(cat).includes(norm(c)));
      const finalCat = matchedCat || "OUTRAS RECEITAS";

      // EXPANSÃO: 1 transação por UNIDADE vendida.
      // Distribuição EXATA em centavos pra evitar erro de float — soma das N
      // transações é IGUAL ao 'Valor Vendido' original (sem perda de precisão).
      //
      // Exemplo: 687un × R$ 5472,63 total.
      //   totalCents = 547263
      //   baseCents = floor(547263 / 687) = 796 → R$ 7,96
      //   remainder = 547263 - 796 * 687 = 411
      //   → 411 transações de R$ 7,97 + 276 transações de R$ 7,96
      //   → Soma exata: 411*797 + 276*796 = 547263 cents = R$ 5472,63 ✓
      const totalCents = Math.round(valor * 100);
      const baseCents = Math.floor(totalCents / qtd);
      const remainder = totalCents - baseCents * qtd;
      const baseValue = baseCents / 100;
      const extraValue = (baseCents + 1) / 100;
      const descUpper = desc.toUpperCase();
      const catUpper = finalCat.toUpperCase();
      const subcatVal = cat || "Vendas";

      for (let u = 0; u < qtd; u++) {
        allTransactions.push({
          date: fileDate,
          desc: descUpper,
          // Primeiras `remainder` unidades pegam 1 centavo a mais pra fechar o total exato
          value: u < remainder ? extraValue : baseValue,
          category: catUpper,
          subcategory: subcatVal,
          payment: "Pix",
        });
      }
    }
  }

  if (allTransactions.length === 0) return null; // não reconheceu, fallback pra IA
  return { transactions: allTransactions, deterministic: true };
}

// ===================================================================
// IA: parser específico pra planilhas de receita NÃO-PADRÃO.
// Com chunking de 50 linhas — aguenta planilhas grandes sem travar.
// Força tudo como income (não tenta decidir tipo).
// ===================================================================
async function aiParseIncomeSpreadsheet(rawData, ws, onProgress) {
  const sheetNames = Object.keys(rawData.sheets);
  const fileName = rawData.filename || "";
  const allTransactions = [];
  const CHUNK_SIZE = 50; // 50 linhas/chamada de IA — comporta na janela de 8k tokens

  // Conta total de chunks pra mostrar progresso global
  let totalChunks = 0;
  for (const name of sheetNames) {
    const rows = rawData.sheets[name] || [];
    if (rows.length === 0) continue;
    const dataLen = Math.max(0, rows.length - 3);
    totalChunks += Math.max(1, Math.ceil(dataLen / CHUNK_SIZE));
  }

  let chunkCount = 0;
  for (const sheetName of sheetNames) {
    const rows = rawData.sheets[sheetName] || [];
    if (rows.length === 0) continue;

    const headerRows = rows.slice(0, Math.min(3, rows.length));
    const dataRows = rows.slice(headerRows.length);

    // Divide dataRows em chunks de CHUNK_SIZE
    const chunks = dataRows.length === 0
      ? [headerRows]
      : (() => {
          const out = [];
          for (let i = 0; i < dataRows.length; i += CHUNK_SIZE) {
            out.push(dataRows.slice(i, i + CHUNK_SIZE));
          }
          return out;
        })();

    for (let ci = 0; ci < chunks.length; ci++) {
      chunkCount++;
      const chunk = chunks[ci];
      const startLine = dataRows.length === 0 ? 1 : headerRows.length + ci * CHUNK_SIZE + 1;
      onProgress?.(`Analisando aba "${sheetName}" lote ${ci + 1}/${chunks.length} (${chunkCount}/${totalChunks} total)…`);

      const hPart = headerRows.map((r, i) => `[H${i + 1}] ${r.join(" | ")}`).join("\n");
      const dPart = chunk.map((r, i) => `[L${startLine + i}] ${r.join(" | ")}`).join("\n");
      const incCats = (ws.incomeCategories || []).join(", ") || "OUTRAS RECEITAS";

      const systemMsg = `Você extrai RECEITAS de planilhas brasileiras. TODAS as linhas com valor + descrição são RECEITAS (income).

REGRAS:
1. Cada linha [L*] com data E valor vira UMA transação income.
2. Linhas [H*] são cabeçalho — NUNCA viram transação.
3. Use valor ABSOLUTO (positivo) mesmo se a planilha mostrar negativo.
4. Descrição: preserve o NOME REAL. Nunca vazia.
5. Data: YYYY-MM-DD. Se vazia, use hoje.
6. Categoria: APENAS da lista (${incCats}); fallback "OUTRAS RECEITAS".
7. Subcategoria: nome curto agrupador.

VALOR BR: "5.500,00" = 5500, "1.530,66" = 1530.66.

OUTPUT (JSON):
{"transactions":[{"date":"YYYY-MM-DD","desc":"...","value":0,"category":"...","subcategory":"...","payment":"Pix"}]}`;

      const userMsg = `ARQUIVO: "${fileName}" · ABA: "${sheetName}" · LOTE ${ci + 1}/${chunks.length}
TIPO: TODAS são RECEITAS (income).

═══ CABEÇALHO ═══
${hPart || "(sem cabeçalho)"}

═══ DADOS ═══
${dPart}

Extraia em JSON.`;

      const out = await window.db.callAi({ system: systemMsg, user: userMsg, maxTokens: 4000 });
      const fenced = out.match(/```(?:json)?\s*([\s\S]+?)\s*```/);
      const jsonStr = fenced ? fenced[1] : (out.match(/\{[\s\S]+\}/)?.[0] || out);
      try {
        const parsed = JSON.parse(jsonStr);
        const txs = Array.isArray(parsed.transactions) ? parsed.transactions : [];
        allTransactions.push(...txs);
      } catch (e) {
        console.error(`[IncomeImport] JSON inválido no lote ${chunkCount}:`, out.slice(0, 500));
        // Não aborta tudo — continua processando outros lotes
      }
    }
  }

  return { transactions: allTransactions };
}

window.Transactions = Transactions;
