/* ============================================================
   data.jsx — shared store backed by the real /api endpoints.
   Replaces the original in-browser mock store: every action calls
   FastAPI, then re-reads documents so all personas stay in sync.
   STATUS / STEPS / fmtINR are kept identical to the design.
   ============================================================ */

/* ---- status model -------------------------------------------------------- */
// step index reference: 1 Fetch · 2 CN Created · 3 Portal · 4 Notify · 5 Review · 6 Tally · 7 Confirm
// REMASTERED: keyed on lekha.dn_status ∪ lekha.cn_status — a VERBATIM mirror of
// backend app/mock/store.py STATUS (R13). Renaming a key here breaks
// STATUS[doc.status] + Object.keys(STATUS) filters, so keep the two in lockstep.
const STATUS = {
  received:          { label: "Received",         tone: "gray",  step: 1, blurb: "Debit note parsed — ready to validate & generate a credit note" },
  validating:        { label: "Validating…",      tone: "blue",  step: 1, spin: true, blurb: "Checking the debit note against the Tally mirror" },
  validation_failed: { label: "Validation Failed", tone: "amber", step: 1, blurb: "A blocking check failed — flagged to the Lekha exceptions tab; resolving it retries automatically" },
  validated:         { label: "Validated",        tone: "gray",  step: 1, blurb: "Passed validation — ready for credit-note drafting" },
  cn_drafted:        { label: "CN Drafted",        tone: "blue",  step: 2, blurb: "Credit note drafted from the debit note" },
  closed:            { label: "Closed",           tone: "green", step: 7, blurb: "Credit note posted & verified — pipeline complete" },
  draft:             { label: "Lekha Review",     tone: "amber", step: 3, blurb: "Draft composed on Tally actuals — awaiting Lekha admin review (G1)" },
  pending_approval:  { label: "Pending Review",   tone: "amber", step: 4, blurb: "Awaiting finance team approval" },
  in_exception:      { label: "In Exception",     tone: "amber", step: 4, blurb: "Held in the finance Exceptions tab — being reworked from feedback (out of the review queue)" },
  approved:          { label: "Approved",         tone: "teal",  step: 5, blurb: "Approved by finance — queued for e-invoice + Tally" },
  irn_pending:       { label: "IRN Pending…",     tone: "blue",  step: 5, spin: true, blurb: "Requesting the e-invoice IRN" },
  irn_generated:     { label: "IRN Generated",    tone: "teal",  step: 5, blurb: "IRN received — queued for Tally" },
  irn_failed:        { label: "IRN Failed",       tone: "red",   step: 5, blurb: "E-invoice IRN request failed" },
  posting:           { label: "Posting…",         tone: "blue",  step: 6, spin: true, blurb: "Writing the voucher into Tally" },
  posted:            { label: "Posted",           tone: "teal",  step: 6, blurb: "Voucher created in Tally" },
  post_failed:       { label: "Post Failed",      tone: "red",   step: 6, blurb: "Tally rejected the voucher — flagged to exceptions" },
  verified_in_tally: { label: "Verified",         tone: "green", step: 7, blurb: "Seen again on a later sync — round-trip closed" },
  missing_in_tally:  { label: "Missing in Tally", tone: "red",   step: 7, blurb: "Phantom: we posted it but a later sync can't find it" },
  drifted:           { label: "Drifted",          tone: "amber", step: 7, blurb: "Found in Tally but fields no longer match ours" },
  cancelled:         { label: "Cancelled",        tone: "red",   step: 5, blurb: "Rejected / cancelled" },
};
const STEPS = ["Fetch", "CN Drafted", "Lekha Review", "Notify Finance", "Finance Review", "Post to Tally", "Confirm"];

const fmtINR = (n) =>
  "₹" + Number(n || 0).toLocaleString("en-IN", { minimumFractionDigits: n % 1 ? 1 : 0, maximumFractionDigits: 2 });

/* ---- tiny API helper (attaches the bearer token) ------------------------ */
const TOKEN_KEY = "lekha_token";
const api = {
  async req(method, path, body) {
    const headers = { "Content-Type": "application/json" };
    const token = localStorage.getItem(TOKEN_KEY);
    if (token) headers.Authorization = `Bearer ${token}`;
    const opt = { method, headers };
    if (body !== undefined) opt.body = JSON.stringify(body);
    const r = await fetch(path, opt);
    // session expired/revoked -> bounce to login, but ONLY when a real session
    // token exists (public pages like the email-approval landing have none and
    // must never reload-loop on a 401).
    if (r.status === 401 && !path.includes("/auth/login") && token) {
      let reason = "";
      try { const j = await r.clone().json(); reason = (j && j.detail) || ""; } catch (_) {}
      localStorage.removeItem(TOKEN_KEY);
      // single-session: a newer login on another device superseded this one
      if (reason === "session_superseded") sessionStorage.setItem("lekha_signout", "superseded");
      if (!window.__bouncing) { window.__bouncing = true; window.location.reload(); }
    }
    if (!r.ok) {
      // surface the server's human message (e.g. "email already exists") so
      // signup / team forms can show it; fall back to a terse status line.
      const e = new Error(`${method} ${path} -> ${r.status}`);
      e.status = r.status;
      try { const j = await r.json(); if (j && j.detail) e.message = j.detail; } catch (_) {}
      throw e;
    }
    const ct = r.headers.get("content-type") || "";
    return ct.includes("application/json") ? r.json() : null;
  },
  get(p) { return this.req("GET", p); },
  post(p, b) { return this.req("POST", p, b); },
  put(p, b) { return this.req("PUT", p, b); },
  patch(p, b) { return this.req("PATCH", p, b); },
  del(p) { return this.req("DELETE", p); },
  // Build a same-origin media URL (e.g. a PDF for an <iframe> src / download) with
  // the JWT as a ?token= query param — an iframe/anchor can't send an auth header.
  mediaUrl(p) {
    if (!p) return p;
    const t = localStorage.getItem(TOKEN_KEY);
    return t ? `${p}${p.includes("?") ? "&" : "?"}token=${encodeURIComponent(t)}` : p;
  },
};

/* ---- Desktop-native Google OAuth (loopback + PKCE via Rust) ---------------
   Google BLOCKS OAuth inside the Tauri webview, so on desktop the Rust `google_oauth`
   command opens the SYSTEM browser and returns the auth code; the backend swaps it for
   tokens using the desktop client secret (server-side). Returns { id_token, access_token }.
   On the web build this is never used — the browser (GIS) flow handles it. */
const isDesktopApp = () => !!(window.__TAURI__ && window.__TAURI__.core && window.__TAURI__.core.invoke);
let _desktopClientId = null;
async function desktopGoogleOAuth(scopes) {
  if (!isDesktopApp()) return null;
  if (_desktopClientId == null) {
    try { const c = await api.get("/api/auth/config"); _desktopClientId = c.google_desktop_client_id || ""; }
    catch (_) { _desktopClientId = ""; }
  }
  if (!_desktopClientId) throw new Error("Desktop Google sign-in isn't configured.");
  const res = await window.__TAURI__.core.invoke("google_oauth", { clientId: _desktopClientId, scopes });
  return api.post("/api/auth/google/exchange", {
    code: res.code, code_verifier: res.code_verifier, redirect_uri: res.redirect_uri,
  });
}

/* ---- auth hook ----------------------------------------------------------- */
function useAuth() {
  const [user, setUser] = React.useState(null);
  const [ready, setReady] = React.useState(false);

  React.useEffect(() => {
    if (!localStorage.getItem(TOKEN_KEY)) { setReady(true); return; }
    api.get("/api/me")
      .then((u) => setUser(u))
      .catch(() => localStorage.removeItem(TOKEN_KEY))
      .finally(() => setReady(true));
  }, []);

  const login = async (email, password) => {
    const r = await api.post("/api/auth/login", { email, password });
    localStorage.setItem(TOKEN_KEY, r.token);
    setUser({ id: r.id, name: r.name, role: r.role, email: r.email });
    return r;
  };
  const googleLogin = async (credential) => {
    const r = await api.post("/api/auth/google", { credential });
    localStorage.setItem(TOKEN_KEY, r.token);
    setUser({ id: r.id, name: r.name, role: r.role, email: r.email });
    return r;
  };
  const signup = async (payload) => {
    const r = await api.post("/api/auth/signup", payload);
    localStorage.setItem(TOKEN_KEY, r.token);
    setUser({ id: r.id, name: r.name, role: r.role, email: r.email });
    return r;
  };
  const logout = () => { localStorage.removeItem(TOKEN_KEY); setUser(null); };

  return { user, ready, login, googleLogin, signup, logout };
}

/* ---- store --------------------------------------------------------------- */
function useStore() {
  const [docs, setDocs] = React.useState([]);
  const [businesses, setBusinesses] = React.useState([]);
  const [manualApproval, setManual] = React.useState(true);
  const [bridges, setBridges] = React.useState([]);
  const [toasts, setToasts] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const seq = React.useRef(0);

  const toast = (msg, kind = "ok") => {
    const id = ++seq.current;
    setToasts((t) => [...t, { id, msg, kind }]);
    setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3600);
  };

  const refresh = React.useCallback(async () => {
    try {
      const [d, b, s, br] = await Promise.all([
        api.get("/api/documents?page_size=100"),
        api.get("/api/businesses"),
        api.get("/api/admin/settings"),
        api.get("/api/tally/bridge-status"),
      ]);
      setDocs(d.items);
      setBusinesses(b.items);
      setManual(s.manual_approval);
      setBridges(br.bridges || []);
    } catch (e) {
      // per PRD, API failures are not surfaced as UI errors
      console.warn("refresh failed", e);
    } finally {
      setLoading(false);
    }
  }, []);

  React.useEffect(() => { refresh(); }, [refresh]);

  // Live updates — poll so any server-side state change (a new credit note landing
  // in the review queue, a background post/verify completing, a teammate's action)
  // reflects on its own, with NO manual page reload. Pauses while the tab is hidden
  // and refreshes immediately on refocus.
  React.useEffect(() => {
    let timer = null;
    const tick = () => { if (!document.hidden) refresh(); };
    const start = () => { if (!timer) timer = setInterval(tick, 8000); };
    const stop = () => { if (timer) { clearInterval(timer); timer = null; } };
    const onVis = () => { if (document.hidden) stop(); else { refresh(); start(); } };
    start();
    document.addEventListener("visibilitychange", onVis);
    return () => { stop(); document.removeEventListener("visibilitychange", onVis); };
  }, [refresh]);

  // wrap an action: call API, refresh, optional toast
  const act = async (fn, okMsg, okKind = "ok") => {
    try {
      await fn();
      await refresh();
      if (okMsg) toast(okMsg, okKind);
    } catch (e) {
      console.warn("action failed", e);
    }
  };

  // Step 1 — Brand Ops generates the credit note for a specific debit note
  // picked from the master table. Returns {ok}/{ok:false,error} for inline UX.
  const generateCreditNote = async (docId) => {
    try {
      const out = await api.post(`/api/debit-notes/${docId}/generate`);  // { task_id, doc }
      await refresh();                          // DN shows Processing while the worker composes
      if (!out || !out.task_id) { await refresh(); return { ok: true }; }  // nothing to do (already generated)
      const s = await pollTask(out.task_id);    // compose runs in the background (worker)
      await refresh();
      if (s.status === "failure") return { ok: false, error: "generate_failed" };
      if (s.status === "timeout") {             // still running — don't claim success
        toast("Still generating — this is taking longer than usual; the status will update shortly.", "info");
        return { ok: true };
      }
      toast("Credit note generated", "info");
      return { ok: true };
    } catch (e) {
      const noApprover = String(e.message || "").includes("409");
      return { ok: false, error: noApprover ? "no_finance_approver" : "generate_failed" };
    }
  };

  // Step 1 — pull the next debit note from the source (samples corpus) on demand.
  const fetchDebitNote = async () => {
    try {
      await api.post("/api/debit-notes/fetch", {});
      await refresh();
      toast("Fetched a debit note", "info");
      return { ok: true };
    } catch (e) {
      const msg = String(e.message || "");
      const noApprover = msg.includes("no_finance_approver");
      const none = msg.includes("no_documents");
      return { ok: false, error: noApprover ? "no_finance_approver" : none ? "no_documents" : "fetch_failed" };
    }
  };

  // Generic background-task poll (API2): resolve when the task is terminal. Reusable
  // for OCR / generate / post — the frontend half of the task pattern.
  // timeout MUST exceed the worker's hard task_time_limit (600s) so a slow-but-live
  // task is never mistaken for done; on timeout we return {status:"timeout"} and the
  // caller leaves the doc as-is ("still processing"), never a false success.
  const pollTask = (taskId, { interval = 2000, timeout = 660000 } = {}) =>
    new Promise((resolve) => {
      const started = Date.now();
      const tick = async () => {
        try {
          const s = await api.get(`/api/tasks/${taskId}`);
          if (s.status === "success" || s.status === "failure") return resolve(s);
        } catch (_) { /* transient — keep polling */ }
        if (Date.now() - started > timeout) return resolve({ status: "timeout" });
        setTimeout(tick, interval);
      };
      tick();
    });

  // Step 1 — Brand Ops uploads a debit-note PDF. OCR now runs in the BACKGROUND: the
  // upload returns immediately with a placeholder doc (Processing) + a task_id; we poll
  // until OCR completes, then refresh so the doc flips to 'Generate credit note'.
  const uploadDebitNote = async (file) => {
    const fd = new FormData();
    fd.append("file", file);
    const token = localStorage.getItem(TOKEN_KEY);
    try {
      const r = await fetch("/api/debit-notes/upload", {
        method: "POST",
        headers: token ? { Authorization: `Bearer ${token}` } : {},
        body: fd,
      });
      if (!r.ok) {
        let detail = ""; try { detail = (await r.json()).detail || ""; } catch (_) {}
        if (r.status === 409 && detail === "no_finance_approver")
          return { ok: false, error: "no_finance_approver" };
        return { ok: false, error: detail || "upload_failed" };
      }
      const out = await r.json();              // { task_id, doc }
      await refresh();                          // placeholder appears as Processing
      toast("Uploaded — running OCR…", "info");
      if (out.task_id)
        pollTask(out.task_id).then(async (s) => {
          await refresh();                      // OCR done → doc leaves Processing
          if (s.status === "failure") toast("OCR couldn't read that PDF", "error");
        });
      return { ok: true };
    } catch (e) {
      return { ok: false, error: String(e.message || "upload_failed") };
    }
  };

  // Admin gate. The outcome is status-dependent under the compose-first flow:
  // the toast must say what ACTUALLY happened (2026-07-06: a blocked compose
  // showed "credit note generated" — admin thought the doc vanished).
  const adminApprove = async (id) => {
    try {
      const r = await api.post(`/api/admin/debit-notes/${id}/approve`);
      await refresh();
      if (r.status === "draft")
        toast("Draft composed on actuals — waiting in Draft Review", "ok");
      else if (r.status === "validation_failed")
        toast("Compose blocked — held in Processing; the engine raised an exception. Fix it in the Exceptions tab and it retries.", "bad");
      else
        toast("Approved — released to finance review", "ok");
      return r;
    } catch (e) { console.warn("approve failed", e); toast("Approve failed", "bad"); return null; }
  };
  const adminReject = (id) =>
    act(() => api.post(`/api/admin/debit-notes/${id}/reject`, { reason: "Rejected at Lekha admin review" }), "Debit note rejected", "bad");
  const adminEdit = (id, payload) =>
    act(() => api.patch(`/api/admin/debit-notes/${id}`, payload), "Details updated");

  // G1 — Lekha review of composed drafts (flow inversion 2026-07-05)
  const draftApprove = (id) =>
    act(() => api.post(`/api/admin/drafts/${id}/approve`), "Draft approved — released to brand finance");
  // Reject → the reason drives the LLM agent, which recomposes. Return the outcome
  // so the UI can tell the user what actually happened (reworked → review the new
  // preview / handed to the Lekha team), instead of a bland "rejected".
  const _reject = async (url, reason, fallbackMsg) => {
    try {
      const r = await api.post(url, { reason });
      await refresh();
      const rj = (r && r.reject) || {};
      toast(rj.message || fallbackMsg, rj.outcome === "recomposed" ? "ok" : "bad");
      return rj;
    } catch (e) { console.warn("reject failed", e); toast("Reject failed — try again", "bad"); return null; }
  };
  const draftReject = (id, reason) =>
    _reject(`/api/admin/drafts/${id}/reject`, reason || "Rejected at Lekha draft review", "Draft rejected");

  // Finance
  const financeApprove = (id) =>
    act(() => api.post(`/api/credit-notes/${id}/approve`), "Approved — posting to Tally");
  const financeReject = (id, reason) =>
    _reject(`/api/credit-notes/${id}/reject`, reason, "Credit note rejected");
  // Finance's OWN exceptions tab (their stuck cases, 2-try budget) + the 2nd-try feedback
  const financeExceptions = () => api.get(`/api/finance/exceptions`);
  const financeExceptionFeedback = async (id, feedback) => {
    try {
      const r = await api.post(`/api/finance/exceptions/${id}/feedback`, { feedback });
      await refresh();
      const rj = (r && r.reject) || {};
      toast(rj.message || "Sent to Lekha AI", rj.outcome === "recomposed" ? "ok" : "bad");
      return rj;
    } catch (e) { console.warn("finance feedback failed", e); toast("Feedback failed — try again", "bad"); return null; }
  };

  // Admin config — per-company verify gate (replaces the global toggle)
  const setBizGate = (bizId, v) =>
    act(() => api.put(`/api/admin/businesses/${bizId}/gate`, { manual_approval: v }),
        v ? "Verify gate turned on" : "Verify gate turned off", "info");
  // Per-company finance-approval gate. Off => credit notes auto-approve on
  // creation and queue straight for Tally (the approval email is informational).
  const setBizFinanceGate = (bizId, v) =>
    act(() => api.put(`/api/admin/businesses/${bizId}/finance-gate`, { finance_approval_required: v }),
        v ? "Finance approval required" : "Finance approval off — auto-posting", "info");
  const setBizEmail = (bizId, email) =>
    act(() => api.put(`/api/businesses/${bizId}/finance-email`, { email }));
  // start/stop the (simulated) desktop Tally (desktop app); connectivity is then
  // detected from its heartbeat. Finance controls their own connector.
  const setConnector = (running) =>
    act(() => api.post("/api/tally/connector", { running }),
        running ? "Tally posting resumed" : "Tally posting paused", "info");

  const reset = () => act(() => api.post("/api/reset"), "Demo reset");

  // Connector onboarding (WSS claim): the desktop connector dials in to
  // /ws/connector and proves its ed25519 identity. Finance CLAIMS the live
  // connector to their business by connector_id — nothing is pasted, nothing is
  // baked into the .exe. The cloud relays the engine's XML down that socket.
  const connectorStatus = () => api.get("/api/tally/connector/status").catch(() => null);
  const availableConnectors = () =>
    api.get("/api/tally/connector/available").then((r) => r.connectors || []).catch(() => []);
  const claimConnector = async (connectorId) => {
    try {
      const r = await api.post("/api/tally/connector/claim", { connector_id: connectorId });
      await refresh();
      toast("Tally connected", "ok");
      return r;                       // { paired, connected, companies, ... }
    } catch (e) { console.warn("claim failed", e); toast("Tally isn't connected — open the Lekha app on your Tally PC", "bad"); return null; }
  };
  const unclaimConnector = async () => {
    try { const r = await api.post("/api/tally/connector/unclaim"); await refresh(); toast("Tally disconnected", "info"); return r; }
    catch (e) { toast("Couldn't disconnect", "bad"); return null; }
  };
  // Download the installer (endpoint is auth-gated, so fetch with the token and
  // save the blob rather than a bare <a href>).
  const downloadConnector = async () => {
    try {
      const token = localStorage.getItem(TOKEN_KEY);
      const r = await fetch("/api/tally/connector/download",
        { headers: token ? { Authorization: `Bearer ${token}` } : {} });
      if (!r.ok) throw new Error(`download -> ${r.status}`);
      const blob = await r.blob();
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url; a.download = "LekhaTallyAgentSetup.exe";
      document.body.appendChild(a); a.click(); a.remove();
      URL.revokeObjectURL(url);
      toast("Lekha desktop app downloading…", "info");
    } catch (e) { console.warn("download failed", e); toast("Download unavailable", "bad"); }
  };

  // Download the Excel register of credit notes posted through Lekha (auth-gated;
  // fetch with the token and save the blob). Always current — the server generates
  // it from the live posted set on each request.
  const exportPostedRegister = async () => {
    try {
      const token = localStorage.getItem(TOKEN_KEY);
      const r = await fetch("/api/credit-notes/export.xlsx",
        { headers: token ? { Authorization: `Bearer ${token}` } : {} });
      if (!r.ok) throw new Error(`export -> ${r.status}`);
      const blob = await r.blob();
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
      const fname = `lekha-credit-notes-${today}.xlsx`;
      a.href = url; a.download = fname;
      document.body.appendChild(a); a.click(); a.remove();
      URL.revokeObjectURL(url);
      toast(`Saved ${fname} to your Downloads`, "ok");
    } catch (e) { console.warn("export failed", e); toast("Export unavailable", "bad"); }
  };

  // Post an approved credit note now. The BACKEND relays the engine's XML down
  // the live WSS socket to the desktop connector → Tally, runs the §6.3 write +
  // verify-export read-back, and confirms only on a real masterID match.
  const pushToTally = async (docId) => {
    try {
      const out = await api.post(`/api/tally/credit-notes/${docId}/post`);  // { task_id, doc }
      await refresh();                          // CN shows Processing while the worker posts
      if (!out || !out.task_id) { await refresh(); return true; }  // nothing to post
      const s = await pollTask(out.task_id);    // write+verify runs in the background (worker)
      await refresh();
      if (s.status === "timeout") {             // still posting — don't claim a verdict
        toast("Still posting to Tally — the status will update shortly.", "info");
        return false;
      }
      const cnStatus = (s.result && s.result.status) || "";
      if (cnStatus === "verified_in_tally") {
        toast("Posted & verified in Tally", "ok");
        return true;
      }
      if (cnStatus === "approved") {            // connector dropped mid-post → still queued
        toast("Tally not connected — credit note stays queued", "bad");
        return false;
      }
      toast("Tally didn't confirm — flagged to Lekha admin (see Exceptions)", "bad");
      return false;
    } catch (e) {
      const msg = (e && e.message) || "";
      if (msg.includes("connector_offline")) toast("Tally not connected — credit note stays queued", "bad");
      else toast("Tally post failed — stays queued: " + msg, "bad");
      return false;
    }
  };

  // Single-button "Approve & post": approve (→ queued as APPROVED), then post to Tally
  // in the same action. pushToTally does the authoritative server-side connector check —
  // connector up → it posts + verifies now; offline → it 409s and surfaces "stays queued",
  // and the 10s drain posts it automatically once the connector reconnects. No 2nd button.
  const approveAndPost = async (id) => {
    try {
      await api.post(`/api/credit-notes/${id}/approve`);   // finance approve → APPROVED
    } catch (e) {
      toast("Approve failed: " + ((e && e.message) || ""), "bad");
      return false;
    }
    await refresh();
    return await pushToTally(id);                           // post now if up; else stays queued
  };

  // Communication recipients (email control — Lekha admin / Nakad). Each call
  // returns the updated {finance:[...], brand_ops:[...]} grouping for the business.
  const listRecipients = (bizId) => api.get(`/api/businesses/${bizId}/recipients`);
  const addRecipient = async (bizId, channel, email) => {
    try { const r = await api.post(`/api/businesses/${bizId}/recipients`, { channel, email }); toast("Recipient added", "ok"); return r; }
    catch (e) { toast("Couldn't add — check the email address", "bad"); return null; }
  };
  const removeRecipient = async (bizId, rid) => {
    try { const r = await api.del(`/api/businesses/${bizId}/recipients/${rid}`); toast("Recipient removed", "info"); return r; }
    catch (e) { toast("Couldn't remove recipient", "bad"); return null; }
  };

  // Accounting exceptions (engine flagged a credit note — human-in-loop).
  const listExceptions = (status = "OPEN") => api.get(`/api/exceptions?status=${status}`);
  // Live FB1–FB5 loop health, aggregated server-side from the tables (never a snapshot).
  const loopsSummary = () => api.get("/api/loops/summary");
  const resolveException = async (id, option, note = "") => {
    try {
      const r = await api.post(`/api/exceptions/${id}/resolve`, { option, note });
      await refresh();  // the held doc may have composed (draft) or re-blocked
      const rt = r && r.retry;
      if (rt && rt.stage === "post" && rt.posted)
        toast(`Posted to Tally — credit note ${rt.cnNo || ""} confirmed`.trim(), "ok");
      else if (rt && rt.stage === "post" && rt.queued)
        toast(`Re-queued for Tally — ${rt.cnNo || "the credit note"} will post when the Lekha app is online.`, "info");
      else if (rt && rt.stage === "post")
        toast("Retried the post — still blocked; a new exception was raised. Fix the flagged cause, then resolve again.", "bad");
      else if (rt && rt.composed)
        toast(`Fixed — credit note ${rt.cnNo || ""} generated, released to review`.trim(), "ok");
      else if (rt && rt.composed === false)
        toast("Retried — still blocked; a new exception was raised. Fix the flagged issue, then resolve again.", "bad");
      else
        toast("Exception resolved", "ok");
      return r;
    } catch (e) { toast("Couldn't resolve exception", "bad"); return null; }
  };
  // FB2 — hand free-text guidance to the Lekha AI agent for one exception.
  const sendAgentFeedback = async (id, feedback) => {
    try {
      const r = await api.post(`/api/exceptions/${id}/feedback`, { feedback });
      await refresh();
      if (r && r.resolved) toast("Lekha AI applied your feedback — the draft moved forward.", "ok");
      else if (r && r.provider === "disabled") toast("Lekha AI is turned off in this environment.", "info");
      else if (r && /no key|unavailable/i.test(r.provider || "")) toast("Lekha AI isn't configured here (no API key).", "info");
      else toast("Lekha AI couldn't resolve it — see what it tried, then guide it again or fix manually.", "bad");
      return r;
    } catch (e) { toast("Couldn't send feedback to Lekha AI", "bad"); return null; }
  };

  return {
    docs, businesses, manualApproval, bridges, toasts, loading, STATUS, STEPS, fmtINR,
    bizById: (id) => businesses.find((b) => b.id === id) || { name: id, short: "?", color: "#888", email: "" },
    bridgeFor: (id) => bridges.find((b) => b.business_id === id) || { connected: true, queued: 0 },
    refresh, pollTask, generateCreditNote, fetchDebitNote, uploadDebitNote, adminApprove, adminReject, adminEdit,
    draftApprove, draftReject,
    financeApprove, approveAndPost, financeReject, financeExceptions, financeExceptionFeedback, setBizGate, setBizFinanceGate, setBizEmail, setConnector, toast, reset,
    connectorStatus, availableConnectors, claimConnector, unclaimConnector, downloadConnector, exportPostedRegister, pushToTally,
    listRecipients, addRecipient, removeRecipient,
    listExceptions, resolveException, sendAgentFeedback, loopsSummary,
  };
}

Object.assign(window, { STATUS, STEPS, fmtINR, useStore, useAuth, api, isDesktopApp, desktopGoogleOAuth });
