// ═══════════════════════════════════════════════════════════════════════════
// OM GROUP ERP — OM GROUP AI AGENT · UI
// ---------------------------------------------------------------------------
// Native ERP surface for the agent. Loads lazily, never blocks the app, and
// fails soft: if Claude is unreachable the ERP keeps working and the panel
// says so. All ERP data reaches this component only through the tool layer.
// ═══════════════════════════════════════════════════════════════════════════
const { useState: agSt, useEffect: agEf, useRef: agRf, useContext: agCtx, useMemo: agMemo } = React;

const OMA_SESSION_KEY = 'omg_erp_agent_session_v1';

// Processing status — a high-level account of what the agent is doing, driven
// by the tools it ACTUALLY calls, never a fake progress bar. Nothing here
// exposes reasoning; each line names a stage of retrieval the user could verify.
const OMA_STAGE = {
  get_company_context:        'Checking ERP scope and available data',
  list_master_data:           'Looking up master records',
  get_financial_summary:      'Checking revenue, cost and profitability',
  get_sales:                  'Reading sales orders',
  get_purchases:              'Reading purchase records',
  get_material_balance:       'Calculating material balances',
  get_receivables_payables:   'Checking receivables and payables',
  get_pending_orders:         'Reviewing pending orders',
  get_transport_and_diesel:   'Checking transport and diesel',
  get_operations_summary:     'Counting trips and vehicle activity',
  get_business_health:        'Checking sales, purchases and profitability',
  explain_change:             'Working out what moved and why',
  rank_entities:              'Ranking the results',
  compare_periods:            'Comparing periods',
  compare_companies:          'Comparing companies',
  get_customer_movement:      'Checking customer movement',
  find_rate_anomalies:        'Scanning rates for anomalies',
  find_duplicate_records:     'Scanning for duplicate records',
  find_data_quality_issues:   'Checking data integrity',
  convert_material_units:     'Converting units from the material master',
  get_records:                'Pulling the underlying records'
};
const OMA_STAGE_START = 'Analysing your question';
const OMA_STAGE_FINAL = 'Putting the answer together';
const OMA_STAGE_LOCAL = 'Computing from ERP records';

// 25–30 · PROCESSING STATUS
// The label under the character while a request runs comes from
// agent-status.js: a category chosen from the request's own intent, a message
// chosen inside that category, and suppression against the last dozen shown so
// the same line never appears twice in a row. Nothing here narrates reasoning
// — each line names a stage of retrieval, and the tools actually used are
// printed under the finished answer, so the label is checkable.
function omaStatusStart(plan, setStage) {
  const S = window.ERPAgentStatus;
  if (!S) { setStage(OMA_STAGE_START); return null; }
  return S.progression(plan, setStage, { stepDelay: 1500 });
}
function omaSleep(ms) { return new Promise(r => setTimeout(r, ms)); }
const OMA_MIN_STATUS_MS = 520;   // a status label must be readable, never a flash

// ── Reasoning transport ─────────────────────────────────────────────────────
// The agent has TWO independent paths to an answer, and the ERP tool layer is
// the source of truth on both:
//
//   1. LOCAL ENGINE (agent-local.js) — always available. Runs entirely in the
//      browser on the read-only tool layer. No network, no key, no backend.
//      This is what makes the agent work in a static deployment (Vercel).
//   2. LANGUAGE MODEL — used for phrasing when one is genuinely reachable:
//      the preview host's window.claude, or a server-side proxy the operator
//      configures (localStorage 'omg_agent_ai_endpoint' → e.g. /api/om-agent,
//      which keeps the provider key server-side; nothing secret is ever held
//      in the browser). If it fails, the local answer stands — the user is
//      never shown "assistant unavailable" for a question the ERP can answer.
window.OMAgentAI = window.OMAgentAI || {
  endpointKey: 'omg_agent_ai_endpoint',
  endpoint() { try { return localStorage.getItem(this.endpointKey) || ''; } catch (e) { return ''; } },
  hasHost() { return !!(window.claude && typeof window.claude.complete === 'function'); },
  mode() { return this.hasHost() ? 'host' : (this.endpoint() ? 'proxy' : 'local'); },
  // Single-shot proxy call. The browser sends only figures the ERP already
  // computed locally; the proxy adds the provider key on the server side.
  async viaProxy(system, messages, signalTimeoutMs) {
    const ctrl = new AbortController();
    const t = setTimeout(() => ctrl.abort(), signalTimeoutMs || 20000);
    try {
      const res = await fetch(this.endpoint(), {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ system, messages }), signal: ctrl.signal
      });
      if (!res.ok) throw new Error('HTTP ' + res.status);
      const j = await res.json();
      const text = j && (j.text || j.completion || j.answer || (j.content && j.content[0] && j.content[0].text));
      if (!text) throw new Error('empty response');
      return String(text);
    } finally { clearTimeout(t); }
  }
};

// Bounded retry with exponential backoff — no duplicate in-flight requests,
// no infinite loop, and a hard stop after the last attempt.
async function omaRetry(fn, attempts, baseMs) {
  let err = null;
  for (let i = 0; i < attempts; i++) {
    try { return await fn(i); } catch (e) { err = e; }
    if (i < attempts - 1) await new Promise(r => setTimeout(r, baseMs * Math.pow(2, i)));
  }
  throw err || new Error('unknown error');
}

const OMA_QUICK = [
  { label: "Today's performance",  prompt: "Give me today's management summary for the current scope — revenue, gross profit, tonnage and anything that needs attention." },
  { label: 'Material balance',     prompt: 'What is our material quantity balance right now? Flag anything close to or below zero.' },
  { label: 'Receivables',          prompt: 'Which customers have outstanding receivables, and how old are they?' },
  { label: 'This month vs last',   prompt: 'Compare this month with last month on revenue, gross profit and tonnage, and explain what moved.' },
  { label: 'Rate anomalies',       prompt: 'Find purchase and sale rates that look abnormal against their material average, and tell me which ones matter.' },
  { label: 'Needs attention',      prompt: 'Review the ERP and tell me the management-level issues that need attention right now, most important first.' }
];

const OmaIcon = {
  send: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" /></svg>,
  close: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><path d="M18 6L6 18M6 6l12 12" /></svg>,
  reset: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M3 12a9 9 0 109-9 9 9 0 00-6.36 2.64L3 8" /><path d="M3 3v5h5" /></svg>,
  bolt: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2L3 14h8l-1 8 10-12h-8l1-8z" /></svg>
};

// ── Response markup renderer ────────────────────────────────────────────────
function omaInline(text, key) {
  const parts = String(text).split(/\*\*(.+?)\*\*/g);
  return parts.map((p, i) => (i % 2 ? <strong key={key + '-' + i}>{p}</strong> : <React.Fragment key={key + '-' + i}>{p}</React.Fragment>));
}

function omaParseBlocks(text) {
  const out = []; const re = /```(kpi|actions|ask|approval)\s*([\s\S]*?)```/g; let last = 0, m;
  while ((m = re.exec(text))) {
    if (m.index > last) out.push({ t: 'md', v: text.slice(last, m.index) });
    let data = null; try { data = JSON.parse(m[2].trim()); } catch (e) { data = null; }
    if (m[1] === 'approval') { if (data && data.id) out.push({ t: 'approval', v: data }); }
    else if (data && Array.isArray(data) && data.length) out.push({ t: m[1], v: data });
    last = re.lastIndex;
  }
  if (last < text.length) out.push({ t: 'md', v: text.slice(last) });
  return out;
}

// ── Approval card ───────────────────────────────────────────────────────────
// The transaction preview. It is not a confirmation dialog: it states exactly
// what will be written, where each value came from, which checks passed, and
// what the change costs. The three buttons are the ONLY way a write ever
// happens — and they stop working the moment the draft is no longer the live
// one, so a card scrolled back to in an old turn cannot re-fire a write.
function OmaApproval({ model, live, onWrite }) {
  const risky = model.risk === 'high' || model.risk === 'critical';
  const done = !live;
  return (
    <div className={'oma-appr' + (risky ? ' risky' : '') + (done ? ' spent' : '')}>
      <div className="oma-appr-hd">
        <span className="oma-appr-t">{model.title}</span>
        <span className={'oma-appr-risk r-' + model.risk}>{model.risk}</span>
      </div>
      {model.context ? <div className="oma-appr-ctx">{model.context}</div> : null}
      {model.rows && model.rows.length ? (
        <div className="oma-appr-rows">
          {model.rows.map((r, i) => (
            <div className="oma-appr-row" key={i}>
              <span className="k">{r.label}</span>
              <span className="v">{r.value}{r.source ? <em> · {r.source}</em> : null}</span>
            </div>
          ))}
        </div>
      ) : null}
      {model.diff && model.diff.length ? (
        <div className="oma-appr-diff">
          <div className="oma-appr-diff-h"><span>Field</span><span>Current</span><span>New</span></div>
          {model.diff.map((d, i) => (
            <div className="oma-appr-diff-r" key={i}><span>{d.label}</span><span className="was">{d.from}</span><span className="now">{d.to}</span></div>
          ))}
        </div>
      ) : null}
      {model.sources && model.sources.length ? (
        <div className="oma-appr-src">{model.sources.map((s, i) => <span key={i}>{s.label}: {s.note}</span>)}</div>
      ) : null}
      {model.checks && model.checks.length ? (
        <div className="oma-appr-checks">
          {model.checks.map((c, i) => (
            <span className={'oma-appr-ck ' + c.state} key={i}>{c.state === 'warn' ? '!' : '✓'} {c.label}</span>
          ))}
        </div>
      ) : null}
      {model.warnings && model.warnings.length ? (
        <div className="oma-appr-warn">{model.warnings.map((x, i) => <div key={i}>{x}</div>)}</div>
      ) : null}
      {model.impact ? <div className="oma-appr-impact">{model.impact}</div> : null}
      <div className="oma-appr-foot">{done ? 'This draft is no longer active.' : model.riskLine}</div>
      {model.buttons && model.buttons.length ? (
        <div className="oma-appr-btns">
          {model.buttons.map((b, i) => (
            <button key={i} disabled={done} className={'oma-appr-b ' + b.key}
              onClick={() => onWrite && onWrite(b.key, model.id)}>{b.label}</button>
          ))}
        </div>
      ) : null}
    </div>
  );
}

function OmaMarkdown({ text }) {
  const lines = String(text).split('\n');
  const nodes = []; let bullets = [];
  const flush = (k) => { if (bullets.length) { nodes.push(<ul key={'u' + k}>{bullets.map((b, i) => <li key={i}>{omaInline(b, 'b' + k + i)}</li>)}</ul>); bullets = []; } };
  lines.forEach((raw, idx) => {
    const line = raw.trim();
    if (!line) { flush(idx); return; }
    if (/^([-*_])\1{2,}$/.test(line)) { flush(idx); return; } // markdown rule — the panel uses spacing, not lines
    if (/^-\s+/.test(line) || /^\d+\.\s+/.test(line)) { bullets.push(line.replace(/^-\s+/, '').replace(/^\d+\.\s+/, '')); return; }
    flush(idx);
    if (/^#{2,4}\s+/.test(line)) nodes.push(<h2 key={idx}>{line.replace(/^#{2,4}\s+/, '')}</h2>);
    else if (/^>\s?/.test(line)) nodes.push(<div className="oma-note" key={idx}>{omaInline(line.replace(/^>\s?/, ''), 'n' + idx)}</div>);
    else if (/^[*_#\s]*scope\s*:/i.test(line)) nodes.push(<div className="oma-scopeline" key={idx}>{omaInline(line, 's' + idx)}</div>);
    else nodes.push(<p key={idx}>{omaInline(line, 'p' + idx)}</p>);
  });
  flush('end');
  return <React.Fragment>{nodes}</React.Fragment>;
}

function OmaAnswer({ text, onNavigate, onAsk, onWrite, pendingId }) {
  const blocks = omaParseBlocks(text);
  return (
    <React.Fragment>
      {blocks.map((b, i) => {
        if (b.t === 'kpi') return (
          <div className="oma-kpis" key={i}>
            {b.v.slice(0, 4).map((k, j) => (
              <div className="oma-kpi" key={j}>
                <div className="oma-kpi-l">{k.label}</div>
                <div className="oma-kpi-v">{k.value}</div>
                {k.sub ? <div className="oma-kpi-s">{k.sub}</div> : null}
              </div>
            ))}
          </div>
        );
        if (b.t === 'ask') return (
          <div className="oma-acts" key={i}>
            {b.v.slice(0, 3).map((a, j) => (
              <button className="oma-act ask" key={j} onClick={() => onAsk && onAsk(a.send)}>{a.label}</button>
            ))}
          </div>
        );
        if (b.t === 'approval') return (
          <OmaApproval key={i} model={b.v} live={b.v.id === pendingId} onWrite={onWrite} />
        );
        if (b.t === 'actions') return (
          <div className="oma-acts" key={i}>
            {b.v.slice(0, 3).map((a, j) => (
              <button className="oma-act" key={j} onClick={() => onNavigate(a.page)}>{a.label}</button>
            ))}
          </div>
        );
        return <OmaMarkdown key={i} text={b.v} />;
      })}
    </React.Fragment>
  );
}

// ── Create & Edit guide ────────────────────────────────────────────────────────
// Generated from the action registry and live master data, so it can only
// teach what this ERP can actually do, with names that exist here.
function OmaGuide({ onAsk, onClose }) {
  const model = agMemo(() => (window.ERPAgentGuide ? window.ERPAgentGuide.model() : null), []);
  const [openKey, setOpenKey] = agSt('CREATE');
  if (!model) return null;
  const sec = model.sections.filter(s => s.examples && s.examples.length);
  const active = sec.find(s => s.key === openKey) || sec[0];
  return (
    <div className="oma-guide">
      <div className="oma-guide-hd">
        <div>
          <div className="oma-guide-t">What would you like to do?</div>
          <div className="oma-guide-s">Say it the way you would say it out loud. Tap an example to send it.</div>
        </div>
        <button className="oma-ic" onClick={onClose} title="Close guide">{OmaIcon.close}</button>
      </div>
      <div className="oma-guide-tabs">
        {sec.map(s => (
          <button key={s.key} className={'oma-guide-tab' + (active.key === s.key ? ' on' : '')} onClick={() => setOpenKey(s.key)}>{s.title}</button>
        ))}
      </div>
      <div className="oma-guide-bd">
        <div className="oma-guide-blurb">{active.blurb}</div>
        <div className="oma-guide-ex">
          {active.examples.map((e, i) => (
            <button key={i} onClick={() => { onAsk(e); onClose(); }}>{e}</button>
          ))}
        </div>
        <div className="oma-guide-rules">
          {model.rules.map((r, i) => <div key={i}>{r}</div>)}
        </div>
        {model.limits.length ? (
          <div className="oma-guide-lim">
            <div className="oma-guide-lim-h">What I will not do</div>
            {model.limits.map((l, i) => <div key={i}><b>{l.label}</b> — {l.why}</div>)}
          </div>
        ) : null}
      </div>
    </div>
  );
}

// ── Panel ───────────────────────────────────────────────────────────────────
function OMAgentPanel({ onClose }) {
  const ctx = agCtx(window.AppCtx) || {};
  const [msgs, setMsgs] = agSt(() => {
    try { const s = JSON.parse(sessionStorage.getItem(OMA_SESSION_KEY) || '[]'); return Array.isArray(s) ? s : []; } catch (e) { return []; }
  });
  const [input, setInput] = agSt('');
  const [busy, setBusy] = agSt(false);
  // One authoritative character state, driven by the real agent lifecycle:
  // idle → listening (user composing) → thinking (tools running) →
  // success / nodata / error (what the ERP actually returned) → idle.
  const [face, setFace] = agSt('idle');
  const [stage, setStage] = agSt(OMA_STAGE_START);
  const [guideOpen, setGuideOpen] = agSt(false);
  // Which draft is currently live. Cards from earlier turns render spent and
  // cannot fire a write — the approval belongs to one draft, once.
  const pendingId = (window.ERPWriteDraft && window.ERPWriteDraft.pending()) ? window.ERPWriteDraft.pending().id : null;
  const canWrite = !!(window.ERPWrite && window.ERPWrite.available());
  // Short-term conversational memory: the previous turn's parsed plan, so an
  // elliptical follow-up inherits metric/period/scope instead of resetting.
  const planRef = agRf(null);
  const bodyRef = agRf(null); const taRef = agRf(null); const moodRef = agRf(null);

  function settle(mood, hold) {
    clearTimeout(moodRef.current);
    setFace(mood);
    moodRef.current = setTimeout(() => setFace('idle'), hold);
  }
  agEf(() => () => clearTimeout(moodRef.current), []);

  const context = window.ERPAgentAdapter.liveContext();
  const totalRecords = context.ok ? Object.keys(context.recordCounts).reduce((s, k) => s + context.recordCounts[k], 0) : 0;

  agEf(() => { try { sessionStorage.setItem(OMA_SESSION_KEY, JSON.stringify(msgs.slice(-20))); } catch (e) {} }, [msgs]);
  agEf(() => { const el = bodyRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs, busy]);
  agEf(() => { const t = setTimeout(() => taRef.current && taRef.current.focus(), 120); return () => clearTimeout(t); }, []);

  function navigate(page) {
    if (page && ctx.navigate) { ctx.navigate(page); onClose(); }
  }

  // ── Approve / Modify / Deny ────────────────────────────────────────────
  // The button does not write. It asks the action layer to run the approved
  // lifecycle — revalidate, permission, staleness, execute, read back, verify,
  // audit — and prints whatever that lifecycle actually reports.
  async function writeAction(kind, draftId) {
    const A50 = window.ERPAgentAnswers50;
    if (!A50 || busy) return;
    if (kind === 'modify') {
      setMsgs(m => m.concat([{ role: 'agent', mood: 'listening', text: '**Which details would you like to change?**\n\nName the field and the new value — “make it 60 tons”, “rate 875”, “use tomorrow’s date”. Everything else stays exactly as it is, and the draft stays on the table.' }]));
      setFace('listening');
      setTimeout(() => taRef.current && taRef.current.focus(), 60);
      return;
    }
    setBusy(true); clearTimeout(moodRef.current); setFace('thinking');
    setStage(kind === 'approve' ? 'Writing the approved record…' : 'Cancelling the draft…');
    await omaSleep(240);
    let out = null;
    try { out = kind === 'approve' ? A50.approve(draftId) : A50.deny(draftId); } catch (e) { out = null; }
    if (!out) {
      setMsgs(m => m.concat([{ role: 'error', text: 'That draft could not be actioned. Nothing in your ERP data was changed — ask me to prepare it again.' }]));
      setBusy(false); settle('error', 3000); return;
    }
    if (kind === 'approve') {
      setStage('Reading the record back to verify it…');
      await omaSleep(200);
    }
    const mood = out.kind === 'write_done' ? 'success' : out.kind === 'write_failed' ? 'error' : 'idle';
    setMsgs(m => m.concat([{ role: 'agent', text: out.lines.filter(Boolean).join('\n\n'), mood: mood }]));
    setBusy(false); settle(mood === 'error' ? 'error' : 'success', 2600);
  }

  async function ask(text) {
    const q = String(text || '').trim();
    if (!q || busy) return;
    setMsgs(m => m.concat([{ role: 'user', text: q }]));
    setInput('');
    setBusy(true); clearTimeout(moodRef.current); setFace('thinking');
    setStage((window.ERPAgentStatus && window.ERPAgentStatus.messages.general[0]) || OMA_STAGE_START);
    const startedAt = Date.now();

    // ── 1. Parse the request ────────────────────────────────────────────────
    // Always first, and independent of any AI service: an unreachable model
    // must not stop the ERP from answering.
    let plan = null;
    try {
      if (window.ERPAgentIntel) { plan = window.ERPAgentIntel.plan(q, planRef.current); planRef.current = plan; }
    } catch (e) { plan = null; }

    if (!plan) {
      setMsgs(m => m.concat([{ role: 'error', text: 'I could not understand that request. Try asking something like "show today\u2019s sales", "who owes us money" or "compare this month with last month".' }]));
      setBusy(false); settle('error', 3000);
      return;
    }

    // The status progression is driven by the parsed plan, so a challan lookup
    // never shows a ranking label. It is started BEFORE retrieval and yields
    // one paint, so the first label is on screen while the ERP is read.
    const prog = omaStatusStart(plan, setStage);
    await omaSleep(50);

    // ── 2. Answer from the ERP, locally ────────────────────────────────────
    // The read-only tool layer computes the figures. This path has no network
    // dependency at all, so it works identically in dev, in a production
    // build and on a static host.
    const used = [];
    let local = null, erpError = null;
    try {
      local = window.ERPAgentLocal.respond(plan, {
        onStage: (name) => {
          used.push(name);
          if (prog) prog.setTool(name);
          else setStage(OMA_STAGE[name] || OMA_STAGE_LOCAL);
        }
      });
    } catch (e) {
      erpError = e;
    }
    if (prog) prog.finish();
    // Local retrieval is synchronous and often finishes inside one frame. Hold
    // the status just long enough to be read — this is honest about the stage
    // that ran, and stops the panel from flickering on fast answers.
    const elapsed = Date.now() - startedAt;
    if (elapsed < OMA_MIN_STATUS_MS) await omaSleep(OMA_MIN_STATUS_MS - elapsed);

    // What the ANSWER established becomes conversational context: after "who
    // is our biggest customer?", "they" is that customer. The ERP said it, so
    // the next question does not have to.
    try {
      if (local && window.ERPAgentContext) {
        window.ERPAgentContext.noteAnswer(local.focus, { kind: local.kind, records: local.records });
      }
    } catch (e) {}

    if (!local) {
      const code = String((erpError && (erpError.code || erpError.message)) || '');
      const text = code === 'ERP_UNAVAILABLE'
        ? 'I could not access the current ERP records on this device. OM GROUP ERP itself is unaffected — reload the page and ask again.'
        : 'I could not complete that lookup against the ERP records (' + (code || 'unknown error') + '). Nothing in your data was affected — try rephrasing or narrowing the period.';
      setMsgs(m => m.concat([{ role: 'error', text }]));
      setBusy(false); settle('error', 3400);
      return;
    }

    // A clarification is a complete, correct turn — it must not read as a
    // failure. It also needs no model pass: there are no figures to phrase.
    if (local.kind === 'clarify') {
      setMsgs(m => m.concat([{ role: 'agent', text: local.text, mood: 'listening' }]));
      setBusy(false); settle('listening', 2000);
      return;
    }

    // A write turn NEVER goes through the phrasing model. The approval card is
    // a structured contract between the action layer and the user; a model that
    // reworded it could describe a write that was not the one prepared.
    if (local.kind && String(local.kind).indexOf('write_') === 0) {
      const wMood = local.kind === 'write_done' ? 'success' : local.kind === 'write_failed' ? 'error' : 'listening';
      setMsgs(m => m.concat([{ role: 'agent', text: local.text, mood: wMood }]));
      setBusy(false); settle(wMood, wMood === 'success' ? 2600 : 2400);
      return;
    }

    const localMood = local.records === 0 ? 'nodata' : 'success';

    // ── 3. Optional model pass — phrasing only, never the figures ──────────
    const mode = window.OMAgentAI.mode();
    if (mode === 'local') {
      setMsgs(m => m.concat([{ role: 'agent', text: local.text, tools: local.tools.slice(0, 6), mood: localMood }]));
      setBusy(false); settle(localMood, localMood === 'success' ? 2400 : 3000);
      return;
    }

    setStage(window.ERPAgentStatus ? window.ERPAgentStatus.pickFrom('recommendation') : OMA_STAGE_FINAL);

    if (mode === 'proxy') {
      const sys = window.ERPAgentKnowledge.systemInstructions() + '\n\n' + window.ERPAgentAdapter.contextPrompt()
        + (window.ERPAgentContext ? '\n\n' + window.ERPAgentContext.promptBlock() : '')
        + '\n\n' + window.ERPAgentIntel.promptBlock(plan)
        + '\n\n' + window.ERPAgentLocal.dataBlock(local)
        + '\n\nYou have NO tools on this path. State only figures present in the retrieved data above. Never introduce a number that is not there.';
      const hist = msgs.filter(m => m.role === 'user' || m.role === 'agent').slice(-6)
        .map(m => ({ role: m.role === 'user' ? 'user' : 'assistant', content: m.text }));
      try {
        const out = await omaRetry(() => window.OMAgentAI.viaProxy(sys, hist.concat([{ role: 'user', content: q }])), 2, 700);
        setMsgs(m => m.concat([{ role: 'agent', text: String(out), tools: local.tools.slice(0, 6), mood: localMood }]));
      } catch (e) {
        // The ERP answered; only the phrasing pass failed. Show the real answer.
        setMsgs(m => m.concat([{ role: 'agent', text: local.text, tools: local.tools.slice(0, 6), mood: localMood }]));
      }
      setBusy(false); settle(localMood, localMood === 'success' ? 2400 : 3000);
      return;
    }

    // ── Host path: full tool loop, exactly as before ───────────────────────
    // The character's expression is derived from what the ERP actually
    // returned — never from the fact that a reply arrived.
    let maxRecords = local.records, sawRecordCount = local.records > 0;
    const tools = window.ERPAgentTools.forClaude(q).map(t => Object.assign({}, t, {
      run: async (input) => {
        used.push(t.name);
        const cat = window.ERPAgentStatus && window.ERPAgentStatus.forTool(t.name);
        setStage(cat ? window.ERPAgentStatus.pickFrom(cat) : (OMA_STAGE[t.name] || 'Reading ERP records'));
        const out = await t.run(input);
        setStage(window.ERPAgentStatus ? window.ERPAgentStatus.pickFrom('general') : OMA_STAGE_FINAL);
        try {
          const j = JSON.parse(out);
          if (typeof j.recordsInScope === 'number') { sawRecordCount = true; maxRecords = Math.max(maxRecords, j.recordsInScope); }
        } catch (e) { /* tool output is always JSON; ignore anything else */ }
        return out;
      }
    }));

    // The plan is advisory analysis in the prompt — the model still verifies
    // everything through tools.
    const analysis = plan ? '\n\n' + window.ERPAgentIntel.promptBlock(plan) : '';
    const memory = window.ERPAgentContext ? '\n\n' + window.ERPAgentContext.promptBlock() : '';

    const system = window.ERPAgentKnowledge.systemInstructions() + '\n\n' + window.ERPAgentAdapter.contextPrompt() + memory + analysis;
    const history = msgs.filter(m => m.role === 'user' || m.role === 'agent').slice(-8)
      .map(m => ({ role: m.role === 'user' ? 'user' : 'assistant', content: m.text }));

    const body = { system, messages: history.concat([{ role: 'user', content: q }]), tools, max_tokens: 2000 };

    let reply = null;
    try {
      reply = await omaRetry(async (i) => {
        return i === 0
          ? await window.claude.complete(Object.assign({ model: 'claude-sonnet-4-5' }, body))
          : await window.claude.complete(body);
      }, 2, 600);
    } catch (e) { reply = null; }

    // No model reply is not a failure state: the ERP already produced the
    // answer locally from the same records.
    const finalText = reply ? String(reply) : local.text;
    const finalTools = (reply ? used : local.tools).filter((t, i, a) => a.indexOf(t) === i).slice(0, 6);
    const mood = (sawRecordCount && maxRecords === 0) ? 'nodata' : 'success';
    setMsgs(m => m.concat([{ role: 'agent', text: finalText, tools: finalTools, mood: mood }]));
    settle(mood, mood === 'success' ? 2400 : 3000);
    setBusy(false);
  }

  function onKey(e) {
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); ask(input); }
  }

  const hour = new Date().getHours();
  const greeting = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening';
  const firstName = String(context.user || '').split(' ')[0] || '';

  return (
    <div className="oma-panel" role="dialog" aria-label="OM Group AI Agent">
      <div className="oma-hd">
        <window.OMAgentCharacter state={busy ? 'thinking' : face} size={92} style={{ width: 'clamp(72px,20vw,92px)', height: 'clamp(72px,20vw,92px)' }} />
        <div>
          <div className="oma-hd-t">OM Group AI Agent</div>
          <div className="oma-hd-s">ERP Intelligence</div>
        </div>
        <div className="oma-hd-sp" />
        {window.ERPAgentGuide && (
          <div className={'oma-ic' + (guideOpen ? ' on' : '')} title="How to use the agent" role="button" aria-label="Open the agent guide" onClick={() => setGuideOpen(v => !v)}>
            <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 115 .5c0 1.5-2.5 1.8-2.5 3.5" /><path d="M12 17h.01" /></svg>
          </div>
        )}
        {msgs.length > 0 && (
          <div className="oma-ic" title="New conversation" onClick={() => { setMsgs([]); setFace('idle'); planRef.current = null; if (window.ERPAgentContext) window.ERPAgentContext.clear(); if (window.ERPAgentStatus) window.ERPAgentStatus.reset(); if (window.ERPWriteDraft) window.ERPWriteDraft.clear(); if (window.ERPAgentAnswers50) window.ERPAgentAnswers50.reset(); }}>{OmaIcon.reset}</div>
        )}
        <div className="oma-ic" title="Minimize OM Group AI Agent" role="button" aria-label="Minimize OM Group AI Agent" onClick={onClose}>
          <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9l6 6 6-6" /></svg>
        </div>
      </div>

      <div className="oma-scope">
        <span className="oma-chip">Scope <b>{context.ok ? context.currentScope : 'unavailable'}</b></span>
        <span className="oma-chip">{context.ok ? totalRecords.toLocaleString('en-IN') + ' records' : '—'}</span>
        <span className={'oma-chip ' + (canWrite ? 'wr' : 'ro')}>{canWrite ? 'Writes need your approval' : 'Read-only'}</span>
      </div>

      {guideOpen && <OmaGuide onAsk={ask} onClose={() => setGuideOpen(false)} />}

      <div className="oma-body" ref={bodyRef}>
        {msgs.length === 0 && (
          <div>
            <div className="oma-home-t">{greeting}{firstName ? ', ' + firstName : ''}.</div>
            <div className="oma-home-p">
              I read the live OM GROUP ERP state on this device — {context.ok ? context.currentScope : 'the current scope'} — and answer from the records that actually exist. I can also prepare a new record or a change to an existing one: you see exactly what will be written, and nothing is saved until you approve it.
            </div>
            <div className="oma-qa">
              {OMA_QUICK.map((q, i) => (
                <button key={i} onClick={() => ask(q.prompt)}>{OmaIcon.bolt}<span>{q.label}</span></button>
              ))}
            </div>
            {totalRecords > 0 && context.transactionSpan.first ? (
              <div className="oma-empty">Transaction data available from <b>{window.ERPAgentAdapter.fmtDisplay(context.transactionSpan.first)}</b> to <b>{window.ERPAgentAdapter.fmtDisplay(context.transactionSpan.last)}</b>. Ask about any period inside it.</div>
            ) : (
              <div className="oma-empty">This ERP currently holds no dated sales or purchase transactions on this device. I will say so rather than estimate — add records and ask again.</div>
            )}
          </div>
        )}

        {msgs.map((m, i) => {
          if (m.role === 'user') return <div className="oma-msg" key={i}><div className="oma-user">{m.text}</div></div>;
          if (m.role === 'error') return <div className="oma-msg" key={i}><div className="oma-err">{m.text}</div></div>;
          return (
            <div className="oma-msg" key={i}>
              <div className="oma-ag">
                <window.OMAgentCharacter state={m.mood === 'nodata' ? 'nodata' : 'idle'} size={52} style={{ marginTop: -2 }} />
                <div className="oma-ag-b">
                  <OmaAnswer text={m.text} onNavigate={navigate} onAsk={ask} onWrite={writeAction} pendingId={pendingId} />
                  {m.tools && m.tools.length ? (
                    <div className="oma-tools"><i>Read via</i>{m.tools.map((t, j) => <span className="oma-tool" key={j}>{t}</span>)}</div>
                  ) : null}
                </div>
              </div>
            </div>
          );
        })}

        {busy && (
          <div className="oma-msg">
            <div className="oma-ag">
              <window.OMAgentCharacter state="thinking" size={52} style={{ marginTop: -2 }} />
              <div className="oma-ag-b"><div className="oma-think"><i key={stage}>{stage}</i><em><span /><span /><span /></em></div></div>
            </div>
          </div>
        )}
      </div>

      <div className="oma-ft">
        <div className="oma-in">
          <textarea ref={taRef} rows={1} placeholder={canWrite ? 'Ask, or tell me what to create or change…' : 'Ask about sales, purchases, stock, receivables, rates…'}
            value={input}
            onChange={e => { setInput(e.target.value); e.target.style.height = 'auto'; e.target.style.height = Math.min(e.target.scrollHeight, 96) + 'px'; }}
            onFocus={() => { if (!busy) { clearTimeout(moodRef.current); setFace('listening'); } }}
            onBlur={() => { if (!busy && face === 'listening') setFace('idle'); }}
            onKeyDown={onKey} />
          <button className="oma-send" disabled={busy || !input.trim()} onClick={() => ask(input)} title="Send">{OmaIcon.send}</button>
        </div>
        <div className="oma-ft-note">{canWrite ? 'Answers computed from live ERP records · every create and edit waits for your approval' : 'Read-only · answers computed from live ERP records on this device'}</div>
      </div>
    </div>
  );
}

// ── Dock + mount ────────────────────────────────────────────────────────────
// VISIBILITY is a separate concern from the character's EMOTION: this component
// owns dock/open only, and never touches OMAgentCharacter's state. Collapsing
// the panel cannot reset the face, and a face change cannot move the panel.
//
// Phases exist so the collapse can actually be seen: the panel stays mounted
// through 'closing' and animates toward the dock's corner, and the dock scales
// up out of that same corner — one element handing off to the other, rather
// than two independent things appearing and disappearing.
const OMA_DOCK_KEY = 'omAgentDock';       // 'open' | 'min'  — panel open/closed
const OMA_PREF_KEY = 'omAgentEnabled';    // '1' | '0'       — user preference
const OMA_CLOSE_MS = 380, OMA_OPEN_MS = 60;

// ── Agent preference ────────────────────────────────────────────────────────
// A user-level preference, deliberately kept as a tiny store with a read/write
// pair rather than inline localStorage calls: swapping the body of these two
// functions for a user-preferences API later needs no UI change. Notifies via
// a DOM event so the header toggle and the agent stay in sync without either
// importing the other.
// get() NEVER writes — no default is persisted on read or init, so nothing can
// resurrect the agent by simply reading the preference. set() is the only
// writer and is only ever reached from an explicit user action.
window.OMAgentPref = {
  key: OMA_PREF_KEY,
  get() { try { return localStorage.getItem(OMA_PREF_KEY) !== '0'; } catch (e) { return true; } },  // default ON
  set(on) {
    on = !!on;
    try { localStorage.setItem(OMA_PREF_KEY, on ? '1' : '0'); } catch (e) {}
    window.dispatchEvent(new CustomEvent('om-agent-pref', { detail: { enabled: on } }));
  },
  // Flip from the STORED value, never from a caller's cached copy. A stale
  // React value used to be able to write back the opposite of what the user
  // had just chosen — which silently re-enabled a disabled agent.
  toggle() { const next = !this.get(); this.set(next); return next; },
  // Single subscription point. Both the toggle and the agent derive from this,
  // so the switch and the rendered agent cannot drift apart — including across
  // tabs, where 'storage' is the only signal that arrives.
  subscribe(fn) {
    const self = this;
    const onPref = () => fn(self.get());
    const onStorage = e => { if (e.key === OMA_PREF_KEY) fn(self.get()); };
    window.addEventListener('om-agent-pref', onPref);
    window.addEventListener('storage', onStorage);
    return () => { window.removeEventListener('om-agent-pref', onPref); window.removeEventListener('storage', onStorage); };
  }
};

function ERPAgent() {
  // VISIBILITY (preference) and EMOTION are independent: this flag only decides
  // whether the agent is mounted at all. It never reaches OMAgentCharacter.
  const [enabled, setEnabled] = agSt(() => window.OMAgentPref.get());
  const [justEnabled, setJustEnabled] = agSt(false);
  agEf(() => {
    let t = null;
    const off = window.OMAgentPref.subscribe(on => {
      setEnabled(was => {
        if (on && !was) { setJustEnabled(true); clearTimeout(t); t = setTimeout(() => setJustEnabled(false), 420); }
        return on;
      });
    });
    return () => { off(); clearTimeout(t); };
  }, []);

  const [phase, setPhase] = agSt(() => {
    try {
      // Small screens always start docked: the panel is full-bleed there and
      // would otherwise cover the ERP on arrival.
      if (window.matchMedia('(max-width:600px)').matches) return 'dock';
      return localStorage.getItem(OMA_DOCK_KEY) === 'open' ? 'open' : 'dock';
    } catch (e) { return 'dock'; }
  });
  const [ready, setReady] = agSt(false);
  const phTimer = agRf(null);
  const open = phase === 'open' || phase === 'opening' || phase === 'closing';
  const settled = phase === 'open' || phase === 'opening';   // not mid-collapse
  const mounted = phase !== 'dock';

  // Catching the dock mid-collapse re-expands it: the panel is still mounted,
  // so it simply reverses out of the corner instead of finishing the trip and
  // starting over. Intent beats animation.
  function expand() {
    if (settled) return;
    clearTimeout(phTimer.current);
    try { localStorage.setItem(OMA_DOCK_KEY, 'open'); } catch (e) {}
    setPhase('opening');
    phTimer.current = setTimeout(() => setPhase('open'), OMA_OPEN_MS);
  }
  function minimize() {
    if (!settled) return;
    clearTimeout(phTimer.current);
    try { localStorage.setItem(OMA_DOCK_KEY, 'min'); } catch (e) {}
    // Rapid hide→unhide→hide is safe: each call cancels the pending phase and
    // the newest intent wins; the panel is never left half-mounted.
    let reduce = false;
    try { reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
    if (reduce) { setPhase('dock'); return; }
    setPhase('closing');
    phTimer.current = setTimeout(() => setPhase('dock'), OMA_CLOSE_MS);
  }
  agEf(() => () => clearTimeout(phTimer.current), []);

  // Lazy: the agent stack only initialises once the ERP shell is idle.
  agEf(() => {
    const go = () => setReady(!!(window.ERPAgentTools && window.ERPAgentAdapter && window.ERPData));
    if (window.requestIdleCallback) { const id = window.requestIdleCallback(go, { timeout: 1500 }); return () => window.cancelIdleCallback && window.cancelIdleCallback(id); }
    const t = setTimeout(go, 400); return () => clearTimeout(t);
  }, []);

  agEf(() => {
    function onKey(e) { if (e.key === 'Escape' && open) minimize(); }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open]);

  if (!ready) return null;

  // OFF unmounts everything — no face, no container, no overlay, no click
  // target, no reserved space. The ERP simply has the corner back.
  if (!enabled) return null;

  return (
    <React.Fragment>
      <div className={'oma-dock' + (open ? ' hidden' : '') + (phase === 'closing' ? ' arriving' : '') + (justEnabled ? ' enabling' : '')}
        onClick={expand} title="OM Group AI Agent" role="button" tabIndex={0} aria-label="Open OM Group AI Agent"
        aria-expanded={open} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); expand(); } }}>
        <window.OMAgentCharacter state="idle" size={92} interactive style={{ width: '100%', height: '100%' }} />
      </div>
      {mounted && <div className={'oma-shell' + (phase === 'closing' ? ' closing' : '')}><OMAgentPanel onClose={minimize} /></div>}
    </React.Fragment>
  );
}

window.ERPAgent = ERPAgent;
