// App — top-level shell. Owns routing, state, and responsive layout.
// Mobile: bottom tab bar + floating FAB.
// Desktop (≥768px): right sidebar with nav + capture buttons.

const DESKTOP_MQ = '(min-width: 768px)';

// ── Gamification: XP + levels ────────────────────────────────────
// XP per completed task (by priority); level N→N+1 needs 100*N XP.
const XP_PER_TASK = { urgent: 20, important: 15, normal: 10 };
const XP_WELCOME = 30; // one-time bonus for finishing onboarding
const XP_FOCUS_BONUS = 5; // every third completed task in a day
const XP_STREAK_BONUS = 25; // every seventh active day
const LEVEL_TITLES = ['تازه‌کار', 'کوشا', 'منظم', 'پیگیر', 'حرفه‌ای', 'استاد', 'افسانه'];
const DEFAULT_GAMIFY = { xp: 0, awarded: {}, days: {}, streak: 0, bestStreak: 0, lastDay: null, lastGain: null };
// Returns { level, into, need, pct, title } for a cumulative XP total.
const levelInfo = (xp = 0) => {
  let level = 1, need = 100, acc = 0;
  while (xp >= acc + need) { acc += need; level += 1; need = 100 * level; }
  const into = xp - acc;
  return {
    level, into, need,
    pct: Math.max(0, Math.min(100, Math.round((into / need) * 100))),
    title: LEVEL_TITLES[Math.min(level - 1, LEVEL_TITLES.length - 1)],
  };
};
window.levelInfo = levelInfo;

const _gameDayKey = (d = new Date()) =>
  [d.getFullYear(), String(d.getMonth()+1).padStart(2,'0'), String(d.getDate()).padStart(2,'0')].join('-');
const _prevGameDayKey = key => {
  const d = new Date(key + 'T12:00:00');
  d.setDate(d.getDate() - 1);
  return _gameDayKey(d);
};
const normalizeGamify = g => ({
  ...DEFAULT_GAMIFY,
  ...(g || {}),
  xp: Math.max(0, Number(g?.xp) || 0),
  awarded: g?.awarded && typeof g.awarded === 'object' ? g.awarded : {},
  days: g?.days && typeof g.days === 'object' ? g.days : {},
  streak: Math.max(0, Number(g?.streak) || 0),
  bestStreak: Math.max(0, Number(g?.bestStreak) || 0),
});
const awardGamifyTask = (gamify, taskId, priority = 'normal', dayKey = _gameDayKey()) => {
  const prev = normalizeGamify(gamify);
  if (!taskId || prev.awarded[taskId]) return { gamify: prev, event: null };
  const base = XP_PER_TASK[priority] || XP_PER_TASK.normal;
  const oldDayCount = Number(prev.days[dayKey]) || 0;
  const dayCount = oldDayCount + 1;
  const firstToday = oldDayCount === 0;
  const streak = firstToday
    ? (prev.lastDay === dayKey ? Math.max(1, prev.streak) : prev.lastDay === _prevGameDayKey(dayKey) ? prev.streak + 1 : 1)
    : Math.max(1, prev.streak || 1);
  const focusBonus = dayCount > 0 && dayCount % 3 === 0 ? XP_FOCUS_BONUS : 0;
  const streakBonus = firstToday && streak > 0 && streak % 7 === 0 ? XP_STREAK_BONUS : 0;
  const gain = base + focusBonus + streakBonus;
  const xp = prev.xp + gain;
  const event = { gain, base, focusBonus, streakBonus, dayCount, streak, leveled: levelInfo(xp).level > levelInfo(prev.xp).level };
  return {
    gamify: {
      ...prev,
      xp,
      streak,
      bestStreak: Math.max(prev.bestStreak || 0, streak),
      lastDay: dayKey,
      lastGain: { ...event, at: Date.now(), dayKey },
      days: { ...prev.days, [dayKey]: dayCount },
      awarded: { ...prev.awarded, [taskId]: true },
    },
    event,
  };
};
window.awardGamifyTask = awardGamifyTask;

// Migrate legacy tasks (done:boolean, tag:'فوری') to the status/priority model.
const migrateTask = t => ({
  ...t,
  status: t.status || (t.done ? 'done' : 'pending'),
  done: t.status ? t.status === 'done' : !!t.done,
  priority: t.priority || (t.tag === 'فوری' ? 'urgent' : 'normal'),
});

// ── AvalAI config ────────────────────────────────────────────
// F05: in a fully client-side app any key the browser uses is necessarily
// exposable (true secrecy needs a server-side proxy, which this PWA has none
// of). This is a scoped demo key. It can be overridden without editing source
// via `window.__ENV__.AVAL_KEY` or localStorage `logi_aval_key`, so a deployer
// can rotate/supply their own key.
const AVAL_KEY_DEFAULT = 'aa-lzjLT2b4r5ny6zYW0Cw178xzWhdEU2TMvUgfDDnOF6XOurkU';
const AVAL_KEY = (() => {
  try {
    return (window.__ENV__ && window.__ENV__.AVAL_KEY)
      || localStorage.getItem('logi_aval_key')
      || AVAL_KEY_DEFAULT;
  } catch { return AVAL_KEY_DEFAULT; }
})();
const AVAL_URL    = 'https://api.avalai.ir/v1/chat/completions';
const AVAL_MODEL  = 'gpt-5.4-nano';
const getSystemMsg = (finances = [], tasks = [], knowledge = [], projects = []) => {
  const now = new Date();
  const todayKey = [now.getFullYear(), String(now.getMonth()+1).padStart(2,'0'), String(now.getDate()).padStart(2,'0')].join('-');
  const faDate = new Intl.DateTimeFormat('fa-IR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).format(now);
  const faDtFmt = new Intl.DateTimeFormat('fa-IR', { year: 'numeric', month: 'long', day: 'numeric' });
  const catLabel = { salary:'حقوق', freelance:'فریلنس', rent:'کرایه', loan:'قسط‌وام', utilities:'قبوض', food:'خوراک', shop:'خرید', other:'سایر' };
  const financeCtx = finances.length === 0 ? '' :
    `\n═══ رویدادهای مالی ثبت‌شده کاربر (${finances.length} مورد) ═══\nوقتی کاربر سوال مالی پرسید از این داده‌ها جواب بده:\n` +
    finances.slice().sort((a,b)=>b.date.localeCompare(a.date)).slice(0,80).map(f => {
      const d = faDtFmt.format(new Date(f.date + 'T12:00:00'));
      const t = f.type === 'income' ? 'درآمد' : 'هزینه';
      const c = catLabel[f.cat] || f.cat;
      const amt = f.amount.toLocaleString('fa-IR');
      return `${d} | ${t} | ${c} | ${f.title} | ${amt} تومان`;
    }).join('\n') + '\n';

  const taskCtx = tasks.length === 0 ? '' :
    `\n═══ تسک‌های کاربر (${tasks.length} مورد) ═══\nوقتی کاربر درباره کارها/برنامه‌هایش پرسید از این داده‌ها جواب بده:\n` +
    tasks.slice()
      .sort((a,b) => (b.createdAt || 0) - (a.createdAt || 0))
      .slice(0, 50)
      .map(t => {
        const d = t.dueDate ? faDtFmt.format(new Date(t.dueDate + 'T12:00:00')) : 'بدون تاریخ';
        const status = t.status === 'done' || t.done ? 'انجام‌شده'
          : t.status === 'in-progress' ? 'در حال انجام'
          : t.status === 'cancelled' ? 'لغو‌شده' : 'در انتظار';
        const prio = t.priority === 'urgent' ? 'فوری' : t.priority === 'important' ? 'مهم' : 'عادی';
        const rec = t.recurrence?.type && t.recurrence.type !== 'none' ? ' | تکرارشونده' : '';
        return `${t.title} | ${d} | ${t.cat || 'تسک'} | ${status} | اولویت ${prio}${rec}`;
      }).join('\n') + '\n';

  const knowledgeCtx = knowledge.length === 0 ? '' :
    `\n═══ کارت‌های دانش کاربر (${knowledge.length} مورد) ═══\nوقتی کاربر درباره چیزی که یاد گرفته یا یادداشت کرده پرسید از این داده‌ها جواب بده:\n` +
    knowledge.slice(0, 30).map(k => {
      const body = (k.body || '').slice(0, 120);
      const tags = (k.tags || []).join(' ');
      return `${k.title} | ${body}${(k.body || '').length > 120 ? '…' : ''}${tags ? ' | ' + tags : ''}`;
    }).join('\n') + '\n';

  const projectCtx = projects.length === 0 ? '' :
    `\n═══ پروژه‌های کاربر (${projects.length} مورد) ═══\n` +
    projects.map(p => {
      const pt = tasks.filter(t => t.projectId === p.id);
      const done = pt.filter(t => t.done || t.status === 'done').length;
      return `${p.name} | کل: ${pt.length} تسک | انجام‌شده: ${done}`;
    }).join('\n') + '\n';

  return `تو دستیار هوشمند لاگی (Logi) هستی — یک برنامه مغز دوم فارسی. همیشه فارسی، روشن، قابل اعتماد و کوتاه پاسخ بده.
تاریخ امروز: ${faDate} — کد تاریخ: ${todayKey}
${financeCtx}${taskCtx}${knowledgeCtx}${projectCtx}
═══ پروتکل پاسخ قابل نمایش به کاربر ═══
۱) متن قابل نمایش نباید شامل JSON، مارکر، براکت، یا توضیح فنی باشد.
۲) اگر چیزی را ثبت می‌کنی، در یک جمله کوتاه و مشخص بگو: «ثبت شد: ...». اگر چیزی ثبت نشد، ادعای ثبت نکن.
۳) اگر اطلاعات برای ثبت مالی مبلغ ندارد، بلوک FINANCE ننویس و فقط یک سوال کوتاه درباره مبلغ بپرس.
۴) اگر کار/یادآوری عنوان ندارد، بلوک TASKS ننویس و فقط یک سوال کوتاه درباره عنوان بپرس.
۵) برای سؤال‌های معمولی، مشاوره، یا گفتگوهای بدون قصد ثبت، هیچ بلوک ساختاری ننویس و فقط پاسخ بده.

═══ قانون ۱ — ثبت رویداد مالی در چرتکه ═══
اگر کاربر از هر مبلغ، هزینه، درآمد، پرداخت یا خریدی حرف زد (گذشته یا آینده) حتماً این بلوک را در انتهای پاسخ بنویس:
[[FINANCE:[{"type":"expense","cat":"rent","title":"اجاره خانه","amount":17000000,"date":"${todayKey}","recurrence":"monthly"}]]]
اگر چند مورد مالی گفت، همه را در یک بلوک و در یک آرایه بنویس (هر مورد یک شیء جدا).
مقادیر type: "income" (درآمد) یا "expense" (هزینه)
مقادیر cat: "salary" | "freelance" | "rent" | "loan" | "utilities" | "food" | "shop" | "other"
amount = عدد صحیح با ارقام انگلیسی، بدون کاما و بدون ارقام فارسی و بدون واحد. درست: 17000000 — غلط: ۱۷٬۰۰۰٬۰۰۰ یا 17,000,000 یا "۱۷ میلیون". اگر مبلغ نامشخص است بلوک را ننویس.
recurrence (تکرار): "none" (پیش‌فرض) | "monthly" (اگر گفت ماهانه/هر ماه/ماهی) | "yearly" (اگر گفت سالانه/هر سال). حقوق ماهانه، اجاره و قسط معمولاً monthly هستند.
date = YYYY-MM-DD — اگر آینده گفت (جمعه، هفته دیگر...) تاریخ دقیق را محاسبه کن
رویدادهایی که [چرتکه ثبت شد] دارند را دوباره ننویس.

═══ قانون ۲ — ساخت تسک ═══
ثبت کن فقط وقتی نیت کاربر برای برنامه‌ریزی/یادآوری/انجام کار روشن است: جلسه، قرار، یادآوری، باید انجام بدهم، زنگ بزن، پیگیری کن، یا زمان آینده.
اگر ساعت یا جزییات ندارد، با همان اطلاعات موجود ثبت کن و در متن فقط یک سوال کوتاه برای تکمیل جزئیات بپرس.
برای یک جمله خبری یا سؤال عمومی درباره کارها، بدون قصد ثبت، TASKS ننویس.
به‌روزرسانی: اگر کاربر بعداً جزییات بیشتری داد (ساعت، مکان، اولویت، تکرار...)، دوباره [[TASKS:[...]]] با همان عنوان و اطلاعات کامل‌تر بنویس — سیستم خودش آپدیت می‌کند.
[[TASKS:[{"title":"عنوان","dueDate":"YYYY-MM-DD","dueTime":"HH:MM","cat":"تسک","priority":"normal","status":"pending","recurrence":{"type":"none"},"notes":""}]]]
فقط title اجباری است؛ بقیه فیلدها را فقط وقتی کاربر اشاره کرد پر کن وگرنه مقدار پیش‌فرض بگذار یا حذف کن.
dueDate: امروز=${todayKey} (اگر روز آینده گفت تاریخ دقیق را حساب کن) · dueTime: ساعت ۲۴ساعته HH:MM
cat: "جلسه" | "یادآوری" | "ایده" | "تسک"
priority (اولویت): "urgent" (فوری/خیلی مهم) | "important" (مهم) | "normal" (عادی، پیش‌فرض)
status (وضعیت): "pending" (در انتظار، پیش‌فرض) | "in-progress" (در حال انجام) | "done" (انجام‌شده) | "cancelled" (لغوشده)
recurrence (تکرار): {"type":"none"|"daily"|"weekly"|"monthly"|"yearly"} — برای ماهانه می‌توانی "jalaliDay" (روز ماه شمسی، عدد ۱ تا ۳۱) هم بدهی. اگر کاربر تکرار نخواست "none" بگذار.
notes (یادداشت): توضیح یا جزییات اضافه‌ای که کاربر گفت (مکان، افراد، نکته‌ها).
تسک‌هایی که [ثبت شد] دارند و اطلاعات جدیدی نیامده را دوباره ننویس.

═══ قانون ۳ — ساخت کارت دانش ═══
اگر کاربر یک مفهوم، آموخته، تعریف، یا نکتهٔ جلسه را به اشتراک گذاشت (نه صرفاً یک کار یا خرید)، این بلوک را در انتهای پاسخ بنویس:
[[KNOWLEDGE:[{"kind":"learning","title":"عنوان کوتاه","body":"توضیح مفهوم","tags":["#برچسب"]}]]]
مقادیر kind: "note" (یادداشت) | "meeting" (جلسه) | "learning" (یادگیری) | "doc" (سند) | "personal" (شخصی)
کارت‌هایی که [دانش ثبت شد] دارند را دوباره نساز. اگر مطلب جدیدی برای ذخیره نیست این بلوک را ننویس.

═══ قانون ۴ — حل مسئله و ساخت ریزروتین ═══
اگر کاربر یک مشکل، چالش رفتاری یا هدفی را مطرح کرد (مثل «سخت خوابم می‌بره»، «صبح‌ها نمی‌تونم بیدار شم»، «می‌خوام ورزش رو شروع کنم»، «استرس دارم»، «تمرکز ندارم»، «می‌خوام زبان یاد بگیرم»)، به‌جای یک تسک ساده، یک «ریزروتین» علمی و کوتاه طراحی کن: ۲ تا ۵ گام مشخص، کوچک و عملی.
این گام‌ها را در یک بلوک [[TASKS:[...]]] بنویس (هر گام یک تسک با عنوان کوتاه و ساعت منطقی). اگر روتینِ تکرارشوندهٔ روزانه است، برای هر گام recurrence:{"type":"daily"} بگذار. cat را "یادآوری" بگذار.
در متن پاسخ، در یک یا دو جملهٔ کوتاه و دوستانه توضیح بده چه روتینی برایش ساختی. منتظر جزییات بیشتر نمان.
مثال برای «سخت خوابم می‌بره»: کاهش نور و صفحه‌نمایش ساعت ۲۲:۳۰، مسواک و بهداشت شبانه ساعت ۲۳:۰۰، نوشتن اولین کار فردا ساعت ۲۳:۱۰، خاموشی و تنفس عمیق ساعت ۲۳:۲۰.
برای یک کار سادهٔ تک‌مرحله‌ای ریزروتین نساز؛ فقط وقتی مسئله/هدف چندمرحله‌ای است.`;
};


// Repairs the most common ways the model breaks JSON in a marker payload:
// Persian/Arabic digits, and thousands separators inside numbers (e.g. the
// model writes "amount":100,000,000 or ۱۷٬۰۰۰٬۰۰۰ which is invalid JSON).
const _faToAsciiDigit = d => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d);
const _arToAsciiDigit = d => '٠١٢٣٤٥٦٧٨٩'.indexOf(d);
const _repairJSON = s => s
  .replace(/[۰-۹]/g, _faToAsciiDigit)        // Persian digits → ASCII
  .replace(/[٠-٩]/g, _arToAsciiDigit)        // Arabic-Indic digits → ASCII
  .replace(/(?<=\d)[,،٬](?=\d)/g, '');        // strip thousands separators between digits

// Parse with the raw text first (keeps Persian digits in titles); only fall
// back to the digit/separator repair when strict parsing fails.
const _safeJSON = s => {
  try { return JSON.parse(s); } catch {}
  try { return JSON.parse(_repairJSON(s)); } catch {}
  return undefined;
};

// Scans every [[KEY: <json> ]] marker via balanced-bracket matching (string
// aware), so it is immune to whitespace/newlines after the colon, nested
// brackets inside the payload, and 2-vs-3 closing brackets. Both parsing and
// stripping use this one scanner so they can never diverge (the bug where a
// marker was stripped from the reply yet never saved).
const _scanMarkers = (text, key) => {
  const res = [];
  const re = new RegExp(`\\[\\[\\s*${key}\\s*:`, 'g');
  let m;
  while ((m = re.exec(text))) {
    const matchStart = m.index;
    let i = m.index + m[0].length;
    while (i < text.length && /\s/.test(text[i])) i++;      // skip ws after colon
    const open = text[i];
    if (open !== '[' && open !== '{') {                      // no JSON body — orphan
      const c = text.indexOf(']]', i);
      res.push({ matchStart, matchEnd: c === -1 ? text.length : c + 2, payload: null });
      re.lastIndex = c === -1 ? text.length : c + 2;
      continue;
    }
    let depth = 0, inStr = false, esc = false, end = -1;
    for (let j = i; j < text.length; j++) {
      const ch = text[j];
      if (inStr) {
        if (esc) esc = false;
        else if (ch === '\\') esc = true;
        else if (ch === '"') inStr = false;
      } else if (ch === '"') inStr = true;
      else if (ch === '[' || ch === '{') depth++;
      else if (ch === ']' || ch === '}') { depth--; if (depth === 0) { end = j; break; } }
    }
    if (end === -1) { res.push({ matchStart, matchEnd: text.length, payload: null }); break; }
    const payload = text.slice(i, end + 1);
    let k = end + 1;
    while (k < text.length && /\s/.test(text[k])) k++;       // skip ws before closers
    let closed = 0;
    while (k < text.length && text[k] === ']' && closed < 2) { k++; closed++; } // marker ]]
    res.push({ matchStart, matchEnd: k, payload });
    re.lastIndex = k;
  }
  return res;
};

// Extracts ALL [[KEY: ...]] markers as a flat array (or null). Tolerant of
// whitespace, nesting, multiple blocks, Persian digits, comma-grouped numbers.
const _parseAIBlock = (text, key) => {
  const out = [];
  for (const mk of _scanMarkers(text, key)) {
    if (!mk.payload) continue;
    const p = _safeJSON(mk.payload);
    if (Array.isArray(p)) out.push(...p);
    else if (p && typeof p === 'object') out.push(p);
  }
  return out.length ? out : null;
};

// Removes every marker span from a string (same scanner → always agrees with
// _parseAIBlock). Deletes right-to-left so indices stay valid.
const _stripAIMarkers = (text) => {
  let t = text;
  for (const key of ['TASKS', 'FINANCE', 'KNOWLEDGE']) {
    const marks = _scanMarkers(t, key);
    for (let idx = marks.length - 1; idx >= 0; idx--) {
      t = t.slice(0, marks[idx].matchStart) + t.slice(marks[idx].matchEnd);
    }
  }
  // Sweep any colon-less orphan markers the scanner skips (e.g. "[[TASKS]]")
  t = t.replace(/\[\[\s*(?:TASKS|FINANCE|KNOWLEDGE)[^\]]*\]\]/g, '');
  return t.trim();
};

// Strips any incomplete or complete marker during streaming so the user never
// sees raw [[TASKS: blobs even while the model is mid-generation.
const _stripStreamingMarkers = (partial) => {
  // Remove complete markers first
  let s = _stripAIMarkers(partial);
  // Then cut off any incomplete marker still being generated (ws-tolerant,
  // and also a bare trailing "[[" that may be the start of one)
  s = s.replace(/\[\[\s*(?:TASKS|FINANCE|KNOWLEDGE)[\s\S]*$/, '').replace(/\[\[\s*$/, '').trim();
  return s || '…';
};

// Local intent extraction is a safety net for common Persian inputs. The model
// still answers and may emit richer markers, but obvious "۵۰ هزار قهوه خریدم"
// or "فردا ساعت ۶ جلسه دارم" should not fail to save just because nano omitted
// or malformed a JSON marker.
const _toAsciiDigits = (value) => String(value || '')
  .replace(/[۰-۹]/g, d => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
  .replace(/[٠-٩]/g, d => '٠١٢٣٤٥٦٧٨٩'.indexOf(d));

const _isoFromDate = (d) => [
  d.getFullYear(),
  String(d.getMonth() + 1).padStart(2, '0'),
  String(d.getDate()).padStart(2, '0'),
].join('-');

const _addDaysISO = (base, days) => {
  const d = new Date(base + 'T12:00:00');
  d.setDate(d.getDate() + days);
  return _isoFromDate(d);
};

const _normalizeISODate = (value, fallback) => {
  const v = _toAsciiDigits(value).trim();
  return /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : fallback;
};

const _relativeDateFromText = (text, todayKey) => {
  const s = String(text || '');
  if (/پس\s*فردا/.test(s)) return _addDaysISO(todayKey, 2);
  if (/فردا/.test(s)) return _addDaysISO(todayKey, 1);
  if (/دیروز/.test(s)) return _addDaysISO(todayKey, -1);
  if (/امروز/.test(s)) return todayKey;
  return todayKey;
};

const _normalizeTimeText = (value) => {
  const s = _toAsciiDigits(value).trim();
  const explicit = s.match(/\b([01]?\d|2[0-3])[:٫.]([0-5]\d)\b/);
  if (explicit) return `${explicit[1].padStart(2, '0')}:${explicit[2].padStart(2, '0')}`;
  const hourMatch = s.match(/(?:ساعت|حدود|راس|روی)\s*([01]?\d|2[0-3])(?:\s*(?:و|:|٫)\s*([0-5]?\d))?/);
  if (!hourMatch) return undefined;
  let h = Number(hourMatch[1]);
  const m = hourMatch[2] ? Number(hourMatch[2]) : 0;
  if (/(عصر|غروب|شب|بعدازظهر|بعد از ظهر)/.test(s) && h >= 1 && h <= 11) h += 12;
  if (/(ظهر)/.test(s) && h >= 1 && h <= 4) h += 12;
  if (!Number.isFinite(h) || !Number.isFinite(m) || h < 0 || h > 23 || m < 0 || m > 59) return undefined;
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};

const _parseMoneyAmount = (text) => {
  const s = _toAsciiDigits(text).replace(/[٬،]/g, ',');
  const m = s.match(/(\d[\d,\s.]*)\s*(میلیون|ملیون|هزار|تومن|تومان|ریال|m|k)?/i);
  if (!m) return 0;
  let n = Number(String(m[1]).replace(/[,\s]/g, ''));
  if (!Number.isFinite(n) || n <= 0) return 0;
  const unit = m[2] || '';
  if (/میلیون|ملیون|m/i.test(unit)) n *= 1000000;
  else if (/هزار|k/i.test(unit)) n *= 1000;
  else if (n > 0 && n < 1000 && /(خرج|خرید|پرداخت|هزینه|قسط|اجاره|حقوق|درآمد|تومن|تومان)/.test(s)) n *= 1000000;
  return Math.round(n);
};

const _financeCatFromText = (text) => {
  const s = String(text || '');
  if (/حقوق|دستمزد|فیش/.test(s)) return 'salary';
  if (/فریلنس|پروژه|درآمد/.test(s)) return 'freelance';
  if (/اجاره|کرایه/.test(s)) return 'rent';
  if (/قسط|وام|بدهی/.test(s)) return 'loan';
  if (/قبض|برق|آب|گاز|اینترنت|تلفن|شارژ/.test(s)) return 'utilities';
  if (/غذا|ناهار|شام|صبحانه|قهوه|نان|سوپر|خوراک|رستوران/.test(s)) return 'food';
  if (/خرید|لباس|کفش|وسیله|دیجی|بازار/.test(s)) return 'shop';
  return 'other';
};

const _financeTitleFromText = (text, cat) => {
  const s = String(text || '').trim();
  const known = ['قهوه', 'نان', 'ناهار', 'شام', 'صبحانه', 'اجاره', 'قسط', 'قبض برق', 'قبض آب', 'اینترنت', 'حقوق', 'خرید'];
  const hit = known.find(k => s.includes(k));
  if (hit) return hit;
  const fallback = { salary:'حقوق', freelance:'درآمد', rent:'اجاره', loan:'قسط', utilities:'قبض و خدمات', food:'خوراک', shop:'خرید', other:'هزینه' };
  return fallback[cat] || 'رویداد مالی';
};

const _extractLocalFinance = (text, todayKey) => {
  const s = String(text || '').trim();
  const hasFinanceCue = /(تومان|تومن|ریال|هزار|میلیون|ملیون|خرج|خرید|خریدم|پرداخت|دادم|هزینه|حقوق|درآمد|واریز|قسط|اجاره|قبض)/.test(s);
  if (!hasFinanceCue) return [];
  const amount = _parseMoneyAmount(s);
  if (amount <= 0) return [];
  const cat = _financeCatFromText(s);
  const income = /(حقوق|درآمد|واریز|دریافتی|گرفتم|فریلنس)/.test(s) && !/(خرج|خرید|خریدم|پرداخت|دادم|هزینه)/.test(s);
  const recurrence = /(ماهانه|هر\s*ماه|ماهی|ماهیانه)/.test(s) ? 'monthly'
    : /(سالانه|هر\s*سال|سالی)/.test(s) ? 'yearly' : 'none';
  return [{
    type: income ? 'income' : 'expense',
    cat,
    title: _financeTitleFromText(s, cat),
    amount,
    date: _relativeDateFromText(s, todayKey),
    recurrence,
  }];
};

const _taskTitleFromText = (text) => {
  const s = String(text || '').trim();
  const meeting = s.match(/(?:جلسه|قرار)\s*(?:با|برای)?\s*([^،.؟]*)/);
  if (meeting) return (`جلسه ${meeting[1] || ''}`).trim();
  const reminder = s.match(/(?:یادآوری\s*کن|یادم\s*بنداز|باید)\s*([^،.؟]*)/);
  if (reminder?.[1]) return reminder[1].trim().slice(0, 60);
  const call = s.match(/(?:زنگ\s*بزن|تماس\s*بگیر)\s*(?:به|با)?\s*([^،.؟]*)/);
  if (call) return (`تماس با ${call[1] || ''}`).trim();
  return s.replace(/^(لطفا|لطفاً|برام|برایم)\s*/, '').slice(0, 60);
};

const _extractLocalTask = (text, todayKey) => {
  const s = String(text || '').trim();
  const isPastFinance = /(خریدم|پرداخت کردم|خرج کردم|دادم)/.test(s) && _parseMoneyAmount(s) > 0 && !/(فردا|پس\s*فردا|یادآوری|یادم|باید|جلسه|قرار)/.test(s);
  if (isPastFinance) return [];
  const hasTaskCue = /(یادآوری\s*کن|یادم\s*بنداز|باید|جلسه|قرار|زنگ\s*بزن|تماس\s*بگیر|پیگیری|انجام\s*بدم)/.test(s);
  if (!hasTaskCue) return [];
  const title = _taskTitleFromText(s);
  if (!title || title.length < 2) return [];
  const dueDate = _relativeDateFromText(s, todayKey);
  const dueTime = _normalizeTimeText(s);
  const cat = /جلسه|قرار/.test(s) ? 'جلسه' : /یادآوری|یادم/.test(s) ? 'یادآوری' : 'تسک';
  const priority = /(فوری|ضروری|اورژانسی|خیلی مهم)/.test(s) ? 'urgent' : /(مهم)/.test(s) ? 'important' : 'normal';
  const recType = /(هر\s*روز|روزانه)/.test(s) ? 'daily'
    : /(هر\s*هفته|هفتگی)/.test(s) ? 'weekly'
    : /(هر\s*ماه|ماهانه|ماهی)/.test(s) ? 'monthly'
    : /(هر\s*سال|سالانه)/.test(s) ? 'yearly' : null;
  return [{
    title,
    dueDate,
    dueTime,
    cat,
    priority,
    status: 'pending',
    recurrence: recType ? { type: recType } : { type: 'none' },
    notes: s.length > title.length ? s : '',
  }];
};

const _extractLocalIntent = (text, todayKey) => ({
  tasks: _extractLocalTask(text, todayKey),
  finances: _extractLocalFinance(text, todayKey),
});

const _mergeRawItems = (modelItems = [], localItems = [], keyFn) => {
  const out = [];
  const seen = new Set();
  [...(modelItems || []), ...(localItems || [])].forEach(item => {
    if (!item) return;
    const key = keyFn(item);
    if (seen.has(key)) return;
    seen.add(key);
    out.push(item);
  });
  return out;
};

// Keep the system prompt at position 0 and cap history to the last 40 turns
// (20 user + 20 assistant) so long chats don't overflow the context window.
// Future improvement: summarize older turns instead of dropping them.
const MAX_HISTORY_TURNS = 40;

// ── Chat hygiene ──────────────────────────────────────────────
// Chats are day-scoped: a chat belongs to the day it started; past-day chats
// (and chats that hit the cap) are read-only archives. New input always lands
// in today's continuable chat (created on demand).
const MAX_CHAT_MESSAGES = 100;        // hard cap per conversation (user+assistant)
const MAX_CHAT_INPUT    = 2000;       // single-message length cap
const _dayKey = (ts = Date.now()) => {
  const d = new Date(ts);
  return [d.getFullYear(), String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0')].join('-');
};
const chatArchived = (chat) =>
  !chat || chat.dayKey !== _dayKey() || (chat.messages?.length || 0) >= MAX_CHAT_MESSAGES;

async function callAvalAI(history, sysMsg, onChunk) {
  const trimmed = history.slice(-MAX_HISTORY_TURNS);
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 60000);
  try {
    const res = await fetch(AVAL_URL, {
      method: 'POST',
      signal: controller.signal,
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${AVAL_KEY}` },
      body: JSON.stringify({ model: AVAL_MODEL, messages: [{ role: 'system', content: sysMsg }, ...trimmed], stream: !!onChunk }),
    });
    clearTimeout(timeoutId);
    if (!res.ok) { const e = new Error('API_' + res.status); e.kind = 'api'; throw e; }

    if (!onChunk || !res.body) {
      const data = await res.json();
      return data.choices[0].message.content;
    }

    const reader  = res.body.getReader();
    const decoder = new TextDecoder();
    let full = '', buf = '';
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      const lines = buf.split('\n'); buf = lines.pop();
      for (const line of lines) {
        const d = line.trim();
        if (!d.startsWith('data:')) continue;
        const payload = d.slice(5).trim();
        if (payload === '[DONE]') continue;
        try {
          const delta = JSON.parse(payload).choices?.[0]?.delta?.content;
          if (delta) { full += delta; onChunk(full); }
        } catch {}
      }
    }
    return full || '…';
  } catch (err) {
    clearTimeout(timeoutId);
    if (err.name === 'AbortError') { const e = new Error('پاسخ دریافت نشد. دوباره تلاش کنید'); e.kind = 'timeout'; throw e; }
    throw err;
  }
}

const _appAsset = (name) => window.assetPath ? window.assetPath(name) : `assets/${name}`;

const appShellStyles = {
  // ── Mobile ──────────────────────────────────────────────────
  screen: {
    position: 'fixed', inset: 0,
    background: 'var(--paper)',
    backgroundImage: `linear-gradient(135deg, rgba(42,157,143,0.08), transparent 32%), linear-gradient(315deg, rgba(255,172,31,0.10), transparent 26%), url(${_appAsset('paper-grain.svg')})`,
    backgroundSize: 'auto, auto, 200px 200px',
    display: 'flex', flexDirection: 'column',
    overflow: 'hidden',
    color: 'var(--ink)',
  },
  screenNoGrain: { backgroundImage: 'none' },

  // ── Desktop shell ────────────────────────────────────────────
  desktopShell: {
    position: 'fixed', inset: 0,
    display: 'flex', flexDirection: 'row',
    direction: 'rtl',
    overflow: 'hidden',
    color: 'var(--ink)',
    background: 'var(--paper)',
    backgroundImage: `linear-gradient(135deg, rgba(42,157,143,0.08), transparent 34%), linear-gradient(315deg, rgba(255,172,31,0.08), transparent 28%), url(${_appAsset('paper-grain.svg')})`,
    backgroundSize: 'auto, auto, 220px 220px',
  },

  // ── Sidebar ──────────────────────────────────────────────────
  sidebar: {
    width: 240, flexShrink: 0,
    display: 'flex', flexDirection: 'column',
    background: 'var(--surface-glass)',
    borderInlineStart: 'var(--hairline)',
    height: '100%',
    boxShadow: 'var(--shadow-lift)',
    backdropFilter: 'blur(18px) saturate(145%)',
    WebkitBackdropFilter: 'blur(18px) saturate(145%)',
  },
  sidebarHeader: {
    display: 'flex', alignItems: 'center', gap: 10,
    padding: '24px 20px 18px',
    borderBottom: 'var(--hairline)',
    direction: 'rtl',
  },
  sidebarMark: { width: 34, height: 34, opacity: 0.98 },
  sidebarAppName: {
    font: "600 17px/1 'Estedad', sans-serif",
    color: 'var(--ink)',
    letterSpacing: 0,
  },
  sidebarNav: {
    flex: 1,
    padding: '10px 10px',
    display: 'flex', flexDirection: 'column', gap: 2,
    overflowY: 'auto',
  },
  sidebarItem: {
    display: 'flex', alignItems: 'center', gap: 12,
    padding: '11px 14px',
    borderRadius: 12, border: 'none',
    background: 'transparent', color: 'var(--ink-2)',
    font: "500 15px 'Estedad', sans-serif",
    cursor: 'pointer', textAlign: 'right', direction: 'rtl',
    transition: 'background 160ms var(--ease-out), color 160ms var(--ease-out), transform 160ms var(--ease-out)',
  },
  sidebarItemActive: {
    background: 'var(--firoozeh-soft)', color: 'var(--firoozeh-deep)',
    boxShadow: 'inset -3px 0 0 var(--firoozeh)',
  },
  sidebarFooter: {
    padding: '14px 12px 20px',
    borderTop: 'var(--hairline)',
    display: 'flex', flexDirection: 'column', gap: 8,
    direction: 'rtl',
  },
  sidebarBtn: {
    display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
    padding: '11px 16px', borderRadius: 999,
    font: "500 14px 'Estedad', sans-serif",
    border: 'none', cursor: 'pointer', width: '100%',
    direction: 'rtl',
  },
  sidebarBtnPrimary: { background: 'var(--firoozeh)', color: '#fff' },
  sidebarBtnGhost: {
    background: 'transparent', color: 'var(--ink-2)',
    border: '1px solid var(--c-border-sm)',
  },

  // ── Desktop main area ────────────────────────────────────────
  desktopMain: {
    flex: 1, display: 'flex', flexDirection: 'column',
    overflow: 'hidden', minWidth: 0,
    background: 'var(--paper)',
    backgroundImage: `linear-gradient(135deg, rgba(42,157,143,0.05), transparent 34%), linear-gradient(315deg, rgba(255,172,31,0.07), transparent 28%), url(${_appAsset('paper-grain.svg')})`,
    backgroundSize: 'auto, auto, 200px 200px',
    direction: 'rtl',
  },
  desktopContent: {
    flex: 1, minHeight: 0,
    position: 'relative', overflow: 'hidden',
    maxWidth: 780, width: '100%',
    alignSelf: 'center',
  },
};

function DesktopSidebar({ active, onChange, onCompose }) {
  const { Icons } = window;
  const tabs = [
    { key: 'home',      label: 'خانه',       Icon: Icons.Home },
    { key: 'tasks',     label: 'کارها',      Icon: Icons.CheckCircle },
    { key: 'knowledge', label: 'حافظه',       Icon: Icons.Book },
    { key: 'finance',   label: 'چرتکه',      Icon: Icons.Wallet },
    { key: 'assistant', label: 'دستیار',     Icon: Icons.Chat },
    { key: 'profile',   label: 'پروفایل',    Icon: Icons.User },
  ];
  return (
    <aside style={appShellStyles.sidebar}>
      <div style={appShellStyles.sidebarHeader}>
        <img src={_appAsset('mark.svg?v=logi-functional-v2-20260604')} style={appShellStyles.sidebarMark} alt="" />
        <span style={appShellStyles.sidebarAppName}>لاگی | Logi</span>
      </div>
      <nav style={appShellStyles.sidebarNav}>
        {tabs.map(t => {
          const isActive = active === t.key;
          return (
            <button key={t.key}
              style={{ ...appShellStyles.sidebarItem, ...(isActive ? appShellStyles.sidebarItemActive : {}) }}
              onClick={() => onChange(t.key)}
              aria-current={isActive ? 'page' : undefined}
            >
              <t.Icon size={20} />
              {t.label}
            </button>
          );
        })}
      </nav>
      <div style={appShellStyles.sidebarFooter}>
        <button
          style={{ ...appShellStyles.sidebarBtn, ...appShellStyles.sidebarBtnPrimary }}
          onClick={() => onCompose('listening')}
        >
          <Icons.Mic size={18} /> ضبط صدا
        </button>
        <button
          style={{ ...appShellStyles.sidebarBtn, ...appShellStyles.sidebarBtnGhost }}
          onClick={() => onCompose('typing')}
        >
          <Icons.Type size={16} /> تایپ کن
        </button>
      </div>
    </aside>
  );
}

// Global confirmation bottom-sheet. Call window.confirmAction(message, onConfirm)
// from anywhere to require a confirm tap before a destructive action.
function ConfirmHost() {
  const [state, setState] = React.useState(null);
  React.useEffect(() => {
    window.confirmAction = (message, onConfirm, confirmLabel) => setState({ message, onConfirm, confirmLabel });
    return () => { delete window.confirmAction; };
  }, []);
  if (!state) return null;
  const close = () => setState(null);
  return (
    <div
      style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', zIndex: 900, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', direction: 'rtl' }}
      onClick={close}
    >
      <div
        style={{ width: '100%', maxWidth: 600, background: 'var(--paper)', borderRadius: '22px 22px 0 0', padding: '24px 20px calc(24px + env(safe-area-inset-bottom))', boxShadow: '0 -8px 32px rgba(0,0,0,0.18)' }}
        onClick={e => e.stopPropagation()}
      >
        <div style={{ font: "600 16px 'Estedad', sans-serif", color: 'var(--ink)', textAlign: 'center', marginBottom: 20 }}>
          {state.message}
        </div>
        <div style={{ display: 'flex', gap: 10 }}>
          <button
            style={{ flex: 1, padding: '13px', borderRadius: 999, border: '1px solid var(--c-border-sm)', background: 'var(--surface)', color: 'var(--ink-2)', cursor: 'pointer', font: "500 15px 'Estedad', sans-serif" }}
            onClick={close}
          >انصراف</button>
          <button
            style={{ flex: 1, padding: '13px', borderRadius: 999, border: 'none', background: '#B41E1E', color: '#fff', cursor: 'pointer', font: "600 15px 'Estedad', sans-serif" }}
            onClick={() => { const fn = state.onConfirm; close(); fn && fn(); }}
          >{state.confirmLabel || 'حذف'}</button>
        </div>
      </div>
    </div>
  );
}

// Bottom bar shown after a voice/text capture from anywhere in the app.
// Drives the in-place "spoke → saved" experience without dragging the user to chat.
function CaptureBar({ result, onUndo, onDismiss, onSeeReply, onRetry }) {
  const { Icons } = window;
  const faNum = n => String(n).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d]);
  const wrap = {
    position: 'fixed', bottom: 90, left: '50%', transform: 'translateX(-50%)',
    zIndex: 600, maxWidth: 400, width: 'calc(100% - 32px)',
    background: 'var(--ink)', color: 'var(--paper)',
    borderRadius: 16, padding: '12px 14px',
    boxShadow: '0 4px 24px rgba(0,0,0,0.28)',
    direction: 'rtl',
    animation: 'captureIn 0.22s ease',
  };
  const ghostBtn = {
    padding: '7px 12px', borderRadius: 999, border: '1px solid rgba(255,255,255,0.22)',
    background: 'transparent', color: 'var(--paper)', cursor: 'pointer',
    font: "500 12px 'Estedad', sans-serif", whiteSpace: 'nowrap',
  };
  const linkBtn = {
    padding: '7px 12px', borderRadius: 999, border: 'none',
    background: 'var(--firoozeh)', color: '#fff', cursor: 'pointer',
    font: "600 12px 'Estedad', sans-serif", whiteSpace: 'nowrap',
  };
  const clip = s => s && s.length > 40 ? s.slice(0, 40) + '…' : s;
  const css = `@keyframes captureIn{from{opacity:0;transform:translate(-50%,12px)}to{opacity:1;transform:translate(-50%,0)}}
@keyframes captureSpin{to{transform:rotate(360deg)}}`;

  // ── Processing ──────────────────────────────────────────────
  if (result.status === 'processing') {
    return (
      <div style={wrap}>
        <style>{css}</style>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <span style={{
            width: 16, height: 16, flexShrink: 0, borderRadius: '50%',
            border: '2px solid rgba(255,255,255,0.25)', borderTopColor: 'var(--firoozeh)',
            animation: 'captureSpin 0.7s linear infinite',
          }} />
          <div style={{ minWidth: 0 }}>
            <div style={{ font: "600 13px 'Estedad', sans-serif" }}>در حال ثبت…</div>
            {result.transcript && (
              <div style={{ font: "400 11px 'Estedad', sans-serif", opacity: 0.6, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                «{clip(result.transcript)}»
              </div>
            )}
          </div>
        </div>
      </div>
    );
  }

  // ── Error ───────────────────────────────────────────────────
  if (result.status === 'error') {
    return (
      <div style={wrap}>
        <style>{css}</style>
        <div style={{ font: "500 13px 'Estedad', sans-serif", marginBottom: 10 }}>
          {result.message || 'ثبت نشد.'}
        </div>
        <div style={{ display: 'flex', gap: 8 }}>
          <button onClick={onRetry} style={{ ...linkBtn, flex: 1 }}>تلاش مجدد</button>
          <button onClick={onDismiss} style={ghostBtn}>بستن</button>
        </div>
      </div>
    );
  }

  // ── Done ────────────────────────────────────────────────────
  const { tasks = [], finances = [], knowledge = [] } = result.saved || {};
  const total = tasks.length + finances.length + knowledge.length;
  const parts = [];
  if (tasks.length)     parts.push(`${faNum(tasks.length)} کار`);
  if (finances.length)  parts.push(`${faNum(finances.length)} رکورد مالی`);
  if (knowledge.length) parts.push(`${faNum(knowledge.length)} یادداشت`);
  const firstTitle = (tasks[0] || finances[0] || knowledge[0])?.title;

  // Nothing saved, but the assistant replied — pure Q&A turn
  if (total === 0) {
    return (
      <div style={wrap}>
        <style>{css}</style>
        <div style={{ font: "500 13px 'Estedad', sans-serif", marginBottom: result.reply ? 10 : 0 }}>
          {result.reply ? 'دستیار پاسخ داد.' : 'چیزی برای ثبت پیدا نشد.'}
        </div>
        <div style={{ display: 'flex', gap: 8 }}>
          {result.reply && <button onClick={onSeeReply} style={{ ...linkBtn, flex: 1 }}>دیدن پاسخ دستیار ←</button>}
          <button onClick={onDismiss} style={result.reply ? ghostBtn : { ...ghostBtn, flex: 1 }}>باشه</button>
        </div>
      </div>
    );
  }

  return (
    <div style={wrap}>
      <style>{css}</style>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
        <span style={{ color: 'var(--firoozeh)', display: 'flex', flexShrink: 0 }}><Icons.Check size={18} /></span>
        <div style={{ minWidth: 0 }}>
          <div style={{ font: "600 13px 'Estedad', sans-serif" }}>{parts.join(' و ')} ثبت شد</div>
          {firstTitle && (
            <div style={{ font: "400 11px 'Estedad', sans-serif", opacity: 0.65, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
              {clip(firstTitle)}{total > 1 ? ` +${faNum(total - 1)}` : ''}
            </div>
          )}
        </div>
      </div>
      <div style={{ display: 'flex', gap: 8 }}>
        <button onClick={onUndo} style={ghostBtn}>لغو</button>
        {result.reply
          ? <button onClick={onSeeReply} style={{ ...linkBtn, flex: 1 }}>دیدن پاسخ دستیار ←</button>
          : <button onClick={onDismiss} style={{ ...ghostBtn, flex: 1 }}>باشه</button>}
      </div>
    </div>
  );
}

function ToastBar({ msg }) {
  return (
    <div style={{
      position: 'fixed', bottom: 90, left: '50%', transform: 'translateX(-50%)',
      zIndex: 600, maxWidth: 340, width: 'calc(100% - 32px)',
      background: 'var(--ink)', color: 'var(--paper)',
      borderRadius: 12, padding: '12px 16px',
      font: "500 14px 'Estedad', sans-serif",
      textAlign: 'center', direction: 'rtl',
      boxShadow: '0 4px 24px rgba(0,0,0,0.22)',
      pointerEvents: 'none',
    }}>
      {msg}
    </div>
  );
}

function RewardBurst({ reward }) {
  if (!reward) return null;
  const fa = n => String(n ?? 0).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d]);
  const sparks = [
    [-56, -22, 0], [-34, 30, 70], [44, -26, 110],
    [60, 22, 150], [8, -46, 40], [-6, 44, 120],
  ];
  const label = reward.leveled
    ? `سطح ${fa((reward.level || 1))}`
    : reward.focusBonus
      ? 'پاداش تمرکز'
      : reward.streakBonus
        ? 'زنجیره'
        : 'برد ثبت شد';
  return (
    <div className="sb-reward-burst" aria-live="polite">
      <div className="sb-reward-chip">
        <span>+{fa(reward.gain)} XP</span>
        <span className="sb-reward-sub">{label}</span>
      </div>
      {sparks.map(([x, y, d], i) => (
        <i
          key={i}
          className="sb-reward-spark"
          style={{ '--spark-x': `${x}px`, '--spark-y': `${y}px`, '--spark-delay': `${d}ms` }}
        />
      ))}
    </div>
  );
}

function GlobalSearch({ tasks, knowledge, finances, onClose, onGoto }) {
  const { Icons } = window;
  const [q, setQ] = React.useState('');
  const inputRef = React.useRef(null);
  React.useEffect(() => { inputRef.current?.focus(); }, []);

  const results = React.useMemo(() => {
    const s = q.trim().toLowerCase();
    if (!s) return [];
    const out = [];
    tasks.filter(t => t.title.toLowerCase().includes(s) || (t.notes || '').toLowerCase().includes(s))
      .slice(0, 5).forEach(t => out.push({ kind: 'task', icon: 'CheckCircle', label: t.title, sub: t.cat, tab: 'tasks', id: t.id }));
    knowledge.filter(k => k.title.toLowerCase().includes(s) || (k.body || '').toLowerCase().includes(s))
      .slice(0, 5).forEach(k => out.push({ kind: 'knowledge', icon: 'Book', label: k.title, sub: k.body?.slice(0, 60), tab: 'knowledge', id: k.id }));
    finances.filter(f => f.title.toLowerCase().includes(s))
      .slice(0, 3).forEach(f => out.push({ kind: 'finance', icon: 'Wallet', label: f.title, sub: `${f.amount.toLocaleString('fa-IR')} تومان`, tab: 'finance', id: f.id }));
    return out;
  }, [q, tasks, knowledge, finances]);

  return (
    <div
      style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', zIndex: 800, direction: 'rtl', display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 60 }}
      onClick={onClose}
    >
      <div
        style={{ width: '100%', maxWidth: 560, background: 'var(--paper)', borderRadius: 20, overflow: 'hidden', boxShadow: '0 12px 40px rgba(0,0,0,0.28)', margin: '0 16px' }}
        onClick={e => e.stopPropagation()}
      >
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid rgba(27,26,23,0.08)' }}>
          <Icons.Search size={18} style={{ color: 'var(--ink-3)', flexShrink: 0 }} />
          <input
            ref={inputRef}
            value={q}
            onChange={e => setQ(e.target.value)}
            placeholder="جستجو در تسک‌ها، حافظه، مالی…"
            style={{ flex: 1, border: 'none', background: 'transparent', outline: 'none', font: "400 16px 'Estedad', sans-serif", color: 'var(--ink)', direction: 'rtl', textAlign: 'right' }}
          />
          <button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--ink-3)', padding: 2, flexShrink: 0 }}>
            <Icons.X size={18} />
          </button>
        </div>
        {results.length > 0 && (
          <div style={{ maxHeight: 360, overflowY: 'auto' }}>
            {results.map((r, i) => {
              const Ic = Icons[r.icon];
              return (
                <button key={i}
                  onClick={() => { onGoto(r.tab, r.id); onClose(); }}
                  style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', background: 'transparent', border: 'none', borderBottom: '1px solid rgba(27,26,23,0.05)', cursor: 'pointer', direction: 'rtl', textAlign: 'right' }}
                >
                  <span style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--surface)', display: 'grid', placeItems: 'center', flexShrink: 0 }}>
                    {Ic && <Ic size={18} />}
                  </span>
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div style={{ font: "500 14px 'Estedad', sans-serif", color: 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.label}</div>
                    {r.sub && <div style={{ font: "400 12px 'Estedad', sans-serif", color: 'var(--ink-3)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.sub}</div>}
                  </div>
                </button>
              );
            })}
          </div>
        )}
        {q.trim() && results.length === 0 && (
          <div style={{ padding: '28px 16px', textAlign: 'center', color: 'var(--ink-3)', font: "400 14px 'Estedad', sans-serif" }}>
            نتیجه‌ای یافت نشد
          </div>
        )}
        {!q.trim() && (
          <div style={{ padding: '20px 16px', textAlign: 'center', color: 'var(--ink-3)', font: "400 13px 'Estedad', sans-serif" }}>
            شروع به تایپ کن…
          </div>
        )}
      </div>
    </div>
  );
}

function App({ tweaks, setTweak }) {
  const { TopBar, IconButton, TabBar, VoiceComposer,
          Home, Tasks, Knowledge, Log, Assistant, Profile, Settings, Icons, SBData } = window;

  const [isDesktop, setIsDesktop] = React.useState(
    () => window.matchMedia(DESKTOP_MQ).matches
  );
  React.useEffect(() => {
    const mq = window.matchMedia(DESKTOP_MQ);
    const handler = e => setIsDesktop(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  const [active, setActive]       = React.useState('home');
  const [openConvId, setOpenConvId] = React.useState(null);
  const [clockTime, setClockTime] = React.useState(
    () => new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date())
  );
  React.useEffect(() => {
    const tick = () => setClockTime(new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date()));
    const id = setInterval(tick, 60000);
    return () => clearInterval(id);
  }, []);
  const [composer, setComposer]   = React.useState('collapsed');
  const [tasks, setTasks]               = React.useState(() => DB.load('tasks', []).map(migrateTask));
  const tasksRef = React.useRef(tasks);
  tasksRef.current = tasks;
  const [projects, setProjects]         = React.useState(() => DB.load('projects', []));
  const [conversations, setConversations] = React.useState(() => DB.load('conversations', []));
  const [knowledge, setKnowledge]       = React.useState(() => DB.load('knowledge', []));
  const knowledgeRef = React.useRef(knowledge);
  knowledgeRef.current = knowledge;
  const [finances, setFinances]         = React.useState(() => DB.load('finances', []));
  const financesRef = React.useRef(finances);
  financesRef.current = finances;
  const [finAddOpen, setFinAddOpen]     = React.useState(false);
  const [taskSortOpen, setTaskSortOpen] = React.useState(false);
  const [highlightId, setHighlightId]   = React.useState(null);
  const [kAddOpen, setKAddOpen]         = React.useState(false);
  React.useEffect(() => { DB.save('finances', finances); }, [finances]);
  const addFinance    = item => setFinances(prev => [item, ...prev]);
  const deleteFinance = id   => setFinances(prev => prev.filter(f => f.id !== id));
  const updateFinance = (id, fields) => setFinances(prev => prev.map(f => f.id === id ? { ...f, ...fields } : f));
  const [typing, setTyping]       = React.useState(false);
  const [settingsOpen, setSettingsOpen] = React.useState(false);
  const [showSearch,   setShowSearch]   = React.useState(false);
  const notifiedRef = React.useRef(new Set());

  // ── Multi-chat state ──────────────────────────────────────────
  const initChats = () => {
    const saved = DB.load('chats', null);
    if (saved && saved.length > 0) {
      // Backfill day-scope on legacy chats (their last-updated day)
      return saved.map(c => ({ ...c, dayKey: c.dayKey || _dayKey(c.updatedAt || c.createdAt || Date.now()) }));
    }
    const oldMsgs    = DB.load('messages', SBData?.assistant || []);
    const oldHistory = DB.load('apiHistory', []);
    return [{
      id: 'chat-default',
      title: 'گفتگوی پیش‌فرض',
      dayKey: _dayKey(),
      createdAt: Date.now(),
      updatedAt: Date.now(),
      messages: (Array.isArray(oldMsgs) ? oldMsgs : []).map((m, i) => ({
        ...m, id: m.id || `m-s-${i}`,
        timestamp: m.timestamp || (Date.now() - 1000 * 60 * ((oldMsgs.length || 0) - i)),
      })),
      apiHistory: Array.isArray(oldHistory) ? oldHistory : [],
    }];
  };
  const [chats, setChats]             = React.useState(initChats);
  const [activeChatId, setActiveChatId] = React.useState(() => {
    const saved = DB.load('chats', null);
    return (saved && saved.length > 0) ? saved[saved.length - 1].id : 'chat-default';
  });
  const chatsRef = React.useRef(chats);
  chatsRef.current = chats;

  // Persist every state slice to local DB on change
  React.useEffect(() => { DB.save('tasks',         tasks);         }, [tasks]);
  React.useEffect(() => { DB.save('projects',      projects);      }, [projects]);
  React.useEffect(() => { DB.save('conversations', conversations); }, [conversations]);
  React.useEffect(() => { DB.save('knowledge',     knowledge);     }, [knowledge]);
  React.useEffect(() => { DB.save('chats',         chats);          }, [chats]);

  // ── Toast notifications ───────────────────────────────────────
  const [toast, setToast] = React.useState(null);
  const showToast = (msg, ms = 3500) => {
    setToast(msg);
    setTimeout(() => setToast(null), ms);
  };
  React.useEffect(() => {
    DB.onError(msg => showToast(msg, 4000));
  }, []);

  // ── Gamification (XP + levels) ────────────────────────────────
  const [gamify, setGamify] = React.useState(() => normalizeGamify(DB.load('gamify', DEFAULT_GAMIFY)));
  const [rewardBurst, setRewardBurst] = React.useState(null);
  React.useEffect(() => { DB.save('gamify', gamify); }, [gamify]);
  React.useEffect(() => {
    if (!rewardBurst) return;
    const t = setTimeout(() => setRewardBurst(null), 1750);
    return () => clearTimeout(t);
  }, [rewardBurst]);
  // Award XP once per task id (so toggling done off/on doesn't farm points).
  const awardXP = (taskId, priority) => {
    setGamify(prev => {
      const { gamify: next, event } = awardGamifyTask(prev, taskId, priority);
      if (!event) return next;
      const nextLevel = levelInfo(next.xp).level;
      setRewardBurst({ id: `${taskId}-${Date.now()}`, ...event, level: nextLevel });
      if (event.leveled) {
        const li = levelInfo(next.xp);
        setTimeout(() => showToast(`🎉 به سطح ${toFaPad(li.level)} رسیدی — ${li.title}!`, 4200), 350);
      } else {
        const bits = [`+${toFaPad(event.gain)} XP`];
        if (event.focusBonus) bits.push('پاداش تمرکز');
        if (event.streakBonus) bits.push(`رکورد ${toFaPad(event.streak)} روزه`);
        else if (event.streak > 1 && event.dayCount === 1) bits.push(`${toFaPad(event.streak)} روز پشت‌سرهم`);
        setTimeout(() => showToast(bits.join(' · '), 2600), 120);
      }
      return next;
    });
  };

  // ── User profile (PRD-007) ────────────────────────────────────
  const [profile, setProfile] = React.useState(() => DB.load('profile', { name: SBData?.user?.name || 'کاربر', avatar: SBData?.user?.avatar || 'ک' }));
  React.useEffect(() => { DB.save('profile', profile); }, [profile]);
  const updateProfile = (fields) => setProfile(prev => {
    const next = { ...prev, ...fields };
    if (fields.name) next.avatar = (fields.name.trim()[0] || 'ک');
    return next;
  });

  // ── Onboarding ────────────────────────────────────────────────
  const [onboarded, setOnboarded] = React.useState(() => DB.load('onboarded', false));
  const [firstRunFlow, setFirstRunFlow] = React.useState('welcome'); // welcome | signup | signup-auth | login
  const [pendingOnboarding, setPendingOnboarding] = React.useState(null);
  // Seed many tasks at once (used by onboarding routine selection).
  const seedTasks = (newTasks) => setTasks(prev => {
    const next = [...newTasks, ...prev];
    DB.save('tasks', next);
    return next;
  });
  const seedFinances = (newItems) => setFinances(prev => {
    const valid = (newItems || []).filter(f => f && f.amount > 0 && f.title && f.date);
    if (!valid.length) return prev;
    const seen = new Set(prev.map(f => [f.type, f.cat, f.title, f.amount, f.recurrence || 'none'].join('|')));
    const fresh = valid.filter(f => !seen.has([f.type, f.cat, f.title, f.amount, f.recurrence || 'none'].join('|')));
    if (!fresh.length) return prev;
    const next = [...fresh, ...prev];
    DB.save('finances', next);
    return next;
  });
  const finishOnboarding = ({ tasks: seed = [], finances: finSeed = [], profile: onbProfile } = {}) => {
    const seedList = Array.isArray(seed) ? seed : [];
    if (seedList.length) {
      const current = DB.load('tasks', tasksRef.current || []).map(migrateTask);
      const next = [...seedList, ...current];
      DB.save('tasks', next);
      setTasks(next);
    }

    const validFinances = (Array.isArray(finSeed) ? finSeed : []).filter(f => f && f.amount > 0 && f.title && f.date);
    if (validFinances.length) {
      const current = DB.load('finances', financesRef.current || []);
      const seen = new Set(current.map(f => [f.type, f.cat, f.title, f.amount, f.recurrence || 'none'].join('|')));
      const fresh = validFinances.filter(f => !seen.has([f.type, f.cat, f.title, f.amount, f.recurrence || 'none'].join('|')));
      if (fresh.length) {
        const next = [...fresh, ...current];
        DB.save('finances', next);
        setFinances(next);
      }
    }

    if (onbProfile) {
      const currentProfile = DB.load('profile', profile || {});
      const next = { ...currentProfile, ...onbProfile };
      DB.save('profile', next);
      setProfile(next);
    }

    const baseGamify = normalizeGamify(DB.load('gamify', gamify));
    const nextGamify = { ...baseGamify, xp: baseGamify.xp + XP_WELCOME };
    DB.save('gamify', nextGamify);
    setGamify(nextGamify);
    DB.save('onboarded', true);
    setOnboarded(true);
  };
  const finishSignupOnboarding = (payload = {}) => {
    setPendingOnboarding(payload || {});
    setFirstRunFlow('signup-auth');
  };
  const gLevel = levelInfo(gamify.xp);
  const todayDoneCount = Number(gamify.days?.[_gameDayKey()]) || 0;
  const nextFocusAt = todayDoneCount > 0 && todayDoneCount % 3 === 0 ? 3 : 3 - (todayDoneCount % 3);
  const gamifyView = {
    xp: gamify.xp,
    streak: gamify.streak || 0,
    bestStreak: gamify.bestStreak || 0,
    todayDoneCount,
    nextFocusAt: todayDoneCount > 0 && todayDoneCount % 3 === 0 ? 3 : nextFocusAt,
    lastGain: gamify.lastGain || null,
    ...gLevel,
  };

  // ── Account / backend session ─────────────────────────────────
  const [account, setAccount] = React.useState(() => ({ user: DB.load('authUser', null), sub: DB.load('authSub', null) }));
  const [authOpen, setAuthOpen] = React.useState(false);

  // Refresh session + subscription on mount when a token exists.
  React.useEffect(() => {
    if (!(window.Api && window.Api.isAuthed())) return;
    window.Api.me().then(r => {
      DB.save('authUser', r.user); DB.save('authSub', r.subscription);
      setAccount({ user: r.user, sub: r.subscription });
    }).catch(e => {
      if (e && e.status === 401) { window.Api.setToken(null); DB.remove('authUser'); DB.remove('authSub'); setAccount({ user: null, sub: null }); }
    });
  }, []);

  const onAuthSuccess = async (r, meta = {}) => {
    const cleanName = String(meta.name || '').trim();
    let user = r?.user || null;
    try {
      window.Api.setToken(r.token);
      if (cleanName && window.Api.updateProfile) {
        try {
          const updated = await window.Api.updateProfile(cleanName, cleanName[0] || 'ک');
          if (updated?.user) user = updated.user;
        } catch {}
      }
      DB.save('authUser', user || null);
      DB.save('authSub', r.subscription || null);
    } catch {}

    try { await DB.pull(); } catch {}

    if (meta.intent === 'signup' && pendingOnboarding) {
      finishOnboarding({
        ...pendingOnboarding,
        profile: {
          ...(pendingOnboarding.profile || {}),
          name: cleanName || pendingOnboarding.profile?.name || 'کاربر',
          avatar: (cleanName || pendingOnboarding.profile?.name || 'کاربر')[0] || 'ک',
        },
      });
    } else if (meta.markOnboarded) {
      DB.save('onboarded', true);
      setOnboarded(true);
    }

    if (cleanName) {
      const currentProfile = DB.load('profile', profile || {});
      const nextProfile = { ...currentProfile, name: cleanName, avatar: cleanName[0] || currentProfile.avatar || 'ک' };
      DB.save('profile', nextProfile);
      setProfile(nextProfile);
    }

    try {
      if (window.Api?.isAuthed?.()) {
        DB.markAllDirty?.();
        await DB.pushNow?.();
      }
    } catch {}
    window.location.reload(); // re-init all state from the merged localStorage
  };

  const signOut = () => {
    window.confirmAction('از حساب خارج می‌شوی؟ داده‌های این دستگاه باقی می‌مانند.', async () => {
      try { await window.Api.logout(); } catch {}
      window.Api.setToken(null);
      DB.remove('authUser'); DB.remove('authSub');
      window.location.reload();
    }, 'خروج');
  };

  const upgrade = async () => {
    try {
      const r = await window.Api.activate();
      if (r.ok) { DB.save('authSub', r.subscription); setAccount(a => ({ ...a, sub: r.subscription })); showToast('اشتراک فعال شد ✓'); }
      else showToast(r.message || 'درگاه پرداخت هنوز فعال نیست');
    } catch (e) { showToast(e.message || 'خطا در ارتقا'); }
  };


  React.useEffect(() => {
    const today = new Date();
    const todayKey = [today.getFullYear(), String(today.getMonth()+1).padStart(2,'0'), String(today.getDate()).padStart(2,'0')].join('-');
    const loaded = DB.load('finances', []);
    const toAdd = [];
    loaded.forEach(f => {
      if (!f.recurrence || f.recurrence === 'none') return;
      const siblings = loaded.filter(s => s.title === f.title && s.type === f.type && s.recurrence === f.recurrence)
        .sort((a, b) => b.date.localeCompare(a.date));
      if (siblings[0]?.id !== f.id) return;
      if (f.date >= todayKey) return;
      const d = new Date(f.date + 'T12:00:00');
      let nextDate;
      if (f.recurrence === 'monthly') {
        const n = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate());
        nextDate = [n.getFullYear(), String(n.getMonth()+1).padStart(2,'0'), String(n.getDate()).padStart(2,'0')].join('-');
      } else if (f.recurrence === 'yearly') {
        const n = new Date(d.getFullYear() + 1, d.getMonth(), d.getDate());
        nextDate = [n.getFullYear(), String(n.getMonth()+1).padStart(2,'0'), String(n.getDate()).padStart(2,'0')].join('-');
      }
      if (nextDate && nextDate <= todayKey) {
        const allItems = [...loaded, ...toAdd];
        if (!allItems.some(s => s.date === nextDate && s.title === f.title && s.type === f.type)) {
          toAdd.push({ ...f, id: `fin-${Date.now()}-r${toAdd.length}`, date: nextDate });
        }
      }
    });
    if (toAdd.length > 0) setFinances(prev => [...toAdd, ...prev]);
  }, []);

  // ── DEF-006: Push notification check every 60s ────────────────
  React.useEffect(() => {
    const check = () => {
      if (Notification?.permission !== 'granted') return;
      const now = new Date();
      const nowKey = [now.getFullYear(), String(now.getMonth()+1).padStart(2,'0'), String(now.getDate()).padStart(2,'0')].join('-');
      const hh = now.getHours(), mm = now.getMinutes();
      const nowTime = `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`;
      // PRD-006: optional lead-time reminder (0/15/30/60 min before due)
      const lead = parseInt(DB.load('notifLead', 0), 10) || 0;
      tasksRef.current.forEach(t => {
        if (t.done || t.status === 'cancelled') return;
        if (t.dueDate !== nowKey || !t.dueTime) return;
        // exact-time reminder
        const nid = `${t.id}-${nowKey}-${t.dueTime}`;
        if (t.dueTime === nowTime && !notifiedRef.current.has(nid)) {
          notifiedRef.current.add(nid);
          new Notification('لاگی — یادآوری تسک', { body: t.title, icon: _appAsset('favicon.svg'), dir: 'rtl', lang: 'fa' });
        }
        // lead-time reminder ("در X دقیقه دیگر: ...")
        if (lead > 0) {
          const [dh, dm] = t.dueTime.split(':').map(Number);
          const leadMin = dh * 60 + dm - lead;
          if (leadMin >= 0) {
            const leadTime = `${String(Math.floor(leadMin / 60)).padStart(2,'0')}:${String(leadMin % 60).padStart(2,'0')}`;
            const lid = `${t.id}-${nowKey}-lead${lead}`;
            if (leadTime === nowTime && !notifiedRef.current.has(lid)) {
              notifiedRef.current.add(lid);
              new Notification('لاگی — یادآوری تسک', { body: `در ${toFaPad(lead)} دقیقه دیگر: ${t.title}`, icon: _appAsset('favicon.svg'), dir: 'rtl', lang: 'fa' });
            }
          }
        }
      });
    };
    check();
    const id = setInterval(check, 60000);
    return () => clearInterval(id);
  }, []);

  // ── Capture flow: voice/text auto-saves in place, no chat detour ──
  // captureResult drives the bottom CaptureBar. Shape:
  //   { status: 'processing'|'done'|'error', transcript,
  //     saved: { tasks, finances, knowledge },   // counts actually saved
  //     snapshot: { tasks, finances, knowledge }, // pre-save arrays for undo
  //     reply, chatId }
  const [captureResult, setCaptureResult] = React.useState(null);
  const captureTimerRef = React.useRef(null);

  const clearCaptureTimer = () => {
    if (captureTimerRef.current) { clearTimeout(captureTimerRef.current); captureTimerRef.current = null; }
  };
  // Auto-dismiss a settled capture bar after a grace window (undo stays live until then)
  const scheduleCaptureDismiss = (ms = 7000) => {
    clearCaptureTimer();
    captureTimerRef.current = setTimeout(() => setCaptureResult(null), ms);
  };

  // Core save logic (shared by capture + any confirm path). Mutates state and
  // returns the count of items actually saved per kind.
  const applyExtractions = (newTasks = [], newFins = [], newKnow = []) => {
    const saved = { tasks: [], finances: [], knowledge: [] };

    // Tasks: upsert by title — if a pending/in-progress task with same title exists, update it
    if (newTasks.length > 0) {
      let updated = [...(tasksRef.current || [])];
      newTasks.forEach(nt => {
        const normTitle = nt.title.trim().toLowerCase();
        const existIdx = updated.findIndex(ex =>
          ex.title.trim().toLowerCase() === normTitle &&
          ex.status !== 'done' && ex.status !== 'cancelled'
        );
        if (existIdx >= 0) {
          const ex = updated[existIdx];
          // Merge: keep id/project, overwrite with any new (non-empty) detail the user added
          const mergedStatus = nt.status && nt.status !== 'pending' ? nt.status : ex.status;
          const merged = {
            ...ex,
            dueDate:    nt.dueDate    || ex.dueDate,
            dueTime:    nt.dueTime    || ex.dueTime,
            cat:        nt.cat        || ex.cat,
            sub:        nt.sub        || ex.sub,
            section:    nt.section    || ex.section,
            priority:   nt.priority && nt.priority !== 'normal' ? nt.priority : ex.priority,
            status:     mergedStatus,
            done:       mergedStatus === 'done',
            recurrence: nt.recurrence || ex.recurrence,
            notes:      nt.notes      || ex.notes,
          };
          updated[existIdx] = merged;
          saved.tasks.push(merged);
        } else {
          updated = [nt, ...updated];
          saved.tasks.push(nt);
        }
      });
      tasksRef.current = updated;
      setTasks(updated);
    }

    // Finance: dedup by title+amount+date+type (no upsert — amounts must be exact)
    if (newFins.length > 0) {
      const prev = financesRef.current || [];
      const fresh = newFins.filter(nf => !prev.some(ex =>
        ex.title === nf.title && ex.amount === nf.amount && ex.date === nf.date && ex.type === nf.type
      ));
      if (fresh.length > 0) {
        const next = [...fresh, ...prev];
        saved.finances = fresh;
        financesRef.current = next;
        setFinances(next);
      }
    }

    // Knowledge: dedup by title+body
    if (newKnow.length > 0) {
      const prev = knowledgeRef.current || [];
      const fresh = newKnow.filter(nk => !prev.some(ex => ex.title === nk.title && ex.body === nk.body));
      if (fresh.length > 0) {
        const next = [...fresh, ...prev];
        saved.knowledge = fresh;
        knowledgeRef.current = next;
        setKnowledge(next);
      }
    }

    return saved;
  };

  // Undo: restore the exact arrays captured just before the auto-save
  const undoCapture = () => {
    clearCaptureTimer();
    setCaptureResult(cur => {
      if (cur?.snapshot) {
        tasksRef.current = cur.snapshot.tasks;
        financesRef.current = cur.snapshot.finances;
        knowledgeRef.current = cur.snapshot.knowledge;
        setTasks(cur.snapshot.tasks);
        setFinances(cur.snapshot.finances);
        setKnowledge(cur.snapshot.knowledge);
      }
      return null;
    });
  };

  const dismissCapture = () => { clearCaptureTimer(); setCaptureResult(null); };

  // Jump to the chat that holds the conversational reply for this capture
  const seeReplyInChat = () => {
    setCaptureResult(cur => {
      if (cur?.chatId) setActiveChatId(cur.chatId);
      return null;
    });
    clearCaptureTimer();
    setActive('assistant');
  };

  const goto = (tab) => {
    setActive(tab);
    if (composer !== 'collapsed') setComposer('collapsed');
  };

  const openInLog = (convId) => {
    setOpenConvId(convId);
    goto('knowledge');
  };

  const openComposer = (mode = 'listening') => setComposer(mode);

  const toggleTask = (id) => {
    const ts = tasksRef.current;
    const task = ts.find(t => t.id === id);
    if (!task) return;
    const becomingDone = (task.status ? task.status !== 'done' : !task.done);
    if (becomingDone) awardXP(task.id, task.priority || 'normal'); // gamification
    const updated = ts.map(t => t.id === id
      ? { ...t, done: becomingDone, status: becomingDone ? 'done' : 'pending' }
      : t);
    if (becomingDone && task.recurrence?.type && task.recurrence.type !== 'none') {
      const nextDate = window._calcNextRecurrence?.(task);
      if (nextDate) {
        const now = Date.now();
        const d = new Date();
        const todayKey = [d.getFullYear(), String(d.getMonth()+1).padStart(2,'0'), String(d.getDate()).padStart(2,'0')].join('-');
        const nextTask = {
          ...task, id: `t-${now}`, done: false, status: 'pending', dueDate: nextDate,
          section: nextDate <= todayKey ? 'today' : 'upcoming',
          createdAt: now, sourceConvId: undefined,
        };
        setTasks([nextTask, ...updated]);
        const fmt = new Intl.DateTimeFormat('fa-IR', { month: 'long', day: 'numeric' });
        showToast(`↻ تکرار بعدی: ${fmt.format(new Date(nextDate + 'T12:00:00'))}`);
        return;
      }
    }
    setTasks(updated);
  };

  const updateTask = (id, fields) => {
    const task = tasksRef.current.find(t => t.id === id);
    const normalized = fields.status && fields.done === undefined
      ? { ...fields, done: fields.status === 'done' }
      : fields;
    if (task) {
      const wasDone = task.status ? task.status === 'done' : !!task.done;
      const nextStatus = normalized.status !== undefined ? normalized.status : task.status;
      const nextDone = normalized.done !== undefined ? normalized.done : task.done;
      const willDone = nextStatus ? nextStatus === 'done' : !!nextDone;
      if (!wasDone && willDone) awardXP(task.id, task.priority || 'normal');
    }
    setTasks(ts => ts.map(t => t.id === id ? { ...t, ...normalized } : t));
  };

  const deleteTask = (id) =>
    setTasks(ts => ts.filter(t => t.id !== id));

  const addProject = (proj) =>
    setProjects(ps => [...ps, { ...proj, id: `p-${Date.now()}`, createdAt: Date.now() }]);

  const updateProject = (id, fields) =>
    setProjects(ps => ps.map(p => p.id === id ? { ...p, ...fields } : p));

  const deleteProject = (id) => {
    setProjects(ps => ps.filter(p => p.id !== id));
    setTasks(ts => ts.map(t => t.projectId === id ? { ...t, projectId: undefined } : t));
  };

  const _newChat = (title = 'گفتگوی جدید') => ({
    id: 'chat-' + Date.now(), title, dayKey: _dayKey(),
    createdAt: Date.now(), updatedAt: Date.now(), messages: [], apiHistory: [],
  });

  const createChat = () => {
    // Reuse an empty today chat if one already exists, else make a fresh one
    const empty = (chatsRef.current || []).find(c => c.dayKey === _dayKey() && c.messages.length === 0);
    if (empty) { setActiveChatId(empty.id); return; }
    const c = _newChat();
    setChats(prev => [...prev, c]);
    setActiveChatId(c.id);
  };

  // Open today's continuable chat (used by the archived-chat banner)
  const startTodayChat = () => {
    const today = _dayKey();
    const cont = (chatsRef.current || []).find(c => c.dayKey === today && c.messages.length < MAX_CHAT_MESSAGES);
    if (cont) { setActiveChatId(cont.id); return; }
    const c = _newChat('گفتگوی امروز');
    setChats(prev => [...prev, c]);
    setActiveChatId(c.id);
  };

  // On load, if the active chat is an archive (past day / capped), switch to an
  // existing continuable today chat when one exists. If none exists we leave it
  // archived so the assistant shows the "start today's chat" banner — avoids
  // creating throwaway empty chats on days the user doesn't chat.
  React.useEffect(() => {
    const a = (chatsRef.current || []).find(c => c.id === activeChatId);
    if (!chatArchived(a)) return;
    const today = _dayKey();
    const cont = (chatsRef.current || []).find(c => c.dayKey === today && c.messages.length < MAX_CHAT_MESSAGES);
    if (cont) setActiveChatId(cont.id);
  }, []);

  const deleteChat = (id) => {
    const next = chats.filter(c => c.id !== id);
    if (next.length === 0) {
      const fresh = _newChat();
      setChats([fresh]);
      setActiveChatId(fresh.id);
      return;
    }
    setChats(next);
    if (id === activeChatId) {
      const latest = next.reduce((a, b) => a.updatedAt > b.updatedAt ? a : b);
      setActiveChatId(latest.id);
    }
  };

  const addTask = (task) =>
    setTasks(ts => [{ id: 't-' + Date.now(), done: false, status: 'pending', priority: 'normal', createdAt: Date.now(), ...task }, ...ts]);

  const starLog = (id) =>
    setConversations(cs => cs.map(c => c.id === id ? { ...c, starred: !c.starred } : c));

  const deleteLog = (id) =>
    setConversations(cs => cs.filter(c => c.id !== id));

  const addKnowledgeItem = (item) =>
    setKnowledge(prev => [item, ...prev]);

  // PRD-002: keep card links bidirectional (and capped at 10) — adding B to A's
  // links also adds A to B's; removing one prunes the reverse side too.
  const MAX_LINKS = 10;
  const updateKnowledgeItem = (id, fields) =>
    setKnowledge(prev => {
      let next = prev.map(k => k.id === id ? { ...k, ...fields } : k);
      if (Object.prototype.hasOwnProperty.call(fields, 'linkedIds')) {
        const want = (fields.linkedIds || []).filter(x => x !== id).slice(0, MAX_LINKS);
        next = next.map(k => {
          if (k.id === id) return { ...k, linkedIds: want };
          const links = new Set(k.linkedIds || []);
          if (want.includes(k.id)) links.add(id); else links.delete(id);
          return { ...k, linkedIds: [...links].slice(0, MAX_LINKS) };
        });
      }
      return next;
    });

  const deleteKnowledgeItem = (id) =>
    setKnowledge(prev => prev
      .filter(k => k.id !== id)
      .map(k => k.linkedIds?.includes(id) ? { ...k, linkedIds: k.linkedIds.filter(x => x !== id) } : k));

  const toFaPad = n => String(n).replace(/\d/g, d => '۰۱۲۳۴۵۶۷۸۹'[d]);

  const sendToAI = async (userText, chatId, passedApiHistory, convId, userMsgId, capture = false) => {
    const newHistory = [...passedApiHistory, { role: 'user', content: userText }];
    setTyping(true);
    // In capture mode, snapshot the data arrays now so Undo can restore them after auto-save
    const snapshot = capture
      ? { tasks: tasksRef.current, finances: financesRef.current, knowledge: knowledgeRef.current }
      : null;

    const streamMsgId = 'm-stream-' + Date.now();
    setChats(prev => prev.map(c => c.id === chatId ? {
      ...c,
      messages: [...c.messages, { id: streamMsgId, from: 'them', text: '…', timestamp: Date.now(), streaming: true }],
      updatedAt: Date.now(),
    } : c));

    const _dForLocal = new Date();
    const localTodayKey = [_dForLocal.getFullYear(), String(_dForLocal.getMonth()+1).padStart(2,'0'), String(_dForLocal.getDate()).padStart(2,'0')].join('-');
    const localIntent = _extractLocalIntent(userText, localTodayKey);

    try {
      let reply = await callAvalAI(newHistory, getSystemMsg(finances, tasks, knowledge, projects), (partial) => {
        setChats(prev => prev.map(c => c.id === chatId ? {
          ...c,
          messages: c.messages.map(m => m.id === streamMsgId ? { ...m, text: _stripStreamingMarkers(partial) } : m),
        } : c));
      });

      // Extract structured data blocks — handles both [array] and {object} model output
      const parsedTasks = _mergeRawItems(
        _parseAIBlock(reply, 'TASKS') || [],
        localIntent.tasks,
        t => `${(t.title || '').trim().toLowerCase()}|${t.dueDate || ''}|${t.dueTime || ''}`
      );
      const parsedFins  = _mergeRawItems(
        _parseAIBlock(reply, 'FINANCE') || [],
        localIntent.finances,
        f => `${f.type || ''}|${f.cat || ''}|${(f.title || '').trim().toLowerCase()}|${f.amount || ''}|${f.date || ''}`
      );
      const parsedKnow  = _parseAIBlock(reply, 'KNOWLEDGE');

      const pendingTasks = [];
      const pendingFins  = [];
      const pendingKnow  = [];
      let extractFailed = false; // F02: track silent extraction failures to surface a toast

      if (parsedTasks) {
        try {
          const extracted = parsedTasks;
          if (Array.isArray(extracted) && extracted.length > 0) {
            const now = Date.now();
            const _d = new Date();
            const todayKey = [_d.getFullYear(), String(_d.getMonth()+1).padStart(2,'0'), String(_d.getDate()).padStart(2,'0')].join('-');
            extracted.forEach((t, i) => {
              const dueDate = _normalizeISODate(t.dueDate, todayKey);
              // Normalize the rich fields the model may emit, validating against the data model
              const priority = ['urgent', 'important', 'normal'].includes(t.priority) ? t.priority : 'normal';
              const status   = ['pending', 'in-progress', 'done', 'cancelled'].includes(t.status) ? t.status : 'pending';
              const cat = ['جلسه', 'یادآوری', 'ایده', 'تسک'].includes(t.cat) ? t.cat : 'تسک';
              const dueTime = _normalizeTimeText(t.dueTime || '');
              const recType  = ['daily', 'weekly', 'monthly', 'yearly'].includes(t.recurrence?.type) ? t.recurrence.type : null;
              const recurrence = recType
                ? (recType === 'monthly' && Number(t.recurrence.jalaliDay) >= 1 && Number(t.recurrence.jalaliDay) <= 31
                    ? { type: 'monthly', jalaliDay: Number(t.recurrence.jalaliDay) }
                    : { type: recType })
                : undefined;
              const notes = typeof t.notes === 'string' ? t.notes.trim() : '';
              const title = (t.title || '').trim();
              if (!title) return; // F03: skip useless title-less tasks
              pendingTasks.push({
                id: `t-${now}-${i}`,
                title,
                sub: 'از دستیار',
                cat,
                dueDate,
                dueTime,
                section: dueDate <= todayKey ? 'today' : 'upcoming',
                done: status === 'done',
                status,
                priority,
                recurrence,
                notes: notes || undefined,
                createdAt: now,
                sourceConvId: convId,
              });
            });
            // Tag the user message with the tasks actually saved
            if (pendingTasks.length > 0) setConversations(prev => prev.map(c =>
              c.id === convId
                ? { ...c, messages: c.messages.map(m =>
                    m.id === userMsgId
                      ? { ...m, extracted: pendingTasks.map(t => ({ kind: 'task', label: t.title })) }
                      : m
                  )}
                : c
            ));
          }
        } catch (err) { extractFailed = true; console.error('[extract] TASKS failed:', err); }
      }
      let finNote = '';
      if (parsedFins) {
        try {
          const extracted = parsedFins;
          if (Array.isArray(extracted) && extracted.length > 0) {
            const _d = new Date();
            const todayKey = [_d.getFullYear(), String(_d.getMonth()+1).padStart(2,'0'), String(_d.getDate()).padStart(2,'0')].join('-');
            const catLabels = { salary:'حقوق', freelance:'فریلنس', rent:'کرایه', loan:'قسط وام', utilities:'قبوض', food:'خوراک', shop:'خرید', other:'سایر' };
            const validCats = ['salary', 'freelance', 'rent', 'loan', 'utilities', 'food', 'shop', 'other'];
            extracted.forEach((f, i) => {
              const cat = validCats.includes(f.cat) ? f.cat : 'other';
              // tolerate Persian digits + thousands separators in the amount
              const amount = typeof f.amount === 'number'
                ? Math.round(f.amount)
                : (_parseMoneyAmount(f.amount) || parseInt(_toAsciiDigits(String(f.amount || 0)).replace(/[,،٬\s]/g, ''), 10) || 0);
              if (amount <= 0) return; // F03: a finance row with no amount is meaningless
              const recurrence = ['monthly', 'yearly'].includes(f.recurrence) ? f.recurrence : 'none';
              pendingFins.push({
                id: `fin-${Date.now()}-${i}`,
                type: f.type === 'income' ? 'income' : 'expense',
                cat,
                // F03: never store a blank title — fall back to the category label
                title: (f.title || '').trim() || (catLabels[cat] || 'رویداد مالی'),
                amount,
                date: _normalizeISODate(f.date, todayKey),
                recurrence,
              });
            });
          }
        } catch (err) { extractFailed = true; console.error('[extract] FINANCE failed:', err); }
      }
      let knowNote = '';
      if (parsedKnow) {
        try {
          const extracted = parsedKnow;
          if (Array.isArray(extracted) && extracted.length > 0) {
            const kindIcon = { note: 'Doc', meeting: 'Calendar', learning: 'Book', doc: 'Doc', personal: 'Heart' };
            const time = new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date());
            const newCards = extracted
              .filter(k => (k.title || '').trim())
              .map((k, i) => ({
                id: `k-${Date.now()}-${i}`,
                kind: kindIcon[k.kind] ? k.kind : 'note',
                icon: kindIcon[k.kind] || 'Doc',
                title: (k.title || '').trim(),
                body: k.body || '',
                tags: Array.isArray(k.tags) ? k.tags : [],
                meta: `از دستیار · ${time}`,
              }));
            newCards.forEach(c => pendingKnow.push(c));
            if (newCards.length > 0) {
              setConversations(prev => prev.map(c =>
                c.id === convId
                  ? { ...c, messages: c.messages.map(m =>
                      m.id === userMsgId
                        ? { ...m, extracted: [...(m.extracted || []), ...newCards.map(k => ({ kind: 'knowledge', label: k.title }))] }
                        : m
                    )}
                  : c
              ));
            }
          }
        } catch (err) { extractFailed = true; console.error('[extract] KNOWLEDGE failed:', err); }
      }

      // F02: a marker was present but failed to save — tell the user instead of silently dropping it
      if (extractFailed) showToast('بخشی از اطلاعات استخراج‌شده ذخیره نشد');

      // Strip all markers from the visible reply using the robust helper
      reply = _stripAIMarkers(reply);

      // Auto-save extractions immediately (no manual confirmation gate)
      const candidateExtractions = pendingTasks.length > 0 || pendingFins.length > 0 || pendingKnow.length > 0;
      const savedExtractions = candidateExtractions
        ? applyExtractions(pendingTasks, pendingFins, pendingKnow)
        : { tasks: [], finances: [], knowledge: [] };
      const hasSavedExtractions = savedExtractions.tasks.length > 0 ||
        savedExtractions.finances.length > 0 ||
        savedExtractions.knowledge.length > 0;

      if (convId && userMsgId && candidateExtractions) {
        const savedBadges = [
          ...savedExtractions.tasks.map(t => ({ kind: 'task', label: t.title })),
          ...savedExtractions.finances.map(f => ({ kind: 'finance', label: f.title })),
          ...savedExtractions.knowledge.map(k => ({ kind: 'knowledge', label: k.title })),
        ];
        setConversations(prev => prev.map(c =>
          c.id === convId
            ? { ...c, messages: c.messages.map(m => m.id === userMsgId ? { ...m, extracted: savedBadges } : m) }
            : c
        ));
      }

      if (hasSavedExtractions && !/(ثبت|ذخیره|اضافه)/.test(reply || '')) {
        const savedBits = [
          savedExtractions.tasks.length ? `${toFaPad(savedExtractions.tasks.length)} کار` : '',
          savedExtractions.finances.length ? `${toFaPad(savedExtractions.finances.length)} رویداد مالی` : '',
          savedExtractions.knowledge.length ? `${toFaPad(savedExtractions.knowledge.length)} یادداشت` : '',
        ].filter(Boolean).join('، ');
        reply = `ثبت شد: ${savedBits}.${reply ? `\n${reply}` : ''}`;
      } else if (candidateExtractions && !hasSavedExtractions && /(ثبت|ذخیره|اضافه)/.test(reply || '')) {
        reply = 'این مورد قبلاً ثبت شده بود و مورد تازه‌ای اضافه نشد.';
      }

      // In capture mode, surface the result in the bottom CaptureBar (user stays in place)
      if (capture) {
        // Show the "see reply" affordance only when the assistant said something
        // beyond the items it saved (i.e. a real conversational message)
        const meaningfulReply = reply && reply.trim().length > 0;
        setCaptureResult({
          status: 'done',
          transcript: userText,
          saved: savedExtractions,
          snapshot,
          reply: meaningfulReply ? reply : '',
          chatId,
        });
        scheduleCaptureDismiss(hasSavedExtractions ? 7000 : 9000);
      }

      // Add AI reply to conversation Log (only if called from processInput with a convId)
      const aiNow = Date.now();
      const faTimeAI = new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date(aiNow));
      if (convId) {
        const aiMsgLog = { id: 'm-ai-' + aiNow, role: 'assistant', text: reply, time: faTimeAI, timestamp: aiNow };
        setConversations(prev => prev.map(c =>
          c.id === convId ? { ...c, messages: [...c.messages, aiMsgLog], lastTimestamp: aiNow } : c
        ));
      }

      // Append AI reply to the active chat
      const taskNote = [
        savedExtractions.tasks.length > 0 ? `کار: ${savedExtractions.tasks.map(t => t.title).join('، ')}` : '',
        savedExtractions.finances.length > 0 ? `مالی: ${savedExtractions.finances.map(f => f.title).join('، ')}` : '',
        savedExtractions.knowledge.length > 0 ? `دانش: ${savedExtractions.knowledge.map(k => k.title).join('، ')}` : '',
      ].filter(Boolean).join(' | ');
      setChats(prev => prev.map(c => c.id === chatId ? {
        ...c,
        messages: c.messages.map(m => m.id === streamMsgId
          ? { id: 'm-ai-' + aiNow, from: 'them', text: reply, timestamp: aiNow }
          : m
        ),
        apiHistory: [...newHistory, { role: 'assistant', content: taskNote ? `${reply}\n[ثبت شد: ${taskNote}]` : reply }],
        updatedAt: aiNow,
      } : c));
    } catch (e) {
      const errNow = Date.now();
      const fallbackTasks = [];
      const fallbackFins = [];

      (localIntent.tasks || []).forEach((t, i) => {
        const title = (t.title || '').trim();
        if (!title) return;
        const dueDate = _normalizeISODate(t.dueDate, localTodayKey);
        const status = ['pending', 'in-progress', 'done', 'cancelled'].includes(t.status) ? t.status : 'pending';
        const priority = ['urgent', 'important', 'normal'].includes(t.priority) ? t.priority : 'normal';
        const cat = ['جلسه', 'یادآوری', 'ایده', 'تسک'].includes(t.cat) ? t.cat : 'تسک';
        const recType = ['daily', 'weekly', 'monthly', 'yearly'].includes(t.recurrence?.type) ? t.recurrence.type : null;
        fallbackTasks.push({
          id: `t-${errNow}-${i}`,
          title,
          sub: 'از دستیار',
          cat,
          dueDate,
          dueTime: _normalizeTimeText(t.dueTime || ''),
          section: dueDate <= localTodayKey ? 'today' : 'upcoming',
          done: status === 'done',
          status,
          priority,
          recurrence: recType ? { type: recType } : undefined,
          notes: typeof t.notes === 'string' && t.notes.trim() ? t.notes.trim() : undefined,
          createdAt: errNow,
          sourceConvId: convId,
        });
      });

      (localIntent.finances || []).forEach((f, i) => {
        const amount = typeof f.amount === 'number'
          ? Math.round(f.amount)
          : (_parseMoneyAmount(f.amount) || parseInt(_toAsciiDigits(String(f.amount || 0)).replace(/[,،٬\s]/g, ''), 10) || 0);
        if (amount <= 0) return;
        const validCats = ['salary', 'freelance', 'rent', 'loan', 'utilities', 'food', 'shop', 'other'];
        const cat = validCats.includes(f.cat) ? f.cat : 'other';
        const catLabels = { salary:'حقوق', freelance:'فریلنس', rent:'کرایه', loan:'قسط وام', utilities:'قبوض', food:'خوراک', shop:'خرید', other:'سایر' };
        fallbackFins.push({
          id: `fin-${errNow}-${i}`,
          type: f.type === 'income' ? 'income' : 'expense',
          cat,
          title: (f.title || '').trim() || (catLabels[cat] || 'رویداد مالی'),
          amount,
          date: _normalizeISODate(f.date, localTodayKey),
          recurrence: ['monthly', 'yearly'].includes(f.recurrence) ? f.recurrence : 'none',
        });
      });

      const hasLocalFallback = fallbackTasks.length > 0 || fallbackFins.length > 0;
      const savedFallback = hasLocalFallback
        ? applyExtractions(fallbackTasks, fallbackFins, [])
        : { tasks: [], finances: [], knowledge: [] };
      const hasSavedFallback = savedFallback.tasks.length > 0 || savedFallback.finances.length > 0;

      if (hasSavedFallback) {
        const savedBits = [
          savedFallback.tasks.length ? `${toFaPad(savedFallback.tasks.length)} کار` : '',
          savedFallback.finances.length ? `${toFaPad(savedFallback.finances.length)} رویداد مالی` : '',
        ].filter(Boolean).join('، ');
        const text = `ارتباط با مدل برقرار نشد، اما ${savedBits} را از متن تشخیص دادم و ثبت کردم.`;
        const fallbackBadges = [
          ...savedFallback.tasks.map(t => ({ kind: 'task', label: t.title })),
          ...savedFallback.finances.map(f => ({ kind: 'finance', label: f.title })),
        ];

        if (convId && userMsgId) {
          const faTimeAI = new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date(errNow));
          const aiMsgLog = { id: 'm-ai-' + errNow, role: 'assistant', text, time: faTimeAI, timestamp: errNow };
          setConversations(prev => prev.map(c =>
            c.id === convId
              ? {
                  ...c,
                  messages: [
                    ...c.messages.map(m => m.id === userMsgId ? { ...m, extracted: fallbackBadges } : m),
                    aiMsgLog,
                  ],
                  lastTimestamp: errNow,
                }
              : c
          ));
        }

        const taskNote = [
          savedFallback.tasks.length > 0 ? `کار: ${savedFallback.tasks.map(t => t.title).join('، ')}` : '',
          savedFallback.finances.length > 0 ? `مالی: ${savedFallback.finances.map(f => f.title).join('، ')}` : '',
        ].filter(Boolean).join(' | ');
        setChats(prev => prev.map(c => c.id === chatId ? {
          ...c,
          messages: c.messages.map(m => m.id === streamMsgId
            ? { id: 'm-ai-' + errNow, from: 'them', text, timestamp: errNow }
            : m
          ),
          apiHistory: [...newHistory, { role: 'assistant', content: taskNote ? `${text}\n[ثبت شد: ${taskNote}]` : text }],
          updatedAt: errNow,
        } : c));

        if (capture) {
          setCaptureResult({
            status: 'done',
            transcript: userText,
            saved: savedFallback,
            snapshot,
            reply: text,
            chatId,
          });
          scheduleCaptureDismiss(7000);
        }
        return;
      }

      if (hasLocalFallback) {
        const text = 'ارتباط با مدل برقرار نشد. مورد قابل تشخیص در برنامه قبلاً ثبت شده بود و مورد تازه‌ای اضافه نشد.';
        if (convId && userMsgId) {
          const faTimeAI = new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(new Date(errNow));
          const aiMsgLog = { id: 'm-ai-' + errNow, role: 'assistant', text, time: faTimeAI, timestamp: errNow };
          setConversations(prev => prev.map(c =>
            c.id === convId
              ? { ...c, messages: [...c.messages.map(m => m.id === userMsgId ? { ...m, extracted: [] } : m), aiMsgLog], lastTimestamp: errNow }
              : c
          ));
        }
        setChats(prev => prev.map(c => c.id === chatId ? {
          ...c,
          messages: c.messages.map(m => m.id === streamMsgId
            ? { id: 'm-ai-' + errNow, from: 'them', text, timestamp: errNow }
            : m
          ),
          apiHistory: [...newHistory, { role: 'assistant', content: text }],
          updatedAt: errNow,
        } : c));
        if (capture) {
          setCaptureResult({
            status: 'done',
            transcript: userText,
            saved: { tasks: [], finances: [], knowledge: [] },
            snapshot,
            reply: text,
            chatId,
          });
          scheduleCaptureDismiss(9000);
        }
        return;
      }

      const isNetwork = e?.kind !== 'api'; // fetch failures (TypeError) vs API status errors
      const text = isNetwork
        ? 'ارتباط با سرور برقرار نشد. اتصال اینترنتت رو بررسی کن.'
        : 'خطا در دریافت پاسخ از دستیار. لطفاً دوباره تلاش کن.';
      setChats(prev => prev.map(c => c.id === chatId ? {
        ...c,
        messages: c.messages.map(m => m.id === streamMsgId
          ? { id: 'm-err-' + errNow, from: 'them', text, timestamp: errNow, error: true, retry: { userText, passedApiHistory, convId, userMsgId, capture } }
          : m
        ),
        updatedAt: errNow,
      } : c));
      // In capture mode there's no visible chat — surface the failure in the CaptureBar
      if (capture) {
        setCaptureResult({ status: 'error', transcript: userText, message: text, chatId,
          retry: { userText, passedApiHistory, convId, userMsgId } });
      }
    } finally {
      setTyping(false);
    }
  };

  // Retry a failed AI turn: drop the error bubble and re-send the same user
  // message (without re-adding it to the conversation).
  const retryMessage = (chatId, errMsgId) => {
    const chat = chatsRef.current?.find(c => c.id === chatId);
    const errMsg = chat?.messages.find(m => m.id === errMsgId);
    if (!errMsg?.retry) return;
    const { userText, passedApiHistory, convId, userMsgId, capture } = errMsg.retry;
    setChats(prev => prev.map(c => c.id === chatId
      ? { ...c, messages: c.messages.filter(m => m.id !== errMsgId) }
      : c));
    sendToAI(userText, chatId, passedApiHistory, convId, userMsgId, capture);
  };

  // Retry a failed capture (from the CaptureBar error state)
  const retryCapture = () => {
    setCaptureResult(cur => {
      if (cur?.retry) {
        const { userText, passedApiHistory, convId, userMsgId } = cur.retry;
        clearCaptureTimer();
        sendToAI(userText, cur.chatId, passedApiHistory, convId, userMsgId, true);
        return { status: 'processing', transcript: userText };
      }
      return cur;
    });
  };

  // Core: add to conversation log + chat UI + AI — used by all input paths.
  // capture=true → the user spoke/typed from outside the chat; auto-save + stay in place.
  const processInput = (text, kind, duration, capture = false) => {
    const nowDate = new Date();
    const now = nowDate.getTime();
    const dateKey = [
      nowDate.getFullYear(),
      String(nowDate.getMonth() + 1).padStart(2, '0'),
      String(nowDate.getDate()).padStart(2, '0'),
    ].join('-');
    const faDateLabel = new Intl.DateTimeFormat('fa-IR', { weekday: 'long', month: 'long', day: 'numeric' }).format(nowDate);
    const faTime = new Intl.DateTimeFormat('fa-IR', { hour: '2-digit', minute: '2-digit' }).format(nowDate);

    const msgId = 'm-' + now;
    const userMsg = {
      id: msgId,
      role: 'user',
      text,
      kind: kind || 'text',
      dur: duration ? `${toFaPad(duration)} ث` : null,
      time: faTime,
      timestamp: now,
      extracted: [],
    };

    const convId = 'conv-' + dateKey;
    setConversations(prev => {
      const existing = prev.find(c => c.id === convId);
      if (existing) {
        return prev.map(c => c.id === convId
          ? { ...c, messages: [...c.messages, userMsg], lastTimestamp: now }
          : c
        );
      }
      return [
        { id: convId, dateKey, dateLabel: faDateLabel, timestamp: now, lastTimestamp: now, starred: false, messages: [userMsg] },
        ...prev,
      ];
    });

    // Resolve a continuable chat for today (rolls over on a new day or when the
    // 100-message cap is hit; the previous chat becomes a read-only archive).
    const chatsNow = chatsRef.current || [];
    const todayK = _dayKey();
    let target = chatsNow.find(c => c.id === activeChatId && c.dayKey === todayK && c.messages.length < MAX_CHAT_MESSAGES)
      || chatsNow.find(c => c.dayKey === todayK && c.messages.length < MAX_CHAT_MESSAGES);
    const isNewChat = !target;
    const chatId = target ? target.id : ('chat-' + now);
    const currentApiHistory = target?.apiHistory || [];
    const chatUserMsg = {
      id: msgId, from: 'me', text, timestamp: now,
      meta: kind === 'voice' ? `صدای ${toFaPad(duration || 5)} ثانیه‌ای · ${faTime}` : null,
    };
    const titleFromText = text.slice(0, 35) + (text.length > 35 ? '…' : '');
    if (isNewChat) {
      setChats(prev => [...prev, {
        id: chatId, dayKey: todayK, title: titleFromText,
        createdAt: now, updatedAt: now, messages: [chatUserMsg], apiHistory: [],
      }]);
      setActiveChatId(chatId);
    } else {
      setChats(prev => prev.map(c => c.id === chatId ? {
        ...c,
        messages: [...c.messages, chatUserMsg],
        title: c.messages.filter(m => m.from === 'me').length === 0 ? titleFromText : c.title,
        updatedAt: now,
      } : c));
      if (activeChatId !== chatId) setActiveChatId(chatId);
    }

    sendToAI(text, chatId, currentApiHistory, convId, msgId, capture);
  };

  // From VoiceComposer / FAB / Home — capture in place, no chat detour.
  // Show an immediate "processing" bar so the user gets feedback while the AI works.
  const submitInput = (text, kind, duration) => {
    clearCaptureTimer();
    setCaptureResult({ status: 'processing', transcript: text, kind: kind || 'text' });
    processInput(text, kind, duration, true);
  };

  // From Assistant's own chat input (already on assistant tab — normal chat flow)
  const askAssistant = (q) => processInput(q, 'text', undefined, false);

  const searchBtn = <IconButton ariaLabel="جستجو" onClick={() => setShowSearch(true)}><Icons.Search size={20} /></IconButton>;
  const settingsBtn = <IconButton ariaLabel="تنظیمات" onClick={() => setSettingsOpen(true)}><Icons.Gear size={20} /></IconButton>;
  const topBar = {
    home:      { title: (() => { const h = new Date().getHours(); const n = profile.name || 'کاربر'; return h < 12 ? `صبح بخیر، ${n}` : h < 17 ? `روز بخیر، ${n}` : `شب بخیر، ${n}`; })(), subtitle: `${SBData.date}،‌ ${clockTime}`, showMark: !isDesktop, actions: <>{searchBtn}{settingsBtn}</> },
    tasks:     { title: 'کارها',     subtitle: `${toFaPad(tasks.filter(t => !t.done && t.section === 'today').length)} مورد امروز`, actions: <>{searchBtn}<IconButton ariaLabel="مرتب" onClick={() => setTaskSortOpen(true)}><Icons.Filter size={20} /></IconButton>{settingsBtn}</> },
    knowledge: { title: 'حافظه', subtitle: `${toFaPad(knowledge.length)} مورد`, actions: <>{searchBtn}<IconButton ariaLabel="افزودن" onClick={() => setKAddOpen(true)}><Icons.Plus size={20} /></IconButton>{settingsBtn}</> },
    finance:   { title: 'چرتکه', subtitle: 'رویدادهای مالی', actions: <>{searchBtn}<IconButton ariaLabel="افزودن" onClick={() => setFinAddOpen(true)}><Icons.Plus size={20} /></IconButton>{settingsBtn}</> },
    assistant: { title: 'دستیار',    subtitle: 'بپرس از حافظهٔ خودت', actions: <>{searchBtn}{settingsBtn}</> },
    profile:   { title: 'پروفایل', subtitle: `${toFaPad(gamifyView.xp)} XP · ${toFaPad(gamifyView.streak)} روز پیوسته`, actions: <>{settingsBtn}</> },
  }[active];

  const noGrain = tweaks?.paperGrain === false;
  const darkTheme = tweaks?.dark ? 'dark' : undefined;

  // Flat list of user messages for Home screen stats (todayCount, weekly bars)
  const flatLog = React.useMemo(() =>
    conversations.flatMap(c =>
      c.messages.filter(m => m.role === 'user').map(m => ({
        id: m.id, timestamp: m.timestamp, date: c.dateLabel, kind: m.kind,
      }))
    ),
    [conversations]
  );

  // First run: welcome choice -> signup onboarding -> OTP, or direct login.
  if (!onboarded) {
    if (firstRunFlow === 'signup' && window.Onboarding) {
      return <window.Onboarding finishMode="auth" onFinish={finishSignupOnboarding} onTryVoice={() => {}} />;
    }
    if (window.Welcome) {
      return (
        <>
          <window.Welcome onSignup={() => setFirstRunFlow('signup')} onLogin={() => setFirstRunFlow('login')} />
          {window.Auth && firstRunFlow === 'login' && (
            <window.Auth
              open={true}
              intent="login"
              onClose={() => setFirstRunFlow('welcome')}
              onSuccess={(r, meta) => onAuthSuccess(r, { ...(meta || {}), markOnboarded: true })}
            />
          )}
          {window.Auth && firstRunFlow === 'signup-auth' && (
            <window.Auth
              open={true}
              intent="signup"
              initialName={pendingOnboarding?.profile?.name || ''}
              onClose={() => setFirstRunFlow('welcome')}
              onSuccess={onAuthSuccess}
            />
          )}
        </>
      );
    }
    if (window.Onboarding) {
      return <window.Onboarding onFinish={finishOnboarding} onTryVoice={() => openComposer('listening')} />;
    }
  }

  const screens = (
    <div key={active} className="sb-route">
      {active === 'home'      && <Home      data={{ tasks, knowledge, log: flatLog }} finances={finances} gamify={gamifyView} onGoto={goto} onOpenComposer={openComposer} onToggle={toggleTask} isDesktop={isDesktop} />}
      {active === 'tasks'     && <Tasks     tasks={tasks} gamify={gamifyView} onToggle={toggleTask} onUpdate={updateTask} onDelete={deleteTask} onAdd={addTask} isDesktop={isDesktop} onOpenConv={openInLog} projects={projects} onAddProject={addProject} onUpdateProject={updateProject} onDeleteProject={deleteProject} sortOpen={taskSortOpen} onSortClose={() => setTaskSortOpen(false)} highlightId={highlightId} />}
      {active === 'knowledge' && <Knowledge knowledge={knowledge} isDesktop={isDesktop} addOpen={kAddOpen} onAddClose={() => setKAddOpen(false)} onAdd={addKnowledgeItem} onUpdate={updateKnowledgeItem} onDelete={deleteKnowledgeItem} conversations={conversations} onStar={starLog} onDeleteConv={deleteLog} openConvId={openConvId} onConvOpened={() => setOpenConvId(null)} highlightId={highlightId} />}
      {active === 'finance'   && <Finance   finances={finances} isDesktop={isDesktop} addOpen={finAddOpen} onAddClose={() => setFinAddOpen(false)} onAdd={addFinance} onDelete={deleteFinance} onUpdate={updateFinance} highlightId={highlightId} />}
      {active === 'assistant' && <Assistant chats={chats} activeChatId={activeChatId} typing={typing} onAsk={askAssistant} onRetry={retryMessage} onNewChat={createChat} onSelectChat={setActiveChatId} onDeleteChat={deleteChat} onStartToday={startTodayChat} chatArchived={chatArchived} maxInput={MAX_CHAT_INPUT} isDesktop={isDesktop} />}
      {active === 'profile'   && Profile && <Profile profile={profile} gamify={gamifyView} tasks={tasks} knowledge={knowledge} finances={finances} account={account} onUpdateProfile={updateProfile} onOpenSettings={() => setSettingsOpen(true)} onOpenAuth={() => setAuthOpen(true)} onSignOut={signOut} onGoto={goto} isDesktop={isDesktop} />}
    </div>
  );

  // ── Desktop layout ──────────────────────────────────────────
  const accentOverride = tweaks?.accent ? { '--firoozeh': tweaks.accent } : {};

  if (isDesktop) {
    const shellStyle = {
      ...appShellStyles.desktopShell,
      ...(noGrain ? { backgroundImage: 'none' } : {}),
      ...accentOverride,
    };
    const mainStyle = {
      ...appShellStyles.desktopMain,
      ...(noGrain ? { backgroundImage: 'none' } : {}),
    };
    return (
      <div className="sb sb-shell" style={shellStyle} data-theme={darkTheme}>
        <DesktopSidebar active={active} onChange={goto} onCompose={openComposer} />
        <div style={mainStyle}>
          <TopBar {...topBar} />
          <div className="sb-reveal" style={appShellStyles.desktopContent}>
            {screens}
            <VoiceComposer mode={composer} setMode={setComposer} onSubmit={submitInput} isDesktop={true} />
          </div>
        </div>
        <ConfirmHost />
        {rewardBurst && <RewardBurst reward={rewardBurst} />}
        {toast && <ToastBar msg={toast} />}
        {captureResult && <CaptureBar result={captureResult} onUndo={undoCapture} onDismiss={dismissCapture} onSeeReply={seeReplyInChat} onRetry={retryCapture} />}
        {showSearch && <GlobalSearch tasks={tasks} knowledge={knowledge} finances={finances} onClose={() => setShowSearch(false)} onGoto={(tab, id) => { goto(tab); setHighlightId(id || null); }} />}
        {Settings && <Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} tweaks={tweaks} setTweak={setTweak} profile={profile} onUpdateProfile={updateProfile} account={account} onOpenAuth={() => setAuthOpen(true)} onSignOut={signOut} onUpgrade={upgrade} />}
        {window.Auth && <window.Auth open={authOpen} onClose={() => setAuthOpen(false)} onSuccess={onAuthSuccess} />}
      </div>
    );
  }

  // ── Mobile layout ───────────────────────────────────────────
  const wrapStyle = {
    ...appShellStyles.screen,
    ...(noGrain ? appShellStyles.screenNoGrain : {}),
    ...accentOverride,
  };
  return (
    <div className="sb sb-shell" style={wrapStyle} data-theme={darkTheme}>
      <TopBar {...topBar} />
      <div className="sb-reveal" style={{ flex: 1, minHeight: 0, position: 'relative' }}>
        {screens}
      </div>
      <VoiceComposer mode={composer} setMode={setComposer} onSubmit={submitInput} />
      <TabBar active={active} onChange={goto} />
      <ConfirmHost />
      {rewardBurst && <RewardBurst reward={rewardBurst} />}
      {toast && <ToastBar msg={toast} />}
      {captureResult && <CaptureBar result={captureResult} onUndo={undoCapture} onDismiss={dismissCapture} onSeeReply={seeReplyInChat} onRetry={retryCapture} />}
      {showSearch && <GlobalSearch tasks={tasks} knowledge={knowledge} finances={finances} onClose={() => setShowSearch(false)} onGoto={(tab, id) => { goto(tab); setHighlightId(id || null); }} />}
      {Settings && <Settings open={settingsOpen} onClose={() => setSettingsOpen(false)} tweaks={tweaks} setTweak={setTweak} profile={profile} onUpdateProfile={updateProfile} account={account} onOpenAuth={() => setAuthOpen(true)} onSignOut={signOut} onUpgrade={upgrade} />}
        {window.Auth && <window.Auth open={authOpen} onClose={() => setAuthOpen(false)} onSuccess={onAuthSuccess} />}
    </div>
  );
}

window.App = App;
