/* ═══════════════════════════════════════════════════════════════════
   Al Moasis · Dashboard v3 — wiring layer
   ═══════════════════════════════════════════════════════════════════

   Phase A (visual) was the static shell. Phase B (this file) wires the
   minimum critical data flows so the new look is shippable:

   - Device token bootstrap
   - License check (if missing → redirect to legacy /dashboard/ so the
     existing license activation overlay can prompt the user)
   - Tenant load (populates the right-panel user header)
   - Chat composer + SSE stream → real message bubbles
   - Tab nav switching (other tabs link out to /dashboard/ until they
     get pixel-perfect rewrites of their own — the user keeps WhatsApp /
     Settings / Memory / Operations functionality through the fallback)
   - Lucide icon init

   Everything else (live metrics, real upcoming tasks, real sales chart,
   subagent/cron message poller, license overlay UI in-place) lands in
   subsequent passes.
*/

'use strict';

// ─── Token bootstrap ────────────────────────────────────────────────
let DEVICE_BEARER = 'device';
const LOCAL_TOKEN_KEY = 'muassis.owner_token';
const CUSTOMER_KEY    = 'muassis.web_customer_id';

async function bootstrapToken() {
  const cached = localStorage.getItem(LOCAL_TOKEN_KEY);
  if (cached && cached.length >= 32) DEVICE_BEARER = cached;
  window.DEVICE_BEARER = DEVICE_BEARER; // expose for office.js SSE auth
  try {
    const r = await fetch('/api/auth/local-token');
    const j = await r.json();
    if (j?.success && j?.data?.token) {
      DEVICE_BEARER = j.data.token;
      window.DEVICE_BEARER = DEVICE_BEARER;
      localStorage.setItem(LOCAL_TOKEN_KEY, j.data.token);
      return { ok: true };
    }
    return { ok: false, reason: j?.error || 'no_token' };
  } catch (err) {
    return { ok: false, reason: 'network', error: err?.message };
  }
}

// ─── HTTP helper ────────────────────────────────────────────────────
async function api(path, opts = {}) {
  const headers = { Authorization: `Bearer ${DEVICE_BEARER}`, ...(opts.headers || {}) };
  if (opts.body) {
    if (typeof opts.body !== 'string') opts.body = JSON.stringify(opts.body);
    if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
  }
  const res = await fetch(path, { ...opts, headers });
  const text = await res.text();
  let json;
  try { json = JSON.parse(text); } catch { json = { success: false, error: text }; }
  if (!res.ok) {
    const err = typeof json.error === 'string' ? json.error : `HTTP ${res.status}`;
    throw new Error(err);
  }
  return json;
}

// ─── Lucide icon init ───────────────────────────────────────────────
function applyIcons() {
  if (window.lucide && typeof window.lucide.createIcons === 'function') {
    window.lucide.createIcons({ attrs: { 'stroke-width': 1.75 } });
  }
}

// ─── Tool-name → friendly Arabic label + Lucide icon ────────────────
// The owner sees these in real-time as the agent executes. Owner never
// sees raw tool names like "web_search" or "create_pdf_report" in the
// chat — they see "يبحث في الإنترنت" / "ينشئ تقرير PDF" instead.
const TOOL_FRIENDLY = {
  // Research
  web_search:               { label: 'يبحث في الإنترنت',   icon: 'search' },
  web_fetch:                { label: 'يقرأ صفحة ويب',       icon: 'globe' },
  research_topic:           { label: 'يبحث في موضوع',       icon: 'binoculars' },
  internal_knowledge_search:{ label: 'يفحص الذاكرة',         icon: 'database' },
  recall_fact:              { label: 'يستدعي معلومة',        icon: 'history' },
  recall:                   { label: 'يستدعي ذاكرة',         icon: 'history' },
  recall_conversation:      { label: 'يستدعي محادثة سابقة',  icon: 'message-square' },
  lookup_skill:             { label: 'يبحث عن مهارة',        icon: 'sparkles' },
  learn_from_document:      { label: 'يتعلّم من مستند',       icon: 'book-open' },

  // Browser
  browser_open:             { label: 'يفتح المتصفح',         icon: 'monitor' },
  browser_read:             { label: 'يقرأ الصفحة',           icon: 'eye' },
  browser_click:            { label: 'يضغط على عنصر',          icon: 'mouse-pointer-click' },
  browser_type:             { label: 'يكتب في الصفحة',         icon: 'keyboard' },
  browser_screenshot:       { label: 'يأخذ لقطة شاشة',          icon: 'camera' },
  browser_scroll:           { label: 'يتنقّل في الصفحة',         icon: 'mouse' },
  browser_wait_for:         { label: 'ينتظر تحميل الصفحة',       icon: 'loader' },
  browser_press:            { label: 'يضغط مفتاح',                icon: 'keyboard' },
  browser_upload:           { label: 'يرفع ملفاً',                 icon: 'upload' },
  browser_close:            { label: 'يُغلق المتصفح',                icon: 'x' },
  open_url:                 { label: 'يفتح رابطاً',                  icon: 'external-link' },

  // Creation
  create_excel:             { label: 'ينشئ ملف Excel',         icon: 'sheet' },
  create_pdf_report:        { label: 'ينشئ تقرير PDF',          icon: 'file-text' },
  create_presentation:      { label: 'ينشئ عرضاً تقديمياً',      icon: 'presentation' },
  create_web_artifact:      { label: 'يبني صفحة ويب',           icon: 'code' },
  html_to_pdf:              { label: 'يحوّل HTML إلى PDF',       icon: 'file-output' },
  zatca_invoice:            { label: 'يُصدر فاتورة ZATCA',         icon: 'receipt' },
  arabic_contract_gen:      { label: 'يكتب عقداً عربياً',          icon: 'file-pen' },

  // Files
  read_file:                { label: 'يقرأ ملفاً',                  icon: 'file-input' },
  write_file:               { label: 'يكتب ملفاً',                   icon: 'file-output' },
  list_files:               { label: 'يستعرض المجلد',                 icon: 'folder' },
  delete_file:              { label: 'يحذف ملفاً',                    icon: 'trash-2' },
  open_file:                { label: 'يفتح ملفاً',                    icon: 'external-link' },
  display_result:           { label: 'يعرض النتيجة',                  icon: 'eye' },
  exec_command:             { label: 'ينفّذ أمراً',                    icon: 'terminal' },

  // Memory
  remember:                 { label: 'يحفظ في الذاكرة',                icon: 'save' },
  remember_fact:            { label: 'يحفظ معلومة',                    icon: 'save' },

  // Schedule
  schedule_once:            { label: 'يجدول مهمة',                     icon: 'calendar-clock' },
  schedule_followup:        { label: 'يجدول متابعة',                    icon: 'calendar-clock' },
  schedule_recurring:       { label: 'يجدول مهمة دورية',                 icon: 'repeat' },

  // Communication
  send_email:               { label: 'يرسل بريداً',                      icon: 'mail' },
  send_whatsapp:            { label: 'يرسل عبر واتساب',                   icon: 'message-circle' },

  // Customer
  get_customer:             { label: 'يستعلم عن عميل',                    icon: 'user-search' },
  update_customer:          { label: 'يحدّث بيانات العميل',                 icon: 'user-cog' },

  // Saudi / regulatory
  prayer_times:             { label: 'يتحقق من أوقات الصلاة',              icon: 'compass' },
  hijri_date:               { label: 'يتحقق من التاريخ الهجري',             icon: 'calendar' },
  ramadan_hours:            { label: 'يحسب ساعات رمضان',                    icon: 'moon' },
  saudization_check:        { label: 'يتحقق من السعودة',                     icon: 'shield-check' },
  saudi_post_track:         { label: 'يتتبع الشحنة',                          icon: 'package' },
  saudi_market_research:    { label: 'يبحث السوق السعودي',                    icon: 'trending-up' },
  local_payment_gateway:    { label: 'يجهّز الدفع',                            icon: 'credit-card' },
  saudi_social_listening:   { label: 'يرصد وسائل التواصل',                      icon: 'radio' },
  saudi_weather_alert:      { label: 'يتحقق من الطقس',                          icon: 'cloud-sun' },

  // Skills / autonomy
  skill_evolve:             { label: 'يطوّر مهارة',                              icon: 'sparkles' },
  skill_create:             { label: 'ينشئ مهارة جديدة',                          icon: 'plus-square' },
  skill_install_from_url:   { label: 'يثبّت مهارة',                                icon: 'download' },
  install_skill:            { label: 'يثبّت مهارة',                                icon: 'download' },
  list_tools:               { label: 'يستعرض أدواته',                              icon: 'wrench' },
  plan_complex_task:        { label: 'يخطّط لخطوات المهمة',                          icon: 'list-checks' },
  verify_answer:            { label: 'يتحقق من الإجابة',                            icon: 'check-circle' },

  // System
  get_system_info:          { label: 'يتفقد النظام',                                icon: 'cpu' },
  audit_log:                { label: 'يسجّل عملية للمراجعة',                          icon: 'shield' },
  rbac_check:               { label: 'يتحقق من الصلاحيات',                            icon: 'key' },
  billing_subscription:     { label: 'يدير الاشتراك',                                icon: 'credit-card' },
  analytics_dashboard:      { label: 'يحلّل البيانات',                                icon: 'bar-chart-3' },
  team_handoff:             { label: 'يُسلّم للفريق',                                 icon: 'users' },
  compliance_check:         { label: 'يتحقق من الامتثال',                             icon: 'shield-check' },

  // Multimedia
  analyze_image:            { label: 'يحلل الصورة',                                  icon: 'image' },
  transcribe_audio:         { label: 'ينسخ الصوت',                                    icon: 'mic' },

  // Goals / insights / decisions
  create_goal:              { label: 'ينشئ هدفاً',                                    icon: 'target' },
  update_goal_progress:     { label: 'يحدّث تقدّم الهدف',                                icon: 'gauge' },
  record_insight:           { label: 'يسجّل ملاحظة',                                    icon: 'lightbulb' },
  escalate_decision:        { label: 'يرفع قراراً لك',                                  icon: 'alert-circle' },
  propose_action:           { label: 'يقترح إجراءً',                                    icon: 'send' },
  add_allowed_contact:      { label: 'يوافق على جهة اتصال',                              icon: 'user-check' },

  // API / sub-agents
  call_api:                 { label: 'يستدعي API',                                       icon: 'plug' },
  teach_api:                { label: 'يضيف API',                                          icon: 'plus' },
  spawn_subagent:           { label: 'يستدعي وكيلاً فرعياً',                                  icon: 'users' },
};

function friendlyTool(name) {
  return TOOL_FRIENDLY[name] || { label: name, icon: 'cog' };
}

// ─── License gate ───────────────────────────────────────────────────
// If the license isn't active, bounce to /dashboard/ where the existing
// license activation overlay lives. Keeps the new look from masking the
// activation requirement (would have been a way to bypass the paywall).
async function checkLicenseOrBounce() {
  try {
    let r = await api('/api/license/status');
    // offline_grace + read_only have ok:true/false respectively; the server
    // already returns ok=true when serving is allowed. Only show the modal
    // when ok is false. If state is offline_grace and a fresh heartbeat
    // succeeds, re-check before deciding — covers cold-start where the
    // first heartbeat hasn't run yet.
    if (r && r.state === 'offline_grace') {
      try {
        await api('/api/license/heartbeat', { method: 'POST' });
        r = await api('/api/license/status');
      } catch { /* offline; keep grace state */ }
    }
    if (!r?.ok) {
      showLicenseOverlay(r?.message || 'يحتاج إلى تفعيل. الصق مفتاح الترخيص.');
      return false;
    }
    return true;
  } catch {
    showLicenseOverlay('تعذّر التحقق من الترخيص. تأكد من تشغيل المؤسس.');
    return false;
  }
}

// ─── License overlay (v3 modal) ────────────────────────────────────
function showLicenseOverlay(messageText) {
  const el = document.getElementById('license-overlay');
  if (!el) return;
  el.classList.remove('hidden');
  const err = document.querySelector('[data-bind="license-overlay-error"]');
  if (err && messageText) { err.textContent = messageText; err.classList.remove('hidden'); }
}
function hideLicenseOverlay() {
  const el = document.getElementById('license-overlay');
  if (el) el.classList.add('hidden');
}
async function activateLicense() {
  const input = document.getElementById('license-key-input');
  const err   = document.querySelector('[data-bind="license-overlay-error"]');
  const lbl   = document.querySelector('[data-bind="license-activate-label"]');
  const key   = (input?.value || '').trim().toUpperCase();
  if (!/^ALMOASIS(-[A-Z0-9]{4}){4}$/.test(key)) {
    if (err) { err.textContent = 'صيغة المفتاح غير صحيحة (ALMOASIS-XXXX-XXXX-XXXX-XXXX)'; err.classList.remove('hidden'); }
    return;
  }
  if (err) err.classList.add('hidden');
  if (lbl) lbl.textContent = 'جارٍ التفعيل…';
  try {
    const r = await api('/api/license/activate', {
      method: 'POST',
      body: JSON.stringify({ license_key: key }),
    });
    if (r?.ok) {
      hideLicenseOverlay();
      window.location.reload();
    } else {
      if (err) { err.textContent = r?.error || 'فشل التفعيل. تحقق من المفتاح.'; err.classList.remove('hidden'); }
      if (lbl) lbl.textContent = 'تفعيل';
    }
  } catch (e) {
    if (err) { err.textContent = `فشل: ${e.message}`; err.classList.remove('hidden'); }
    if (lbl) lbl.textContent = 'تفعيل';
  }
}

// ─── Onboarding wizard (v3 modal) ──────────────────────────────────
let ONB_STEP = 1;
let ONB_INDUSTRIES = [];

async function maybeShowOnboarding() {
  // Only show if tenant has no business_name yet (first-launch state).
  if (!CURRENT_TENANT || CURRENT_TENANT.business_name) return;
  const el = document.getElementById('onboarding-overlay');
  if (!el) return;
  el.classList.remove('hidden');
  // Pre-fill industries dropdown
  try {
    const r = await fetch('/api/onboarding/industries').then((x) => x.json());
    ONB_INDUSTRIES = r?.data || r?.industries || r || [];
    const sel = document.getElementById('onb-industry');
    if (sel && Array.isArray(ONB_INDUSTRIES)) {
      sel.innerHTML = '<option value="">اختر القطاع</option>' +
        ONB_INDUSTRIES.map((i) => {
          const id = i.id || i.key || i;
          const label = i.label_ar || i.label || i.name || id;
          return `<option value="${escapeHtml(id)}">${escapeHtml(label)}</option>`;
        }).join('');
      sel.addEventListener('change', () => {
        const wrap = document.querySelector('[data-bind="onb-custom-wrap"]');
        if (wrap) wrap.classList.toggle('hidden', sel.value !== 'other');
      });
    }
  } catch { /* ignore */ }
  renderOnbStep();
}

function renderOnbStep() {
  document.querySelectorAll('[data-onb-step]').forEach((s) => {
    s.classList.toggle('hidden', s.dataset.onbStep !== String(ONB_STEP));
  });
  const prog = document.querySelector('[data-bind="onb-progress"]');
  const step = document.querySelector('[data-bind="onb-step"]');
  const back = document.querySelector('[data-action="onb-back"]');
  const nextLbl = document.querySelector('[data-bind="onb-next-label"]');
  if (prog) prog.style.width = `${(ONB_STEP / 3) * 100}%`;
  if (step) step.textContent = String(ONB_STEP);
  if (back) back.classList.toggle('hidden', ONB_STEP === 1);
  if (nextLbl) nextLbl.textContent = ONB_STEP === 3 ? 'ابدأ الآن' : 'التالي';
}

function validateOnbStep() {
  const err = document.querySelector('[data-bind="onb-error"]');
  const clearErr = () => err?.classList.add('hidden');
  const setErr = (m) => { if (err) { err.textContent = m; err.classList.remove('hidden'); } };
  if (ONB_STEP === 1) {
    if (!document.getElementById('onb-name-ar')?.value.trim()) { setErr('اسم الشركة بالعربي مطلوب'); return false; }
    if (!document.getElementById('onb-name-en')?.value.trim()) { setErr('Business name (Latin) required'); return false; }
    if (!document.getElementById('onb-agent-name')?.value.trim()) { setErr('اختر اسماً للمؤسس (الموظف الذكي)'); return false; }
  } else if (ONB_STEP === 2) {
    if (!document.getElementById('onb-industry')?.value) { setErr('اختر القطاع'); return false; }
    if (!document.getElementById('onb-city')?.value.trim()) { setErr('اكتب المدينة'); return false; }
    if (document.getElementById('onb-industry')?.value === 'other'
        && !document.getElementById('onb-custom')?.value.trim()) { setErr('وصف النشاط مطلوب لخيار "أخرى"'); return false; }
  } else if (ONB_STEP === 3) {
    const phone = document.getElementById('onb-phone')?.value.trim() || '';
    if (!/^\+?\d{10,15}$/.test(phone)) { setErr('رقم واتساب بصيغة دولية مطلوب (+966…)'); return false; }
  }
  clearErr();
  return true;
}

async function onbNext() {
  if (!validateOnbStep()) return;
  if (ONB_STEP < 3) { ONB_STEP++; renderOnbStep(); return; }
  // Step 3 → submit
  const payload = {
    business_name:    document.getElementById('onb-name-en').value.trim(),
    business_name_ar: document.getElementById('onb-name-ar').value.trim(),
    agent_name:       document.getElementById('onb-agent-name').value.trim(),
    industry:         document.getElementById('onb-industry').value,
    city:             document.getElementById('onb-city').value.trim(),
    language:         document.getElementById('onb-lang').value,
    whatsapp_phone:   document.getElementById('onb-phone').value.trim(),
    custom_description: document.getElementById('onb-custom')?.value?.trim() || undefined,
  };
  const lbl = document.querySelector('[data-bind="onb-next-label"]');
  if (lbl) lbl.textContent = 'جارٍ الإعداد…';
  try {
    await api('/api/onboarding/start', { method: 'POST', body: JSON.stringify(payload) });
    document.getElementById('onboarding-overlay')?.classList.add('hidden');
    window.location.reload();
  } catch (e) {
    const err = document.querySelector('[data-bind="onb-error"]');
    if (err) { err.textContent = `فشل: ${e.message}`; err.classList.remove('hidden'); }
    if (lbl) lbl.textContent = ONB_STEP === 3 ? 'ابدأ الآن' : 'التالي';
  }
}

function onbBack() {
  if (ONB_STEP > 1) { ONB_STEP--; renderOnbStep(); }
}

// Wire modal actions (document-level so they work even when modals re-render)
document.addEventListener('click', (e) => {
  if (e.target.closest?.('[data-action="license-activate"]')) activateLicense();
  if (e.target.closest?.('[data-action="onb-next"]')) onbNext();
  if (e.target.closest?.('[data-action="onb-back"]')) onbBack();
  if (e.target.closest?.('[data-action="attach"]')) openAttachPicker();
  if (e.target.closest?.('[data-action="mic"]')) toggleMic();
});

// ─── Attach (file upload) ───────────────────────────────────────────
function openAttachPicker() {
  const input = document.getElementById('attach-input');
  if (input) input.click();
}

async function uploadFileToTenant(file) {
  const tenantId = window.CURRENT_TENANT_ID;
  if (!tenantId) throw new Error('No tenant configured');
  const fd = new FormData();
  fd.append('file', file, file.name);
  const res = await fetch(`/api/onboarding/${tenantId}/upload`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${DEVICE_BEARER}` },
    body: fd,
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok || !json?.success) {
    throw new Error(json?.error || `HTTP ${res.status}`);
  }
  return json.data; // { filename, path, absolute_path, size }
}

async function handleAttachedFiles(files) {
  for (const f of files) {
    try {
      const result = await uploadFileToTenant(f);
      const announce = `أرفقت ملفاً: ${result.filename}\nالمسار: ${result.absolute_path}`;
      await streamChat(announce);
    } catch (err) {
      appendBubble('assistant', `(فشل رفع ${f.name}: ${err.message})`);
    }
  }
}

document.addEventListener('change', async (e) => {
  if (e.target?.id !== 'attach-input') return;
  const files = Array.from(e.target.files || []);
  if (files.length === 0) return;
  e.target.value = ''; // allow re-attaching same file
  await handleAttachedFiles(files);
});

// ─── Drag & drop files anywhere in the chat pane ────────────────────
// Targets the chat section so users can drop screenshots, PDFs, docs
// directly onto the conversation. Visual ring-2 indicator while a drag
// is hovering. Suppresses the browser default of navigating to the file.
function wireChatDragDrop() {
  const target = document.querySelector('[data-pane="chat"]') || document.body;
  let depth = 0;
  const showOverlay = (on) => {
    target.classList.toggle('ring-2', on);
    target.classList.toggle('ring-emerald-400', on);
    target.classList.toggle('ring-inset', on);
  };
  ['dragenter', 'dragover'].forEach((ev) => {
    target.addEventListener(ev, (e) => {
      if (!e.dataTransfer || !Array.from(e.dataTransfer.types || []).includes('Files')) return;
      e.preventDefault();
      e.dataTransfer.dropEffect = 'copy';
      if (ev === 'dragenter') { depth++; showOverlay(true); }
    });
  });
  target.addEventListener('dragleave', () => {
    depth = Math.max(0, depth - 1);
    if (depth === 0) showOverlay(false);
  });
  target.addEventListener('drop', async (e) => {
    if (!e.dataTransfer) return;
    const files = Array.from(e.dataTransfer.files || []);
    if (files.length === 0) return;
    e.preventDefault();
    depth = 0;
    showOverlay(false);
    await handleAttachedFiles(files);
  });
  // Window-level fallback: if a file is dropped OUTSIDE the chat pane the
  // browser would otherwise navigate to it. Catch and route it to upload.
  window.addEventListener('dragover', (e) => {
    if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) e.preventDefault();
  });
  window.addEventListener('drop', async (e) => {
    if (!e.dataTransfer) return;
    const files = Array.from(e.dataTransfer.files || []);
    if (files.length === 0) return;
    e.preventDefault();
    await handleAttachedFiles(files);
  });
}
document.addEventListener('DOMContentLoaded', wireChatDragDrop);
if (document.readyState !== 'loading') wireChatDragDrop();

// ─── Speech-to-Text (mic) ───────────────────────────────────────────
// MediaRecorder → blob → POST /api/voice/transcribe (Whisper). Works in
// Tauri's WKWebView (browser-native SpeechRecognition does not — it requires
// macOS entitlements the bundled app doesn't declare). The recognized text
// is dropped into the composer for review before send — never auto-sent.
// Tap to start; tap again to stop + transcribe.
let MIC_RECORDER = null;
let MIC_CHUNKS = [];
let MIC_STREAM = null;
async function toggleMic() {
  const micBtn = document.querySelector('[data-action="mic"]');
  const input = document.getElementById('chat-input');
  if (MIC_RECORDER && MIC_RECORDER.state === 'recording') {
    MIC_RECORDER.stop();
    return;
  }
  if (!navigator.mediaDevices || !window.MediaRecorder) {
    appendBubble('assistant', '(الميكروفون غير مدعوم في هذا المتصفح. أضف OPENAI_API_KEY ثم استخدم الميكروفون من جوالك بدلاً.)');
    return;
  }
  try {
    MIC_STREAM = await navigator.mediaDevices.getUserMedia({ audio: true });
  } catch (err) {
    const msg = (err && err.message) || String(err);
    // "Invalid constraint" / NotAllowedError in WKWebView means Tauri's
    // webview host hasn't hooked the macOS media-capture delegate, so the
    // OS never sees a permission request and getUserMedia is gated. The
    // Info.plist entitlement alone can't fix this — needs a rebuild that
    // implements WKUIDelegate.requestMediaCapturePermissionFor. Until then,
    // surface the macOS system-dictation workaround that works in any text
    // field, no rebuild needed.
    const isConstraintBlocked = /invalid constraint|not(\s|-)allowed|notallowed|permission/i.test(msg);
    if (isConstraintBlocked) {
      appendBubble('assistant',
        'الميكروفون داخل المؤسس مغلق على مستوى Tauri WebView (يحتاج إعادة بناء التطبيق لتفعيله). ' +
        '**الحل الفوري:** اضغط مرّتين سريعاً على زرّ 🌐 (Globe) أو زرّ Fn ثم تكلّم — إملاء macOS الرسمي يكتب مباشرة في صندوق الرسالة. ' +
        'لتفعيل العربية: System Settings → Keyboard → Dictation → Languages → أضف Arabic.\n\n' +
        `(تفاصيل تقنية: ${msg})`,
      );
    } else {
      appendBubble('assistant', `(تعذّر الوصول للميكروفون: ${msg}. جرّب إملاء macOS: اضغط Fn مرّتين.)`);
    }
    resetMicBtn();
    return;
  }
  // Prefer mime types Whisper handles reliably. Tauri WKWebView typically
  // produces audio/mp4 (AAC) by default; Chromium WebViews produce webm/opus.
  // Both round-trip cleanly through Whisper.
  let mimeType = '';
  for (const mt of ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus']) {
    if (window.MediaRecorder.isTypeSupported?.(mt)) { mimeType = mt; break; }
  }
  try {
    MIC_RECORDER = mimeType ? new MediaRecorder(MIC_STREAM, { mimeType }) : new MediaRecorder(MIC_STREAM);
  } catch (err) {
    appendBubble('assistant', `(تعذّر بدء التسجيل: ${err.message})`);
    MIC_STREAM.getTracks().forEach((t) => t.stop());
    MIC_STREAM = null;
    return;
  }
  MIC_CHUNKS = [];
  MIC_RECORDER.ondataavailable = (e) => { if (e.data && e.data.size > 0) MIC_CHUNKS.push(e.data); };
  MIC_RECORDER.onstop = async () => {
    if (micBtn) {
      micBtn.dataset.state = 'transcribing';
      micBtn.classList.remove('text-rose-600');
      micBtn.classList.add('text-muted', 'animate-pulse');
    }
    try { MIC_STREAM?.getTracks().forEach((t) => t.stop()); } catch { /* ignore */ }
    MIC_STREAM = null;
    const blob = new Blob(MIC_CHUNKS, { type: MIC_RECORDER?.mimeType || 'audio/webm' });
    MIC_RECORDER = null;
    if (blob.size < 1000) {
      // Too short — likely an accidental tap. Don't burn an API call.
      resetMicBtn();
      return;
    }
    try {
      const fd = new FormData();
      const ext = (blob.type.split('/')[1] || 'webm').split(';')[0];
      fd.append('file', blob, `recording.${ext}`);
      const res = await fetch('/api/voice/transcribe?lang=ar', {
        method: 'POST',
        headers: { Authorization: `Bearer ${DEVICE_BEARER}` },
        body: fd,
      });
      const json = await res.json().catch(() => ({}));
      if (res.ok && json?.success && json.data?.text) {
        const text = json.data.text.trim();
        if (input) {
          input.value = (input.value ? input.value + ' ' : '') + text;
          input.dispatchEvent(new Event('input'));
          input.focus();
        }
      } else {
        appendBubble('assistant', `(فشل تحويل الصوت: ${json?.error || `HTTP ${res.status}`})`);
      }
    } catch (err) {
      appendBubble('assistant', `(خطأ في الشبكة أثناء التحويل: ${err.message})`);
    } finally {
      resetMicBtn();
    }
  };
  try {
    MIC_RECORDER.start();
    if (micBtn) {
      micBtn.dataset.state = 'recording';
      micBtn.classList.add('text-rose-600');
      micBtn.classList.remove('text-muted');
    }
  } catch (err) {
    appendBubble('assistant', `(فشل تشغيل المسجل: ${err.message})`);
    resetMicBtn();
  }
}
function resetMicBtn() {
  const micBtn = document.querySelector('[data-action="mic"]');
  if (micBtn) {
    micBtn.dataset.state = 'idle';
    micBtn.classList.remove('text-rose-600', 'animate-pulse');
    micBtn.classList.add('text-muted');
  }
}

// ─── Tenant load ────────────────────────────────────────────────────
let CURRENT_TENANT = null;
async function loadTenant() {
  try {
    const r = await api('/api/auth/my-tenant');
    CURRENT_TENANT = r.data;
    if (!CURRENT_TENANT) return;
    // Expose for other v3 modules (whatsapp.js, future tabs)
    window.CURRENT_TENANT = CURRENT_TENANT;
    window.CURRENT_TENANT_ID = CURRENT_TENANT.id;

    // Owner-name selection. Reject the literal placeholder "Owner" the
    // onboarding template leaves behind. Prefer (in order):
    //   1. real owner_name (not "Owner")
    //   2. Arabic business name
    //   3. Latin business name
    const raw = (CURRENT_TENANT.owner_name || '').trim();
    const isPlaceholder = /^(owner|user|admin|test|placeholder)$/i.test(raw);
    const displayName = (!isPlaceholder && raw)
      || CURRENT_TENANT.business_name_ar
      || CURRENT_TENANT.business_name
      || 'مرحباً';

    // Role: ALWAYS "المالك" — this is the owner side of the app.
    // The business name is already prominent elsewhere.
    const role = 'المالك';

    const initial = displayName.replace(/^(شركة|مؤسسة|al |the )\s*/i, '').trim().charAt(0) || 'م';

    const nameEl = document.querySelector('[data-bind="owner-name"]');
    const roleEl = document.querySelector('[data-bind="owner-role"]');
    const avEl   = document.querySelector('[data-bind="owner-avatar"]');
    if (nameEl) nameEl.textContent = displayName;
    if (roleEl) roleEl.textContent = role;
    if (avEl)   avEl.textContent   = initial;
  } catch (err) {
    console.warn('loadTenant failed:', err.message);
  }
}

// ─── Chat history loader ────────────────────────────────────────────
// The agent's memory wasn't lost; this just surfaces the past
// conversation back into the new UI so the owner sees continuity.
// Polls /api/chat/recent with since=epoch-start to get the full thread.
// Tracks message IDs we've already rendered so the same DB row never paints
// twice. Populated by history load + future incremental fetches.
const RENDERED_MSG_IDS = new Set();
// High-water cursor for the incremental background-message poll. Advanced to
// the server's clock after each fetch so we never miss or re-fetch a row.
let CHAT_POLL_SINCE = null;
let CHAT_POLL_TIMER = null;

async function loadChatHistory() {
  try {
    const r = await api('/api/chat/recent?direction=both&since=1970-01-01T00:00:00Z&limit=200');
    // Seed the live-poll cursor from the server clock so the poller only
    // picks up messages created AFTER this bootstrap.
    CHAT_POLL_SINCE = r?.data?.server_time || new Date().toISOString();
    // Pin the dashboard to whatever thread the server resolved as primary.
    // Without this, the reader and writer can drift: history loads from the
    // high-tally thread, but localStorage's stale customer_id sends new
    // messages to a different (empty) thread — symptom: "chat reset out of
    // nowhere, my messages don't appear in history".
    if (r?.data?.customer_id) {
      localStorage.setItem(CUSTOMER_KEY, r.data.customer_id);
    }
    const msgs = r?.data?.messages || [];
    if (msgs.length === 0) return;
    const empty = document.getElementById('chat-empty');
    if (empty) empty.remove();
    for (const m of msgs) {
      if (m.id && RENDERED_MSG_IDS.has(m.id)) continue;
      if (m.id) RENDERED_MSG_IDS.add(m.id);
      const role = m.direction === 'inbound' ? 'user' : 'assistant';
      appendBubbleRaw(role, m.content || '', m.created_at, m.source);
    }
    const stream = getStream();
    if (stream) stream.scrollTop = stream.scrollHeight;
  } catch (err) {
    console.warn('loadChatHistory failed:', err.message);
  }
}

// ─── Live background-message poll ───────────────────────────────────
// Every 8s, pull outbound messages emitted since the cursor — cron firings,
// subagent results, daily reports — and append them to the open chat in
// real time. Direct agent replies (source 'chat') are skipped here because
// the streaming send path already rendered them; appending again would
// double-paint. This is what makes a cron's output appear WHILE the owner
// watches, instead of only after they reopen the app.
async function pollBackgroundMessages() {
  if (!CHAT_POLL_SINCE) return;
  try {
    const r = await api(`/api/chat/recent?direction=outbound&since=${encodeURIComponent(CHAT_POLL_SINCE)}&limit=50`);
    const msgs = r?.data?.messages || [];
    // Advance the cursor even when nothing new arrived.
    CHAT_POLL_SINCE = r?.data?.server_time || CHAT_POLL_SINCE;
    let appended = false;
    for (const m of msgs) {
      if (m.id && RENDERED_MSG_IDS.has(m.id)) continue;
      if (m.id) RENDERED_MSG_IDS.add(m.id);
      if (m.source === 'chat') continue; // already shown live by the stream
      const empty = document.getElementById('chat-empty');
      if (empty) empty.remove();
      appendBubbleRaw('assistant', m.content || '', m.created_at, m.source);
      appended = true;
    }
    if (appended) {
      const stream = getStream();
      if (stream) stream.scrollTop = stream.scrollHeight;
    }
  } catch { /* transient — try again next tick */ }
}

function startBackgroundMessagePoll() {
  if (CHAT_POLL_TIMER) clearInterval(CHAT_POLL_TIMER);
  CHAT_POLL_TIMER = setInterval(pollBackgroundMessages, 8000);
}

// ─── Real chat suggestions ──────────────────────────────────────────
// Replaces the v3.0.0 hardcoded "follow up with customer" ghost chips
// with REAL items from /api/insights/suggestions. Each suggestion
// references a real customer, escalation, or goal in the DB.
async function loadChatSuggestions() {
  const root = document.querySelector('[data-bind="chat-suggestions"]');
  if (!root) return;

  // Icon mapping for suggestion priorities/categories
  const ICON_BY_ID = {
    esc:     'alert-circle',
    goal:    'target',
    cust:    'user-round',
    explore: 'sparkles',
  };

  try {
    const r = await api('/api/insights/suggestions');
    const items = r?.data || [];
    if (!Array.isArray(items) || items.length === 0) {
      // Real empty-state fallback: only the safe defaults (no fake customers)
      root.innerHTML = `
        <button class="chip" data-suggest="أنشئ فاتورة ضريبية جديدة">
          <i data-lucide="file-text" class="w-4 h-4 text-body"></i><span>فاتورة جديدة</span>
        </button>
        <button class="chip" data-suggest="اكتب لي عقداً عربياً">
          <i data-lucide="file-pen" class="w-4 h-4 text-body"></i><span>عقد جديد</span>
        </button>`;
      applyIcons();
      wireSuggestionClicks();
      return;
    }
    root.innerHTML = items.map((s) => {
      const kind = (s.id || '').split(':')[0];
      const icon = ICON_BY_ID[kind] || 'sparkles';
      // Build the chat message that fires when this chip is clicked.
      // For real entities (customer / escalation / goal), the agent gets
      // both the human prompt AND the entity id so it can pull context.
      let promptMsg;
      if (kind === 'cust') {
        promptMsg = `${s.title}. اقترح لي رسالة متابعة قصيرة، ثم أرسلها لو وافقت.`;
      } else if (kind === 'esc') {
        promptMsg = `لخّص لي قرار "${s.title}" واقترح موقفاً.`;
      } else if (kind === 'goal') {
        promptMsg = `حدّث تقدّم هدف "${s.title}".`;
      } else {
        promptMsg = s.title;
      }
      const labelText = String(s.title).length > 28 ? String(s.title).slice(0, 26) + '…' : s.title;
      return `
        <button class="chip" data-suggest="${escapeHtml(promptMsg)}" title="${escapeHtml(s.detail || '')}">
          <i data-lucide="${icon}" class="w-4 h-4 text-body"></i>
          <span>${escapeHtml(labelText)}</span>
        </button>`;
    }).join('');
    applyIcons();
    wireSuggestionClicks();
  } catch {
    // Endpoint missing / 5xx — show only safe static defaults (no fake customer chips)
    root.innerHTML = `
      <button class="chip" data-suggest="أنشئ فاتورة ضريبية جديدة">
        <i data-lucide="file-text" class="w-4 h-4 text-body"></i><span>فاتورة جديدة</span>
      </button>
      <button class="chip" data-suggest="اكتب لي عقداً عربياً">
        <i data-lucide="file-pen" class="w-4 h-4 text-body"></i><span>عقد جديد</span>
      </button>`;
    applyIcons();
    wireSuggestionClicks();
  }
}

function wireSuggestionClicks() {
  const input = document.getElementById('chat-input');
  document.querySelectorAll('[data-suggest]').forEach((chip) => {
    // De-dup listeners: replace node to drop any previous binding
    const fresh = chip.cloneNode(true);
    chip.parentNode.replaceChild(fresh, chip);
    fresh.addEventListener('click', () => {
      if (!input) return;
      input.value = fresh.dataset.suggest || '';
      input.focus();
      input.setSelectionRange(input.value.length, input.value.length);
    });
  });
}

// ─── Right-panel metrics ────────────────────────────────────────────
// Live numbers from /api/insights/* — only fill bindings when real data
// exists; empty states stay otherwise. NO computed deltas that produce
// "-100% from yesterday" garbage — the endpoint returns deltas if real.
async function loadRightPanelMetrics() {
  const set = (key, val) => { const el = document.querySelector(`[data-bind="${key}"]`); if (el && val != null) el.textContent = val; };

  // Overview metrics
  try {
    const r = await api('/api/insights/overview');
    const d = r?.data || r || {};
    if (typeof d.active_conversations === 'number') set('metric-conv', d.active_conversations);
    if (typeof d.tasks_in_progress === 'number') set('metric-tasks', d.tasks_in_progress);
    if (typeof d.transactions_today === 'number') set('metric-tx', d.transactions_today);
    const fmtDelta = (current, prev) => {
      if (typeof prev !== 'number' || prev === 0) return null;
      const pct = Math.round(((current - prev) / prev) * 100);
      return `${pct >= 0 ? '+' : ''}${pct}% من الأمس`;
    };
    if (typeof d.active_conversations_yesterday === 'number') {
      const v = fmtDelta(d.active_conversations || 0, d.active_conversations_yesterday);
      if (v) set('metric-conv-delta', v);
    }
    if (typeof d.tasks_in_progress_yesterday === 'number') {
      const v = fmtDelta(d.tasks_in_progress || 0, d.tasks_in_progress_yesterday);
      if (v) set('metric-tasks-delta', v);
    }
    if (typeof d.transactions_today_yesterday === 'number') {
      const v = fmtDelta(d.transactions_today || 0, d.transactions_today_yesterday);
      if (v) set('metric-tx-delta', v);
    }
  } catch { /* silent — empty states stay */ }

  // Real ZATCA-tied sales (this month). NO fake numbers — count is zero
  // until the first real zatca_invoice tool call succeeds.
  try {
    const r = await api('/api/insights/sales?range=month');
    const d = r?.data || {};
    if (typeof d.total_sar === 'number' && d.total_sar > 0) {
      set('sales-total', d.total_sar.toLocaleString('ar-SA'));
      // Optional: render a tiny bar chart from by_day if present
      const chartHost = document.querySelector('[data-bind="sales-chart-empty"]');
      if (chartHost && d.by_day && Object.keys(d.by_day).length > 0) {
        const days = Object.entries(d.by_day).sort(([a], [b]) => a.localeCompare(b));
        const max = Math.max(...days.map(([, v]) => v.sar));
        chartHost.innerHTML = `
          <div class="flex items-end gap-1.5 h-full w-full">
            ${days.slice(-12).map(([day, v]) => {
              const h = max > 0 ? Math.max(8, (v.sar / max) * 100) : 8;
              return `<div class="flex-1 bg-emerald-500 rounded-t" style="height:${h}%" title="${day}: ${v.sar.toFixed(2)} ر.س (${v.count})"></div>`;
            }).join('')}
          </div>`;
      }
    }
    if (typeof d.invoices_issued === 'number' && d.invoices_issued > 0) {
      set('sales-delta', `${d.invoices_issued} فاتورة ZATCA هذا الشهر`);
    }
  } catch { /* silent — empty state stays */ }
}

// ─── Upcoming scheduled tasks (المهام القادمة) ──────────────────────
// Shows the owner's own scheduled jobs (one-shots + recurring they asked
// for) with a human "next run" time. Internal autonomy ticks (heartbeat,
// housekeeping, etc.) are hidden — they're plumbing, not the owner's tasks.
// Wires the previously-static placeholder to real /api/cron/jobs data so a
// scheduled cron is actually visible somewhere, not just in chat when it fires.
const INTERNAL_CRON_NAMES = new Set([
  'heartbeat','housekeeping','telemetry','audit-verify','delivery-health','reflection',
  'goal-pursuit','proactive-actions','research-cycle','skill-discovery','daily-scorecard',
  'skill-library-review','compliance-radar','industry-trend-scan','business-health-check',
  'customer-segment-analysis','customer-followup-suggestions','overdue-invoice-scan',
  'hot-lead-followup','agenda-checkin','vendor-pricing-changes','seo-position-check',
  'competitor-news-scan','tadawul-open','tadawul-close','fajr-reminder','asr-reminder',
]);

// Clean Arabic labels for the recurring digests that surface here.
const CRON_LABELS_AR = {
  'morning-brief': 'النشرة الصباحية',
  'evening-recap': 'ملخص المساء',
  'weekly-review': 'المراجعة الأسبوعية',
  'ai-intelligence-brief': 'نشرة أخبار الذكاء الاصطناعي',
};

const AR_DAYS = ['الأحد','الإثنين','الثلاثاء','الأربعاء','الخميس','الجمعة','السبت'];

// Format an hour:minute (24h) as Arabic 12h with ص/م. 8,0 → "٨:٠٠ ص".
function fmtClockAr(h, m) {
  const period = h < 12 ? 'ص' : 'م';
  let h12 = h % 12; if (h12 === 0) h12 = 12;
  return `${h12}:${String(m).padStart(2, '0')} ${period}`;
}

// Returns a human "when" string, or null if this job should NOT appear in the
// upcoming list (e.g. a one-shot that already fired). A one-shot is a cron
// with explicit day-of-month + month (no '*'). Recurring next_run_at is stale
// in the DB, so we derive recurring text from the pattern itself.
function cronWhenOrNull(job) {
  const sched = String(job.schedule || '').trim();
  const p = sched.split(/\s+/);
  if (p.length !== 5) return sched || null;
  const [min, hr, dom, mon, dow] = p;
  const isOneShot = dom !== '*' && mon !== '*' && !hr.includes('*') && !hr.includes(',');

  if (isOneShot) {
    // Already ran, or its time is in the past → not upcoming.
    if (Number(job.run_count) >= 1) return null;
    const t = job.next_run_at ? new Date(job.next_run_at) : null;
    if (!t || isNaN(t) || t.getTime() <= Date.now()) return null;
    const diffMin = Math.round((t.getTime() - Date.now()) / 60000);
    if (diffMin < 60) return diffMin <= 1 ? 'خلال دقيقة' : `خلال ${diffMin} دقيقة`;
    const sameDay = t.toDateString() === new Date().toDateString();
    return `${sameDay ? 'اليوم' : t.toLocaleDateString('ar-SA', { weekday: 'long' })} ${fmtClockAr(t.getHours(), t.getMinutes())}`;
  }

  // Recurring patterns.
  if (sched.startsWith('*/')) return `كل ${min.replace('*/', '')} دقيقة`;
  const H = parseInt(hr, 10), M = parseInt(min, 10);
  if (!isNaN(H) && !isNaN(M)) {
    if (dow === '*') return `يومياً ${fmtClockAr(H, M)}`;
    const dayNum = parseInt(dow, 10);
    if (!isNaN(dayNum) && AR_DAYS[dayNum]) return `كل ${AR_DAYS[dayNum]} ${fmtClockAr(H, M)}`;
    return `أسبوعياً ${fmtClockAr(H, M)}`;
  }
  return sched;
}

function upcomingLabel(job) {
  if (CRON_LABELS_AR[job.name]) return CRON_LABELS_AR[job.name];
  try {
    const p = typeof job.payload === 'string' ? JSON.parse(job.payload) : job.payload;
    const msg = p && (p.message || p.text);
    if (msg) return String(msg).replace(/\s+/g, ' ').trim().slice(0, 50);
  } catch { /* fall through */ }
  return job.name || 'مهمة مجدولة';
}

async function loadUpcomingTasks() {
  const root = document.querySelector('[data-bind="upcoming-list"]');
  if (!root) return;
  try {
    const r = await api('/api/cron/jobs');
    const items = [];
    for (const j of (r?.jobs || [])) {
      if (!j.enabled || INTERNAL_CRON_NAMES.has(j.name)) continue;
      const when = cronWhenOrNull(j);
      if (!when) continue; // fired one-shot / not upcoming
      items.push({ label: upcomingLabel(j), when });
    }
    if (items.length === 0) {
      root.innerHTML = `<div class="px-3 py-6 text-center">
        <div class="text-[12.5px] text-muted">لا توجد مهام مجدولة</div>
        <div class="text-[11px] text-muted mt-1">اطلب من المؤسس جدولة مهمة من الدردشة</div>
      </div>`;
      return;
    }
    root.innerHTML = items.slice(0, 6).map((it) => `
      <div class="px-3 py-2.5 flex items-center justify-between gap-3 border-b border-soft last:border-0">
        <div class="min-w-0 flex-1">
          <div class="text-[12.5px] text-ink truncate" dir="auto">${escapeHtml(it.label)}</div>
        </div>
        <div class="text-[11px] text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded-full shrink-0 whitespace-nowrap">${escapeHtml(it.when)}</div>
      </div>`).join('');
  } catch { /* leave placeholder on error */ }
}

function appendBubbleRaw(role, text, isoTime, source) {
  const stream = getStream();
  if (!stream) return null;
  const empty = document.getElementById('chat-empty');
  if (empty) empty.remove();
  const ts = isoTime ? new Date(isoTime).toLocaleTimeString('ar-SA', { hour: '2-digit', minute: '2-digit', hour12: true }) : nowAr();
  const sourceBadge = source && source !== 'chat' ? `
    <span class="inline-block ml-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-50 text-emerald-700">
      ${source === 'subagent' ? '🤖 وكيل فرعي' : source === 'cron' ? '⏰ مهمة مجدولة' : source === 'daily_report' ? '📊 تقرير يومي' : source}
    </span>` : '';

  if (role === 'user') {
    const userInitial = (CURRENT_TENANT?.owner_name || 'أ').trim().charAt(0);
    const row = document.createElement('div');
    row.className = 'flex flex-row-reverse gap-3 max-w-[80%] mr-auto mb-3';
    row.innerHTML = `
      <div class="msg-avatar bg-emerald-100 text-emerald-700 shrink-0">${escapeHtml(userInitial)}</div>
      <div class="flex flex-col items-end min-w-0">
        <div class="bubble bubble-user"></div>
        <div class="text-[11px] text-muted mt-1.5">${ts}</div>
      </div>`;
    row.querySelector('.bubble').textContent = text;
    stream.appendChild(row);
    return row;
  }

  // For assistant messages, the source can come from a background context
  // (cron, subagent, daily-report). Show the source badge ABOVE the bubble
  // so the owner knows the agent did this autonomously while they were away.
  // This is the "Claude-Code-style step display" for background work — every
  // autonomous action gets a visible byline.
  const sourceHeader = source && source !== 'chat' ? `
    <div class="flex items-center gap-1.5 mb-1.5 text-[10.5px] font-medium text-emerald-700">
      <i data-lucide="${source === 'subagent' ? 'users' : source === 'cron' ? 'clock' : source === 'daily_report' ? 'bar-chart-3' : 'sparkles'}" class="w-3 h-3"></i>
      <span>${
        source === 'subagent' ? 'وكيل فرعي'
        : source === 'cron' ? 'مهمة مجدولة'
        : source === 'daily_report' ? 'تقرير يومي'
        : source
      }</span>
    </div>` : '';

  const row = document.createElement('div');
  row.className = 'flex gap-3 max-w-[80%] ml-auto mb-3';
  row.innerHTML = `
    <div class="msg-avatar bg-white border border-line p-1.5 shrink-0">
      <img src="./brand/logo-icon.png" alt="" class="w-full h-full object-contain" />
    </div>
    <div class="flex flex-col items-start min-w-0">
      ${sourceHeader}
      <div class="bubble bubble-agent"></div>
      <div class="text-[11px] text-muted mt-1.5">${ts}</div>
    </div>`;
  row.querySelector('.bubble').textContent = text;
  stream.appendChild(row);
  stream.scrollTop = stream.scrollHeight;
  if (sourceHeader && window.lucide?.createIcons) {
    window.lucide.createIcons({ attrs: { 'stroke-width': 1.75 } });
  }
  return row;
}

// ─── Tab nav routing ────────────────────────────────────────────────
// Pane router. Each tab corresponds to a <section data-pane="X"> in the
// main column. Tabs that have a real v3 implementation render their pane
// directly; tabs that don't yet (operations / conversations / memory /
// settings / telegram) render the placeholder pane with a legacy link.
const V3_IMPLEMENTED_PANES = new Set(['chat', 'whatsapp', 'settings', 'memory', 'operations', 'conversations', 'telegram', 'office']);

// Map nav tab → legacy /dashboard/#tab= anchor (used by the placeholder pane).
const LEGACY_TAB_MAP = {
  operations:    'ops',
  conversations: 'conversations',
  whatsapp:      'whatsapp',
  telegram:      'telegram',
  memory:        'graph',
  settings:      'settings',
};

const PANE_TITLES = {
  operations:    'العمليات',
  conversations: 'المحادثات',
  memory:        'الذاكرة',
  settings:      'الإعدادات',
  telegram:      'تيليجرام',
};

let CURRENT_PANE = 'chat';

function switchPane(tab) {
  // Update nav active state
  document.querySelectorAll('[data-nav]').forEach((n) => {
    n.classList.toggle('nav-item-active', n.dataset.nav === tab);
  });

  // Hide all panes
  document.querySelectorAll('main > section.pane').forEach((p) => p.classList.add('hidden'));

  // If this tab has a v3 implementation, show that pane
  if (V3_IMPLEMENTED_PANES.has(tab)) {
    const pane = document.querySelector(`main > section[data-pane="${tab}"]`);
    if (pane) pane.classList.remove('hidden');
    CURRENT_PANE = tab;
    // Lifecycle hooks — each tab module exposes onShow / onHide on window.
    if (tab === 'whatsapp' && window.__moasisWhatsApp?.onShow) window.__moasisWhatsApp.onShow();
    if (CURRENT_PANE !== tab && tab === 'whatsapp' && window.__moasisWhatsApp?.onHide) window.__moasisWhatsApp.onHide();
    if (tab === 'settings' && window.__moasisSettings?.onShow) window.__moasisSettings.onShow();
    if (tab === 'memory'   && window.__moasisMemory?.onShow)   window.__moasisMemory.onShow();
    if (tab === 'operations' && window.__moasisOperations?.onShow) window.__moasisOperations.onShow();
    if (tab === 'conversations' && window.__moasisConversations?.onShow) window.__moasisConversations.onShow();
    if (tab === 'telegram' && window.__moasisTelegram?.onShow) window.__moasisTelegram.onShow();
    if (tab === 'office' && window.__moasisOffice?.onShow) window.__moasisOffice.onShow();
    if (CURRENT_PANE !== tab && CURRENT_PANE === 'office' && window.__moasisOffice?.onHide) window.__moasisOffice.onHide();
    applyIcons();
    return;
  }

  // Otherwise render the placeholder pane from template
  renderPlaceholderPane(tab);
}

function renderPlaceholderPane(tab) {
  const main = document.querySelector('main');
  if (!main) return;
  main.querySelectorAll('section[data-pane="__placeholder"]').forEach((n) => n.remove());

  // (v3.0.1) No more legacy bounce. The placeholder STAYS in v3 with a
  // clean "قيد البناء" state. Customer never sees the old chrome with
  // raw customer IDs / catchup_digest leaks / etc.
  const node = document.createElement('section');
  node.className = 'pane h-full flex items-center justify-center px-8';
  node.dataset.pane = '__placeholder';
  node.innerHTML = `
    <div class="text-center max-w-[460px]">
      <span class="w-16 h-16 rounded-2xl bg-emerald-50 inline-flex items-center justify-center mb-5">
        <i data-lucide="hammer" class="w-8 h-8 text-emerald-600"></i>
      </span>
      <h2 class="font-display font-bold text-[24px] text-ink mb-2">${escapeHtml(PANE_TITLES[tab] || tab)}</h2>
      <div class="text-[14px] text-body leading-[1.8]">
        هذا التبويب قيد إعادة البناء ضمن نظام المؤسس الجديد. سيظهر هنا قريباً بكامل ميزاته.
      </div>
      <div class="text-[12px] text-muted mt-4">v3.0.1 · ${escapeHtml(PANE_TITLES[tab] || tab)} pane</div>
    </div>
  `;
  main.appendChild(node);
  CURRENT_PANE = tab;
  applyIcons();
}

function wireNav() {
  document.querySelectorAll('[data-nav]').forEach((el) => {
    el.addEventListener('click', (e) => {
      e.preventDefault();
      switchPane(el.dataset.nav);
    });
  });
}

// ─── Chat bubble rendering ──────────────────────────────────────────
function getStream() { return document.getElementById('chat-stream'); }

function appendBubble(role, text) {
  const stream = getStream();
  if (!stream) return null;

  if (role === 'user') {
    // USER row: avatar on LEFT, bubble immediately to its right, whole
    // cluster pushed to the left edge of the chat area (WhatsApp pattern).
    // - flex-row-reverse in RTL puts the first DOM child on the LEFT
    // - mr-auto absorbs all right space → cluster anchors to left edge
    // - no flex-1 → column hugs content so "hi" stays a tiny bubble
    const userInitial = (CURRENT_TENANT?.owner_name || 'أ').trim().charAt(0);
    const row = document.createElement('div');
    row.className = 'flex flex-row-reverse gap-3 max-w-[80%] mr-auto mb-3';
    row.innerHTML = `
      <div class="msg-avatar bg-emerald-100 text-emerald-700 shrink-0">${escapeHtml(userInitial)}</div>
      <div class="flex flex-col items-end min-w-0">
        <div class="bubble bubble-user"></div>
        <div class="text-[11px] text-muted mt-1.5">${nowAr()}</div>
      </div>
    `;
    row.querySelector('.bubble').textContent = text;
    stream.appendChild(row);
    stream.scrollTop = stream.scrollHeight;
    return row.querySelector('.bubble');
  }

  // AGENT row: avatar on RIGHT (RTL default), bubble immediately to its
  // left, whole cluster pushed to the right edge.
  const row = document.createElement('div');
  row.className = 'flex gap-3 max-w-[80%] ml-auto mb-3';
  row.innerHTML = `
    <div class="msg-avatar bg-white border border-line p-1.5 shrink-0">
      <img src="./brand/logo-icon.png" alt="" class="w-full h-full object-contain" />
    </div>
    <div class="flex flex-col items-start min-w-0">
      <div class="bubble bubble-agent"></div>
      <div class="text-[11px] text-muted mt-1.5">${nowAr()}</div>
    </div>
  `;
  row.querySelector('.bubble').textContent = text;
  stream.appendChild(row);
  stream.scrollTop = stream.scrollHeight;
  return row.querySelector('.bubble');
}

function escapeHtml(s) {
  return String(s ?? '').replace(/[&<>"']/g, (c) => ({
    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
  }[c]));
}

function nowAr() {
  return new Date().toLocaleTimeString('ar-SA', {
    hour: '2-digit', minute: '2-digit', hour12: true,
  });
}

// ─── Activity pill renderer ──────────────────────────────────────────
// Inline pills that render in the chat stream as the agent works.
// Each tool_call_start emits one pill in "running" state; matching
// tool_call_end transitions it to "done" (or "failed"). Customer sees
// EVERY step the agent takes — never a black-box "I'll get back to you"
// silence.
function appendActivityPill(toolName, args) {
  const stream = getStream();
  if (!stream) return null;
  const empty = document.getElementById('chat-empty');
  if (empty) empty.remove();

  const meta = friendlyTool(toolName);
  const row = document.createElement('div');
  row.className = 'activity-row flex gap-3 max-w-[80%] ml-auto mb-3';
  row.dataset.tool = toolName;
  row.innerHTML = `
    <div class="msg-avatar bg-emerald-50 border border-emerald-100 p-1.5 opacity-90 shrink-0">
      <i data-lucide="${meta.icon}" class="w-4 h-4 text-emerald-600"></i>
    </div>
    <div class="flex items-center min-w-0">
      <span class="activity-pill activity-running">
        <i data-lucide="loader-circle" class="w-3.5 h-3.5 animate-spin"></i>
        <span class="activity-label">${escapeHtml(meta.label)}</span>
        <span class="activity-state text-[10.5px] text-muted">جارٍ التنفيذ…</span>
      </span>
    </div>`;
  stream.appendChild(row);
  stream.scrollTop = stream.scrollHeight;
  applyIcons();
  return row;
}

// ─── Artifact card (inline file preview in chat) ────────────────────
// When an artifact_ready event arrives (HTML page, PDF, Excel, image,
// etc.), render a clickable card next to the agent's reply. Mirrors
// the invoice card pattern from the reference mock-up.
function appendArtifactCard(payload) {
  const stream = getStream();
  if (!stream) return null;

  // (v3.0.4) Refuse to render a ghost card. If there's no real path
  // attached to this artifact, the card's buttons would lead to
  // /api/artifact?path= (empty) → 400 / nothing happens → user sees
  // a "ghost button". Bail out instead.
  const path = payload.path || payload.url || '';
  if (!path || path.length < 3) return null;
  // Also bail out for non-creating tool names. The backend filter is
  // the primary guard, but defense in depth.
  const READ_ONLY_TOOLS = new Set(['read_file', 'list_files', 'analyze_image', 'learn_from_document', 'open_file']);
  if (payload.name && READ_ONLY_TOOLS.has(payload.name)) return null;

  const empty = document.getElementById('chat-empty');
  if (empty) empty.remove();

  // Pick icon + label by file type
  const name = payload.name || payload.filename || path.split('/').pop() || 'ملف';
  const ext = (path.split('.').pop() || '').toLowerCase();
  const type = payload.type || ext;
  const isHtml = type === 'html' || ext === 'html' || ext === 'htm' || payload.kind === 'landing-page';
  const isPdf  = type === 'pdf'  || ext === 'pdf';
  const isExcel = ['xlsx','xls','csv'].includes(ext) || type === 'excel';
  const isImage = ['png','jpg','jpeg','webp','gif','svg'].includes(ext) || (type || '').startsWith('image');
  const isInvoice = payload.kind === 'invoice' || /invoice|فاتورة/i.test(name);

  const meta = isInvoice ? { icon: 'receipt',     label: 'فاتورة ZATCA',   color: 'emerald' }
            : isHtml    ? { icon: 'globe',       label: 'صفحة ويب',        color: 'emerald' }
            : isPdf     ? { icon: 'file-text',   label: 'تقرير PDF',       color: 'rose' }
            : isExcel   ? { icon: 'sheet',       label: 'ملف Excel',       color: 'emerald' }
            : isImage   ? { icon: 'image',       label: 'صورة',            color: 'amber' }
            : { icon: 'file', label: 'ملف', color: 'slate' };

  const title = payload.title || name;
  // Preview snippet (first <h1> or visible text for HTML; description for others)
  const preview = payload.preview || payload.description || (isHtml ? title : '');

  const row = document.createElement('div');
  row.className = 'flex gap-3 max-w-[80%] ml-auto mb-3';
  row.innerHTML = `
    <div class="msg-avatar bg-white border border-line p-1.5 shrink-0">
      <img src="./brand/logo-icon.png" alt="" class="w-full h-full object-contain" />
    </div>
    <div class="flex flex-col items-start min-w-0 w-full">
      <div class="bg-white rounded-2xl rounded-tr-sm border border-line shadow-card overflow-hidden w-full">
        <div class="px-4 py-3 flex items-center gap-3 border-b border-soft">
          <span class="w-9 h-9 rounded-lg bg-emerald-50 flex items-center justify-center shrink-0">
            <i data-lucide="${meta.icon}" class="w-5 h-5 text-emerald-600"></i>
          </span>
          <div class="flex-1 min-w-0">
            <div class="font-semibold text-[14px] text-ink truncate">${escapeHtml(title)}</div>
            <div class="text-[11px] text-muted truncate">${escapeHtml(meta.label)} · ${escapeHtml(name)}</div>
          </div>
          <i data-lucide="check-circle-2" class="w-4 h-4 text-emerald-600 shrink-0"></i>
        </div>
        ${preview && !isHtml ? `<div class="px-4 py-3 text-[12.5px] text-body leading-[1.7] line-clamp-3">${escapeHtml(preview)}</div>` : ''}
        ${isImage && path ? `<img src="/api/artifact?path=${encodeURIComponent(path)}" alt="" class="w-full max-h-[300px] object-contain bg-soft" />` : ''}
        <div class="px-4 py-3 flex gap-2 border-t border-soft">
          ${path ? `
            <button class="btn-primary flex-1" data-artifact-open="${escapeHtml(path)}">
              <i data-lucide="external-link" class="w-4 h-4"></i>
              <span>${isHtml ? 'افتح الصفحة' : isPdf ? 'افتح PDF' : isExcel ? 'افتح Excel' : 'افتح'}</span>
            </button>
            <button class="btn-outline" data-artifact-download="${escapeHtml(path)}" title="تحميل">
              <i data-lucide="download" class="w-4 h-4"></i>
            </button>` : ''}
        </div>
      </div>
      <div class="text-[11px] text-muted mt-1.5">${nowAr()}</div>
    </div>`;

  stream.appendChild(row);
  stream.scrollTop = stream.scrollHeight;
  applyIcons();

  // Wire actions
  row.querySelectorAll('[data-artifact-open]').forEach((b) =>
    b.addEventListener('click', () => openArtifact(b.dataset.artifactOpen)));
  row.querySelectorAll('[data-artifact-download]').forEach((b) =>
    b.addEventListener('click', () => downloadArtifact(b.dataset.artifactDownload)));
  return row;
}

async function openArtifact(path) {
  if (!path) return;
  // (v3.0.5 fix) The previous implementation tried to hand a tokened HTTP
  // URL to Tauri's opener.openUrl, which spawned the system browser —
  // and the browser had no Authorization header, so the page just 401'd
  // and looked like a ghost button.
  //
  // The right call is opener.openPath(<local-file>) which asks macOS /
  // Windows / Linux to open the file with its default app (Preview for
  // PDF, Numbers/Excel for xlsx, the default browser for HTML, etc.).
  // No HTTP, no auth, no chance of failure for the auth reason.
  //
  // For non-Tauri contexts (browser-only dev), we fall through to a
  // tokened /api/artifact URL — the gateway accepts the bearer via a
  // ?token= query param specifically so this case works.
  const probeOk = await artifactExists(path);
  if (!probeOk) return; // showArtifactError already called

  const tauri = window.__TAURI__;
  if (tauri?.opener?.openPath) {
    try {
      await tauri.opener.openPath(path);
      return;
    } catch (err) {
      // Fall through to the HTTP path on permission/scope errors so the
      // user always gets *something* rather than a silent dead button.
      console.warn('opener.openPath failed, falling back to HTTP', err);
    }
  }
  // Browser-only fallback. The ?token= query param lets the new tab
  // authenticate without a header.
  const url = `/api/artifact?path=${encodeURIComponent(path)}&token=${encodeURIComponent(DEVICE_BEARER || '')}`;
  if (tauri?.opener?.openUrl) {
    tauri.opener.openUrl(`http://127.0.0.1:3000${url}`);
  } else {
    window.open(url, '_blank', 'noopener');
  }
}

async function downloadArtifact(path) {
  if (!path) return;
  // (v3.0.5 fix) "Download" semantically means "give me this file in a
  // place I can see it". The file is *already on disk* in the user's
  // workspace dir — there's nothing to download. The friendliest thing
  // we can do is reveal it in Finder/Explorer so the owner can grab it.
  //
  // Fallback: in browser-only contexts we still trigger the classic
  // blob download because there's no OS to reveal into.
  const probeOk = await artifactExists(path);
  if (!probeOk) return;

  const tauri = window.__TAURI__;
  if (tauri?.opener?.revealItemInDir) {
    try {
      await tauri.opener.revealItemInDir(path);
      showArtifactToast(path, 'فُتح في Finder');
      return;
    } catch (err) {
      console.warn('opener.revealItemInDir failed, falling back to HTTP', err);
    }
  }
  const url = `/api/artifact?path=${encodeURIComponent(path)}`;
  try {
    const r = await fetch(url, { headers: { Authorization: `Bearer ${DEVICE_BEARER}` } });
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    const blob = await r.blob();
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = path.split('/').pop() || 'download';
    document.body.appendChild(a);
    a.click();
    setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 200);
  } catch (err) {
    showArtifactError(path, `فشل التحميل: ${err.message}`);
  }
}

/** Auth-gated HEAD probe — returns true if the file is reachable. */
async function artifactExists(path) {
  const probeUrl = `/api/artifact?path=${encodeURIComponent(path)}`;
  try {
    const probe = await fetch(probeUrl, { method: 'HEAD', headers: { Authorization: `Bearer ${DEVICE_BEARER}` } });
    if (!probe.ok) {
      showArtifactError(path, `الملف غير متاح: ${probe.status === 404 ? 'تم نقله أو حذفه' : 'HTTP ' + probe.status}`);
      return false;
    }
    return true;
  } catch (err) {
    showArtifactError(path, `تعذّر التحقق: ${err.message}`);
    return false;
  }
}

/** Inline success toast — mirror of showArtifactError but emerald. */
function showArtifactToast(path, message) {
  const t = document.createElement('div');
  t.className = 'fixed top-5 left-5 z-[10000] max-w-[360px] p-4 bg-white border border-emerald-200 rounded-xl shadow-cardHover';
  t.innerHTML = `
    <div class="flex items-start gap-2">
      <i data-lucide="check-circle-2" class="w-4 h-4 text-emerald-600 mt-0.5 shrink-0"></i>
      <div class="flex-1 min-w-0">
        <div class="text-[13px] font-semibold text-ink mb-0.5">${escapeHtml(message)}</div>
        <div class="text-[11px] text-muted font-num truncate" dir="ltr">${escapeHtml(path)}</div>
      </div>
    </div>`;
  document.body.appendChild(t);
  applyIcons();
  setTimeout(() => {
    t.style.transition = 'opacity 250ms ease';
    t.style.opacity = '0';
    setTimeout(() => t.remove(), 260);
  }, 2500);
}

// Inline toast — appears at top-right for ~5s, then fades.
// No alert()s — those interrupt the user and look unprofessional.
function showArtifactError(path, message) {
  const t = document.createElement('div');
  t.className = 'fixed top-5 left-5 z-[10000] max-w-[360px] p-4 bg-white border border-rose-200 rounded-xl shadow-cardHover';
  t.innerHTML = `
    <div class="flex items-start gap-2">
      <i data-lucide="alert-circle" class="w-4 h-4 text-rose-600 mt-0.5 shrink-0"></i>
      <div class="flex-1 min-w-0">
        <div class="text-[13px] font-semibold text-ink mb-0.5">${escapeHtml(message)}</div>
        <div class="text-[11px] text-muted font-num truncate" dir="ltr">${escapeHtml(path)}</div>
      </div>
    </div>`;
  document.body.appendChild(t);
  applyIcons();
  setTimeout(() => {
    t.style.transition = 'opacity 250ms ease';
    t.style.opacity = '0';
    setTimeout(() => t.remove(), 260);
  }, 4500);
}

function finalizeActivityPill(row, { success, durationMs, output }) {
  if (!row) return;
  const pill = row.querySelector('.activity-pill');
  if (!pill) return;
  pill.classList.remove('activity-running');
  pill.classList.add(success ? 'activity-done' : 'activity-failed');

  // Swap the spinner icon for ✓ or ✗
  const spinner = pill.querySelector('[data-lucide="loader-circle"]');
  if (spinner) {
    spinner.outerHTML = success
      ? '<i data-lucide="check" class="w-3.5 h-3.5"></i>'
      : '<i data-lucide="x" class="w-3.5 h-3.5"></i>';
  }
  const state = pill.querySelector('.activity-state');
  if (state) {
    const secs = durationMs ? ` · ${(durationMs / 1000).toFixed(1)} ث` : '';
    state.textContent = success ? `تم${secs}` : `فشل${secs}`;
  }
  applyIcons();
}

// ─── Live subagent view ─────────────────────────────────────────────
// Watch a spawned subagent work in real time — its steps stream into an inline
// card — instead of waiting for it to return silently "from the abyss". The
// backend already emits a per-subagent SSE feed (/api/subagents/:id/stream,
// with history replay), so a card opened mid-flight still shows prior steps.
const _subagentCards = new Map(); // subagentId → card row (dedupe)

function appendSubagentCard(subagentId, taskLabel) {
  const stream = getStream();
  if (!stream) return null;
  if (_subagentCards.has(subagentId)) return _subagentCards.get(subagentId);
  const empty = document.getElementById('chat-empty');
  if (empty) empty.remove();

  const row = document.createElement('div');
  row.className = 'flex gap-3 max-w-[85%] ml-auto mb-3';
  row.innerHTML = `
    <div class="msg-avatar bg-white border border-line p-1.5 shrink-0">
      <img src="./brand/logo-icon.png" alt="" class="w-full h-full object-contain" />
    </div>
    <div class="subagent-card">
      <div class="subagent-head">
        <i data-lucide="loader-circle" class="sa-spin animate-spin"></i>
        <span class="sa-title">وكيل فرعي يعمل…</span>
        <span class="subagent-badge" data-sa-badge>يعمل</span>
      </div>
      <div class="subagent-steps" data-sa-steps></div>
    </div>`;
  stream.appendChild(row);
  if (taskLabel) { const t = row.querySelector('.sa-title'); if (t) t.textContent = `وكيل فرعي: ${String(taskLabel).slice(0, 60)}`; }
  stream.scrollTop = stream.scrollHeight;
  applyIcons();
  _subagentCards.set(subagentId, row);
  return row;
}

function addSubagentStep(card, type, message) {
  const steps = card?.querySelector('[data-sa-steps]');
  if (!steps) return;
  const cls = type === 'thinking' ? 'sa-thinking'
    : type === 'tool_call' ? 'sa-tool'
    : (type === 'tool_done' || type === 'finished') ? 'sa-done'
    : type === 'error' ? 'sa-err' : '';
  const ic = type === 'thinking' ? '💭' : type === 'tool_call' ? '🔧'
    : type === 'tool_done' ? '✓' : type === 'finished' ? '✅'
    : type === 'error' ? '⚠️' : type === 'start' ? '🚀' : '•';
  // Collapse the rapidly-repeating "thinking" line instead of spamming rows.
  if (type === 'thinking') {
    const prev = steps.querySelector('.sa-step.sa-thinking[data-transient]');
    if (prev) { prev.querySelector('.sa-msg').textContent = message || 'يفكّر…'; return; }
  } else {
    const t = steps.querySelector('.sa-step.sa-thinking[data-transient]');
    if (t) t.removeAttribute('data-transient'); // freeze the last thinking line in place
  }
  const el = document.createElement('div');
  el.className = `sa-step ${cls}`;
  if (type === 'thinking') el.setAttribute('data-transient', '1');
  el.innerHTML = `<span class="sa-ic">${ic}</span><span class="sa-msg"></span>`;
  el.querySelector('.sa-msg').textContent = message || type;
  steps.appendChild(el);
  steps.scrollTop = steps.scrollHeight;
}

function setSubagentStatus(card, state) {
  const badge = card?.querySelector('[data-sa-badge]');
  const spin = card?.querySelector('.sa-spin');
  if (!badge) return;
  if (state === 'done') {
    badge.textContent = 'اكتمل'; badge.classList.add('done');
    if (spin) { spin.classList.remove('animate-spin'); spin.outerHTML = '<i data-lucide="check" class="sa-spin"></i>'; applyIcons(); }
  } else if (state === 'failed') {
    badge.textContent = 'فشل'; badge.classList.add('failed');
    if (spin) { spin.classList.remove('animate-spin'); spin.outerHTML = '<i data-lucide="alert-triangle" class="sa-spin"></i>'; applyIcons(); }
  }
}

async function streamSubagentLive(subagentId, taskLabel) {
  const card = appendSubagentCard(subagentId, taskLabel);
  if (!card || card.dataset.streaming === '1') return;
  card.dataset.streaming = '1';
  try {
    const res = await fetch(`/api/subagents/${encodeURIComponent(subagentId)}/stream`, {
      headers: { Authorization: `Bearer ${DEVICE_BEARER}`, Accept: 'text/event-stream' },
    });
    if (!res.ok || !res.body) { card.dataset.streaming = '0'; return; }
    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buf = '';
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      const frames = buf.split('\n\n');
      buf = frames.pop() || '';
      for (const frame of frames) {
        let event = 'message', data = '';
        for (const line of frame.split('\n')) {
          if (line.startsWith('event:')) event = line.slice(6).trim();
          else if (line.startsWith('data:')) data += line.slice(5).trim();
        }
        if (!data) continue;
        let payload; try { payload = JSON.parse(data); } catch { continue; }
        const type = payload.type || event;
        if (type === 'finished') { addSubagentStep(card, 'finished', payload.message || 'اكتمل'); setSubagentStatus(card, 'done'); }
        else if (type === 'error' || type === 'cancelled') { addSubagentStep(card, 'error', payload.message || type); setSubagentStatus(card, 'failed'); }
        else { addSubagentStep(card, type, payload.message); }
      }
    }
    // Stream closed without an explicit finish → reconcile from the run status.
    if (card.querySelector('[data-sa-badge]')?.textContent === 'يعمل') {
      try {
        const r = await api(`/api/subagents/${encodeURIComponent(subagentId)}`);
        const st = r?.data?.status;
        if (st === 'completed') setSubagentStatus(card, 'done');
        else if (st === 'failed' || st === 'timeout' || st === 'cancelled') setSubagentStatus(card, 'failed');
      } catch { /* leave as-is */ }
    }
  } catch (err) {
    console.warn('subagent stream failed:', err.message);
  } finally {
    card.dataset.streaming = '0';
  }
}

// Surface any subagent that's currently running (e.g. spawned in a prior
// session or by a cron) so the owner sees it without having to be present at
// spawn time. Safe to call repeatedly — cards dedupe by id.
async function checkActiveSubagents() {
  try {
    const r = await api('/api/subagents');
    const runs = (r && r.data && r.data.runs) || [];
    for (const run of runs) {
      if ((run.status === 'running' || run.status === 'queued') && !_subagentCards.has(run.id)) {
        streamSubagentLive(run.id, run.task);
      }
    }
  } catch { /* non-fatal */ }
}

// ─── Chat send + SSE stream ─────────────────────────────────────────
async function streamChat(message) {
  if (!message?.trim()) return;
  appendBubble('user', message);

  const customerId = localStorage.getItem(CUSTOMER_KEY) || undefined;
  const requestId = (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
  const body = { message, request_id: requestId };
  if (customerId) body.customer_id = customerId;

  const stream = getStream();
  const emptyState = document.getElementById('chat-empty');
  if (emptyState) emptyState.remove();

  // Typing indicator (jumping dots). Rendered the instant the message is sent
  // AND re-shown after every tool call, so the user always sees Moasis is
  // still working and a reply never pops in out of nowhere. Only one exists at
  // a time; re-appending keeps it pinned to the bottom, below the latest tool
  // pill. Styled as an agent bubble so it reads as "composing a message".
  let typingRow = null;
  const showTyping = () => {
    if (!stream) return;
    if (!typingRow) {
      typingRow = document.createElement('div');
      typingRow.className = 'flex gap-3 max-w-[80%] ml-auto mb-3';
      typingRow.innerHTML = `
        <div class="msg-avatar bg-white border border-line p-1.5 shrink-0">
          <img src="./brand/logo-icon.png" alt="" class="w-full h-full object-contain" />
        </div>
        <div class="flex items-center min-w-0">
          <div class="bubble bubble-agent typing-dots" role="status" aria-label="يكتب الآن">
            <span></span><span></span><span></span>
          </div>
        </div>`;
    }
    stream.appendChild(typingRow); // re-append → always the last element
    stream.scrollTop = stream.scrollHeight;
  };
  const removeTyping = () => {
    if (typingRow && typingRow.parentElement) typingRow.remove();
  };
  showTyping();
  // Reflect the main brain's live state on the founder in the Office view.
  window.__moasisOffice?.setFounder?.('thinking');

  // Track tool pills by call_id so end events can finalize the right one.
  // FIFO queue per tool name as fallback when call_id isn't supplied.
  const pillsById = new Map();
  const pillsByTool = new Map();

  let placeholder = null;
  let firstResponse = true;
  let lastCustomerId = null;

  // Back-compat alias: several branches below removed the old "thinking" pill;
  // they now just hide the typing dots.
  const finalizeThinking = removeTyping;

  try {
    const res = await fetch('/api/chat/stream', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${DEVICE_BEARER}`,
        Accept: 'text/event-stream',
      },
      body: JSON.stringify(body),
    });
    if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);

    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buf = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      const frames = buf.split('\n\n');
      buf = frames.pop() || '';
      for (const frame of frames) {
        const lines = frame.split('\n');
        let event = 'message';
        let data = '';
        for (const line of lines) {
          if (line.startsWith('event:')) event = line.slice(6).trim();
          else if (line.startsWith('data:')) data += line.slice(5).trim();
        }
        if (!data) continue;
        let payload;
        try { payload = JSON.parse(data); } catch { continue; }

        if (event === 'open') {
          lastCustomerId = payload.customer_id;
        } else if (event === 'thinking') {
          // Keep the typing dots visible while the brain reasons (idempotent).
          showTyping();
        } else if (event === 'tool_call_start') {
          // Hide the thinking pill the moment a real tool fires.
          finalizeThinking();
          window.__moasisOffice?.setFounder?.('tool', payload.name);
          const pill = appendActivityPill(payload.name, payload.arguments);
          if (payload.call_id) pillsById.set(payload.call_id, pill);
          const q = pillsByTool.get(payload.name) || [];
          q.push(pill);
          pillsByTool.set(payload.name, q);
        } else if (event === 'tool_call_end') {
          let pill = payload.call_id ? pillsById.get(payload.call_id) : null;
          if (!pill) {
            const q = pillsByTool.get(payload.name);
            pill = q?.shift();
            if (q && q.length === 0) pillsByTool.delete(payload.name);
          } else {
            pillsById.delete(payload.call_id);
          }
          finalizeActivityPill(pill, {
            success: payload.success !== false,
            durationMs: payload.durationMs,
            output:    payload.output,
          });
          // A spawned subagent → open a LIVE card that streams its steps in
          // real time, instead of leaving the user to wait for it silently.
          if (payload.name === 'spawn_subagent' && payload.success !== false &&
              payload.data && payload.data.subagent_id) {
            streamSubagentLive(payload.data.subagent_id);
          }
          // Tool done — bring the typing dots back so the user sees Moasis is
          // still working (composing the reply or about to run the next tool),
          // never a frozen gap before the response lands.
          showTyping();
        } else if (event === 'artifact_ready') {
          // The brain just produced a file (HTML page, PDF, Excel, etc.).
          // Render it as an inline card in chat with an "Open" button so
          // the customer sees the deliverable AS A REAL THING. This was
          // the v3.0.1 critical gap — backend created the file, frontend
          // ignored it, customer saw "...".
          removeTyping();
          appendArtifactCard(payload);
          // A text reply almost always follows the artifact — keep the dots up.
          showTyping();
        } else if (event === 'response') {
          finalizeThinking();
          window.__moasisOffice?.setFounder?.('done');
          if (firstResponse) {
            placeholder = appendBubble('assistant', payload.text);
            firstResponse = false;
          } else {
            // Subsequent response chunks (multi-segment replies).
            appendBubble('assistant', payload.text);
          }
        } else if (event === 'error') {
          finalizeThinking();
          if (!placeholder) placeholder = appendBubble('assistant', `(خطأ: ${payload.message || 'unknown'})`);
          else placeholder.textContent = `(خطأ: ${payload.message || 'unknown'})`;
        }
        // skill_match / artifact_ready ignored for now — future passes.
      }
    }

    finalizeThinking();
    if (lastCustomerId) localStorage.setItem(CUSTOMER_KEY, lastCustomerId);
    if (firstResponse) appendBubble('assistant', '(لم يصل رد)');
    setTimeout(() => window.__moasisOffice?.setFounder?.('idle'), 4000);
  } catch (err) {
    finalizeThinking();
    window.__moasisOffice?.setFounder?.('idle');
    if (!placeholder) appendBubble('assistant', `(خطأ في البث: ${err.message})`);
  }
}

function wireComposer() {
  const input = document.getElementById('chat-input');
  const sendBtn = document.querySelector('[data-action="send"]') || null;
  if (!input) return;

  // ─── Auto-grow textarea ────────────────────────────────────────
  // The textarea grows with content (up to max-height from CSS) so the
  // user always sees what they typed. Resets to 1 row after send.
  const baseLineHeight = 22; // ~ leading-[1.6] × 14px font
  const autoGrow = () => {
    input.style.height = 'auto';
    // Cap at max-height set in HTML class (200px); the inline style
    // gets overridden by max-height when content exceeds it, and
    // overflow-y becomes the scroll.
    const newH = Math.min(input.scrollHeight, 200);
    input.style.height = newH + 'px';
    input.style.overflowY = input.scrollHeight > 200 ? 'auto' : 'hidden';
  };
  input.addEventListener('input', autoGrow);
  // Reset to 1 row on initial render
  autoGrow();

  const sendNow = async () => {
    const msg = input.value.trim();
    if (!msg) return;
    input.value = '';
    autoGrow(); // shrink back to 1 row after send
    input.disabled = true;
    try {
      await streamChat(msg);
    } finally {
      input.disabled = false;
      input.focus();
      autoGrow();
    }
  };

  // Enter = send, Shift+Enter = newline. The textarea respects newlines
  // natively now (input was a single-line element, so this is new).
  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      sendNow();
    }
  });
  if (sendBtn) sendBtn.addEventListener('click', sendNow);

  // Quick-suggestion chips — prefill the input with the suggestion text,
  // focus it, and let the user adjust before sending. NOT auto-send so
  // they don't fire something they didn't read.
  document.querySelectorAll('[data-suggest]').forEach((chip) => {
    chip.addEventListener('click', () => {
      input.value = chip.dataset.suggest || '';
      input.focus();
      // Move cursor to end
      input.setSelectionRange(input.value.length, input.value.length);
    });
  });
}

// ─── Boot ───────────────────────────────────────────────────────────
async function boot() {
  applyIcons();
  wireNav();
  wireComposer();

  const tokenRes = await bootstrapToken();
  if (!tokenRes.ok) {
    // No token → likely first-launch / gateway restart. Bounce to legacy
    // so the user sees the existing failure UI rather than a half-broken
    // new view.
    window.location.replace('/dashboard/');
    return;
  }

  const licensed = await checkLicenseOrBounce();
  if (!licensed) return;

  await loadTenant();
  await maybeShowOnboarding();
  await loadChatHistory();
  loadRightPanelMetrics();
  loadUpcomingTasks();
  loadChatSuggestions();

  // Keep the chat and sidebar live without a reload: background messages
  // (cron/subagent/report) stream in every 8s; the overview metrics +
  // upcoming-tasks refresh every 30s so the numbers and the scheduled-job
  // list track reality as the agent works.
  startBackgroundMessagePoll();
  setInterval(() => { loadRightPanelMetrics(); loadUpcomingTasks(); }, 30_000);

  // Surface any subagent already running (spawned before this session or by a
  // cron) and keep watching for new ones the agent spawns autonomously.
  checkActiveSubagents();
  setInterval(checkActiveSubagents, 12_000);

  // Hash-based deep linking: /dashboard-v2/#settings opens that pane.
  const hash = (window.location.hash || '').replace(/^#/, '');
  if (hash && V3_IMPLEMENTED_PANES.has(hash)) {
    switchPane(hash);
  }
  window.addEventListener('hashchange', () => {
    const h = (window.location.hash || '').replace(/^#/, '');
    if (h && (V3_IMPLEMENTED_PANES.has(h) || ['operations','conversations','memory','telegram'].includes(h))) {
      switchPane(h);
    }
  });
}

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', boot);
} else {
  boot();
}

// Expose for other v3 modules
window.__moasis = { applyIcons, api, streamChat, appendBubble, switchPane };
