// Background service worker.
// Responsibilities:
//  - Tick the queue every minute (chrome.alarms)
//  - For the next ready job: open the group URL in a tab, ask content script to publish
//  - Apply daily cap and randomized delay between jobs
//  - Route messages from popup / content script

import { Jobs, Posts, Groups, Logs, Campaigns, Media, Discoveries, Comments } from '../lib/repo.js';
import { getSetting, setSetting } from '../lib/db.js';
import { DEFAULTS, nextInterPostDelayMs, canPostNow, profileForPlatform, canDiscoverNow, discoveryScrollPauseMs } from '../lib/delays.js';
import { runSync, testConnection } from '../lib/sync.js';
import { geminiClassifyPublish, getGeminiKey, geminiSafetyCheck, geminiGeneratePosts, geminiGenerateLinkedInComment, geminiGenerateLinkedInReply, geminiAuditLinkedInProfile, geminiText, getServerKeyStatus } from '../lib/gemini.js';
import { obLog } from '../lib/logger.js';
import { getSession, saveSession, clearSession, verifySession } from '../lib/auth.js';
import { SYNC } from '../lib/config.js';

obLog.setSource('ext-sw');
obLog.attachGlobalHandlers();
obLog.event('sw_boot', { context: { v: chrome.runtime.getManifest?.()?.version } });

// ── AI-Mode scan progress (survives popup close) ──────────────────────────
// The scan runs entirely in the service worker, so it keeps going even after
// the user closes the popup. We persist a progress snapshot to storage after
// every phase; the popup reads it on open and also receives a live push.
async function setScanProgress(patch) {
  let state = {};
  try { state = (await getSetting('aiScanState', {})) || {}; } catch {}
  const next = { ...state, ...patch, updatedAt: Date.now() };
  try { await setSetting('aiScanState', next); } catch {}
  // Best-effort live push to an open popup (ignored if none is listening).
  // Screenshots are capped at 5 upstream, so the push stays small enough.
  try { chrome.runtime.sendMessage({ type: 'AI_SCAN_PROGRESS', state: next }); } catch {}
  // Mirror to the SERVER so the dashboard can watch remotely ("total control").
  // Throttled, and screenshots are stripped (too heavy to poll).
  const now = Date.now();
  const terminal = next.phase === 'done' || next.phase === 'error';
  if (terminal || now - _lastServerPushAt > 2500) {
    _lastServerPushAt = now;
    (async () => {
      try {
        const { token } = await getSession();
        if (!token) return;
        const { screenshots, ...light } = next;
        await fetch(`${SYNC.endpoint}/api/v1/ai-mode/scan-progress`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
          body: JSON.stringify({ state: light }),
        });
      } catch {}
    })();
  }
  return next;
}

// ── Live scan screenshots ("watch the browser work") ──────────────────────
// During a scan we grab a few small JPEGs of the FB tab so the user can SEE
// what the scan is looking at. Coordinated via SCAN_SCROLL_TICK from the
// content script. Kept few + downscaled (JPEG q40) so storage stays light.
let _scanCapturing = false;
let _scanTabId = null;
let _scanWindowId = null;
let _scanShots = [];
let _lastShotAt = 0;
let _scanAbort = false;        // set by AI_SCAN_STOP (remote or local)
let _lastServerPushAt = 0;     // throttle progress mirroring to the server
const SCAN_MAX_SHOTS = 5;

async function grabScanShot(label) {
  if (_scanWindowId == null) return null;
  const now = Date.now();
  if (_scanShots.length >= SCAN_MAX_SHOTS) return null;
  if (now - _lastShotAt < 1200) return null; // throttle
  _lastShotAt = now;
  try {
    const dataUrl = await chrome.tabs.captureVisibleTab(_scanWindowId, { format: 'jpeg', quality: 40 });
    if (!dataUrl) return null;
    const shot = { dataUrl, label: label || '', at: now };
    _scanShots.push(shot);
    // Live-push so an open popup shows it appear in real time.
    try { chrome.runtime.sendMessage({ type: 'SCAN_SHOT', shot }); } catch {}
    return shot;
  } catch { return null; }
}

// Build an honest per-scan conclusions list from what was actually captured.
function buildScanConclusions({ groupPosts, wallPosts, footprint, skipped, analysis }) {
  const c = [];
  if (groupPosts > 0) {
    c.push(`נמצאו ${groupPosts} פוסטים חדשים ב-${footprint.length} קבוצות.`);
    if (footprint[0]) c.push(`הקבוצה הכי פעילה: ${footprint[0].name} (${footprint[0].post_count} פוסטים · 👍${footprint[0].likes || 0}).`);
  } else if (wallPosts > 0) {
    c.push(`נסרקו ${wallPosts} פוסטים — כולם מהוול האישי / חברים, 0 בקבוצות.`);
    c.push('פייסבוק לא חושף פוסטי-קבוצות בטיימליין האישי — לכן לא נמצאה פעילות קבוצתית.');
    c.push('המלצה: סריקת קבוצה-אחר-קבוצה כדי לגלות איפה את מפרסמת בפועל.');
  } else {
    c.push(`אין פוסטים חדשים${skipped ? ` (${skipped} כבר מוכרים)` : ''}.`);
  }
  if (analysis?.topics?.length) c.push(`נושאים מובילים: ${analysis.topics.slice(0, 3).map(t => t.name).join(', ')}.`);
  if (analysis?.best_format) c.push(`פורמט מנצח: ${analysis.best_format}.`);
  return c;
}

// ── BROWSER AGENT PRIMITIVES (PLAN P1) ─────────────────────────────────────
// The plugin already HAD the power (chrome.scripting.executeScript + <all_urls>
// + tabs) — it was just never exposed. These turn it into verbs an AI can drive:
// navigate → extract (see) → click/type (act) → screenshot. That loop is what
// makes it a general browser agent instead of a remote for pre-baked flows.
//
// SAFETY (interim, until PLAN P5 lands a real permission layer): a remote caller
// must not reach privileged surfaces or our own control plane — otherwise an
// agent could drive the dashboard and re-command itself.
let _agentTabId = null;

function isBlockedAgentUrl(u) {
  const url = String(u || '');
  if (!/^https?:\/\//i.test(url)) return true;                       // no chrome://, file://, devtools://
  if (/chromewebstore\.google\.com|chrome\.google\.com\/webstore/i.test(url)) return true;
  if (/organicai\.doogree\.co\.il|fb-poster-sync\.idandada\.workers\.dev/i.test(url)) return true; // our own control plane
  return false;
}

async function agentResolveTab(tabId) {
  if (tabId != null) return tabId;
  if (_agentTabId != null) {
    try { await chrome.tabs.get(_agentTabId); return _agentTabId; } catch { _agentTabId = null; }
  }
  return null;
}

// Inject-and-run helper; returns the injected function's result.
async function agentInject(tabId, func, args = []) {
  const [res] = await chrome.scripting.executeScript({ target: { tabId }, func, args });
  return res?.result;
}

// ── AGENT FLIGHT RECORDER ──────────────────────────────────────────────────
// Records what the agents actually DO, endlessly, so failures can be diagnosed
// after the fact (by a human or by an AI sweeping /api/v1/recordings).
//
// THE CONSTRAINT: agents deliberately work in BACKGROUND tabs (active:false) so
// they never steal the user's focus — and `chrome.tabs.captureVisibleTab` can
// only ever capture the ACTIVE tab (it would photograph whatever the human is
// looking at, not the agent). The debugger protocol is the only API that can
// screenshot a specific background tab, so pixel capture rides on the OPTIONAL
// `debugger` permission. Without it we still record the page TEXT + outcome,
// which is usually what actually explains a failure.
async function hasDebuggerPermission() {
  try { return await chrome.permissions.contains({ permissions: ['debugger'] }); }
  catch { return false; }
}

async function captureTabShot(tabId, quality = 45) {
  if (tabId == null) return null;
  if (!(await hasDebuggerPermission())) return null;
  try {
    await chrome.debugger.attach({ tabId }, '1.3');
    const res = await chrome.debugger.sendCommand({ tabId }, 'Page.captureScreenshot', { format: 'jpeg', quality });
    try { await chrome.debugger.detach({ tabId }); } catch {}
    return res?.data ? `data:image/jpeg;base64,${res.data}` : null;
  } catch {
    try { await chrome.debugger.detach({ tabId }); } catch {}
    return null;
  }
}

// The rendered text of the page — cheap, needs no extra permission, works on
// background tabs, and is what an AI reads to understand WHY something failed.
async function captureTabText(tabId) {
  if (tabId == null) return '';
  try {
    const [res] = await chrome.scripting.executeScript({
      target: { tabId },
      func: () => (document.body?.innerText || '').replace(/\s+/g, ' ').slice(0, 4000),
    });
    return res?.result || '';
  } catch { return ''; }
}

// One recorded step. Fire-and-forget: recording must never break publishing.
async function recordAgentStep({ tabId = null, action, phase, platform = null, jobId = null, outcome = null, error = null }) {
  try {
    if (!(await getSetting('agentRecording', true))) return;
    const { token } = await getSession();
    if (!token) return;
    let pageUrl = '';
    try { if (tabId != null) pageUrl = (await chrome.tabs.get(tabId))?.url || ''; } catch {}
    const [dataUrl, domText] = await Promise.all([captureTabShot(tabId), captureTabText(tabId)]);
    await fetch(`${SYNC.endpoint}/api/v1/recordings`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
      body: JSON.stringify({
        dataUrl, action, phase, platform, page_url: pageUrl, dom_text: domText,
        outcome, error: error ? String(error).slice(0, 500) : null,
        job_id: jobId, device_id: token.slice(0, 12),
      }),
    });
  } catch (e) {
    obLog.warn('agent_recording_failed', { context: { action, phase, msg: String(e?.message || e) } });
  }
}

// Navigate a tab to a URL and wait for it to finish loading.
// `active` defaults true (the scan wants to be watchable); the browser-agent
// primitives pass false so a remote caller never steals the user's focus.
async function navigateAndWait(tabId, url, timeoutMs = 15000, active = true) {
  await chrome.tabs.update(tabId, { url, active });
  await new Promise((resolve) => {
    const lt = (tid, info) => {
      if (tid === tabId && info.status === 'complete') { chrome.tabs.onUpdated.removeListener(lt); resolve(); }
    };
    chrome.tabs.onUpdated.addListener(lt);
    setTimeout(() => { chrome.tabs.onUpdated.removeListener(lt); resolve(); }, timeoutMs);
  });
  await new Promise(r => setTimeout(r, 1800));
}

// ── PHASE B — direct group scan ───────────────────────────────────────────
// The timeline (/me) never shows her GROUP posts, so to learn where she really
// publishes we visit `facebook.com/groups/<gid>/user/<uid>/` (her posts in that
// group) for a few groups. Minimum exposure: 3 groups/run, remembered so we
// don't revisit. Feeds the same footprint + auto-imports the groups as targets.
self.startGroupScan = async (payload = {}) => {
  try {
    _scanAbort = false;
    await setScanProgress({ running: true, phase: 'starting', pct: 3, error: null, result: null, groupPosts: [], screenshots: [], startedAt: Date.now(), mode: 'groups' });
    const { token } = await getSession();
    if (!token) { await setScanProgress({ running: false, phase: 'error', error: 'לא מחובר' }); return; }
    const maxGroups = Math.max(1, Math.min(10, Number(payload?.maxGroups) || 3));
    const perGroup  = Math.max(3, Math.min(30, Number(payload?.perGroup) || 10));

    // Open the groups list page.
    await setScanProgress({ phase: 'opening_fb', pct: 8, message: 'פותח את רשימת הקבוצות…' });
    let tab;
    const existing = await chrome.tabs.query({ url: '*://*.facebook.com/*' });
    tab = existing[0];
    if (!tab) tab = await chrome.tabs.create({ url: 'https://www.facebook.com/groups/joins/', active: true });
    else await navigateAndWait(tab.id, 'https://www.facebook.com/groups/joins/');

    // Identity — need her fb user id for the per-group URL.
    let fbId = (await getSetting('fbIdentity', null))?.fbUserId || null;
    if (!fbId) { try { const id = await chrome.tabs.sendMessage(tab.id, { type: 'DETECT_FB_IDENTITY' }); fbId = id?.fbUserId || null; if (id?.loggedIn) await setSetting('fbIdentity', id); } catch {} }
    if (!fbId) { await setScanProgress({ running: false, phase: 'error', error: 'לא זוהה משתמש פייסבוק — ודא שאתה מחובר' }); return; }

    // Scrape joined groups = the universe of where she can/does post.
    await setScanProgress({ phase: 'scraping', pct: 15, message: 'קורא את הקבוצות שלך…' });
    const gs = await chrome.tabs.sendMessage(tab.id, { type: 'SCRAPE_MY_GROUPS' }).catch(() => null);
    const groups = ((gs && gs.groups) || []).filter(g => g.fbGroupId);
    if (!groups.length) { await setScanProgress({ running: false, phase: 'error', error: 'לא נמצאו קבוצות (פתח facebook.com/groups/joins)' }); return; }

    const seenG = new Set((await getSetting('scanGroupsSeen', [])) || []);
    const accumFp = (await getSetting('scanFootprint', {})) || {};
    const targets = groups.filter(g => !seenG.has(g.fbGroupId)).slice(0, maxGroups);
    if (!targets.length) {
      const fpAll = Object.values(accumFp).sort((a, b) => b.post_count - a.post_count);
      await setScanProgress({ running: false, phase: 'done', pct: 100, message: `כל ${groups.length} הקבוצות כבר נסרקו. "אפס זיכרון" לסריקה מחדש.`, footprint: fpAll, groupPosts: [], conclusions: ['כל הקבוצות שלך כבר נסרקו — אין חדש.'] });
      return;
    }

    _scanCapturing = true; _scanShots = []; _lastShotAt = 0; _scanTabId = tab.id; _scanWindowId = tab.windowId;
    const allFound = [];
    let gi = 0;
    for (const g of targets) {
      if (_scanAbort) break;
      gi++;
      const pct = 15 + Math.round(65 * gi / targets.length);
      await setScanProgress({ phase: 'scraping', pct, message: `סורק את "${g.name}" (${gi}/${targets.length})…`, screenshots: _scanShots });
      try { await navigateAndWait(tab.id, `https://www.facebook.com/groups/${g.fbGroupId}/user/${fbId}/`); } catch {}
      await grabScanShot(`קבוצה: ${g.name}`);
      let posts = [];
      // Retry — the content script may not be ready immediately after navigation.
      for (let attempt = 0; attempt < 3; attempt++) {
        try {
          const r = await chrome.tabs.sendMessage(tab.id, { type: 'SCRAPE_MY_FB_POSTS', payload: { maxPosts: perGroup, maxScrolls: 4 } });
          posts = (r && r.posts) || [];
          break;
        } catch { await new Promise(res => setTimeout(res, 1500)); }
      }
      const f = accumFp[g.fbGroupId] || (accumFp[g.fbGroupId] = { fb_group_id: g.fbGroupId, name: g.name, post_count: 0, likes: 0, comments: 0, shares: 0 });
      for (const p of posts) {
        f.post_count++; f.likes += Number(p.likes) || 0; f.comments += Number(p.comments) || 0; f.shares += Number(p.shares) || 0;
        allFound.push({ group_id: g.fbGroupId, group_name: g.name, text: String(p.text || '').slice(0, 200), likes: p.likes || 0, comments: p.comments || 0, shares: p.shares || 0, url: p.url || null });
      }
      seenG.add(g.fbGroupId);
      try { await Groups.upsertByFbId(g.fbGroupId, { name: g.name, url: `https://www.facebook.com/groups/${g.fbGroupId}/`, source: 'group-scan', footprintPosts: f.post_count }); } catch {}
    }
    _scanCapturing = false;
    await setSetting('scanGroupsSeen', [...seenG].slice(-500));
    await setSetting('scanFootprint', accumFp);
    const footprint = Object.values(accumFp).sort((a, b) => b.post_count - a.post_count);
    const screenshots = _scanShots.slice();

    // Upload her real group posts (content she already writes) for analysis.
    if (allFound.length) {
      try {
        await fetch(`${SYNC.endpoint}/api/v1/ai-mode/history-snapshots`, {
          method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
          body: JSON.stringify({ platform: 'facebook', posts: allFound }),
        });
      } catch {}
    }
    const conclusions = [];
    if (allFound.length) {
      conclusions.push(`נמצאו ${allFound.length} פוסטים שלך ב-${targets.length} קבוצות.`);
      if (footprint[0]) conclusions.push(`הכי פעילה: ${footprint[0].name} (${footprint[0].post_count} פוסטים).`);
      conclusions.push('הקבוצות יובאו כיעדי פרסום — אפשר להקים קמפיין יומי אוטומטי.');
    } else {
      conclusions.push(`לא נמצאו פוסטים שלך ב-${targets.length} הקבוצות שנסרקו (אולי לא פרסמת בהן לאחרונה).`);
      conclusions.push('נסה "סרוק עוד" כדי לבדוק קבוצות נוספות.');
    }
    await setScanProgress({
      running: false, phase: 'done', pct: 100,
      message: `✓ נסרקו ${targets.length} קבוצות · ${allFound.length} פוסטים שלך · footprint: ${footprint.length}`,
      footprint, groupPosts: allFound.slice(0, 40), screenshots, conclusions,
      imported: targets.length, counts: { group: allFound.length, total: allFound.length, wall: 0 },
      finishedAt: Date.now(),
    });
  } catch (e) {
    _scanCapturing = false;
    await setScanProgress({ running: false, phase: 'error', error: String(e?.message || e) });
  }
};

// ── AUTOPILOT (DRY RUN) ───────────────────────────────────────────────────
// One command runs the full planning loop for a single real profile:
// activate (DRY RUN) → generate content → score the footprint groups → build a
// schedule. Nothing is published — it produces a full plan for the user to
// approve. Every step logs to the live activity feed + scan-progress mirror so
// it's visible in the plugin AND the dashboard.
self.runAutopilot = async (payload = {}) => {
  const setP = (patch) => setScanProgress({ mode: 'autopilot', ...patch });
  try {
    await setP({ running: true, phase: 'starting', pct: 4, message: '🤖 אוטופיילוט מתחיל (DRY RUN)…', error: null, conclusions: [], footprint: [], groupPosts: [] });
    const session = await getSession();
    if (!session?.token) { await setP({ running: false, phase: 'error', error: 'לא מחובר' }); return; }
    const hdr = { 'X-Session-Token': session.token, 'Content-Type': 'application/json' };
    const post = (path, body) => fetch(`${SYNC.endpoint}/api/v1/${path}`, { method: 'POST', headers: hdr, body: JSON.stringify(body || {}) }).then(r => r.json()).catch(() => ({}));
    const get = (path) => fetch(`${SYNC.endpoint}/api/v1/${path}`, { headers: hdr }).then(r => r.json()).catch(() => ({}));

    // 1) brand profile required
    const prof = await get('ai-mode/brand-profile');
    if (!prof?.configured) {
      await Logs.add('warn', '🤖 אוטופיילוט: אין פרופיל מותג — הגדר בפלאגין → AI');
      await setP({ running: false, phase: 'error', error: 'צריך פרופיל מותג קודם (פלאגין → 🤖 AI → הגדר פרופיל)' });
      return;
    }

    // 2) activate in DRY RUN (safe — nothing publishes)
    await setP({ phase: 'scraping', pct: 15, message: 'מפעיל מצב AI (DRY RUN)…' });
    await post('ai-mode/activate', { dry_run: true });
    await Logs.add('info', '🤖 אוטופיילוט: מצב AI הופעל (DRY RUN)');

    // 2b) analyze her REAL posts FIRST (ADR-34) so generation matches her style.
    // Needs scraped snapshots — if none exist yet, analysis is skipped and the
    // content falls back to profile-only (generic). Run 🔍 סרוק טיימליין first.
    await setP({ phase: 'analyzing', pct: 25, message: '📊 מנתח את הפוסטים שלך…' });
    const an = await post('ai-mode/analyze-history', {});
    if (an?.ok) await Logs.add('info', `🤖 אוטופיילוט: נותחו הפוסטים שלך — ${(an.analysis?.topics || []).length} נושאים זוהו`);
    else await Logs.add('warn', `🤖 אוטופיילוט: דילג על ניתוח (${an?.error || 'אין פוסטים סרוקים — הרץ 🔍 סרוק טיימליין קודם'})`);

    // 3) generate content (now uses the analysis + few-shot of her real posts)
    await setP({ phase: 'uploading', pct: 40, message: '✨ מייצר פוסטים בסגנון שלך…' });
    const gen = await post('ai-mode/generate-content', { count: Number(payload.count) || 12 });
    if (gen?.ok) await Logs.add('info', `🤖 אוטופיילוט: נוצרו ${gen.generated || 0} פוסטים`);

    // 4) score the footprint groups (the groups we already know she posts in)
    await setP({ phase: 'footprint', pct: 60, message: '👥 מדרג את הקבוצות שלך…' });
    const allGroups = await Groups.list();
    const fpGroups = allGroups.filter(g => g.source === 'history-footprint' || g.source === 'group-scan')
      .map(g => ({ fb_group_id: g.fbGroupId, name: g.name, url: g.url, member_count: g.memberCount }));
    let scored = 0;
    if (fpGroups.length) {
      const sc = await post('ai-mode/score-groups', { groups: fpGroups });
      scored = sc?.scored || 0;
      if (sc?.ok) await Logs.add('info', `🤖 אוטופיילוט: דורגו ${scored} קבוצות`);
    }

    // 5) build the schedule (ramped, cooldown-aware) — still DRY RUN
    await setP({ phase: 'analyzing', pct: 82, message: '📅 בונה לוז פרסום…' });
    const sched = await post('ai-mode/build-schedule', { days: Number(payload.days) || 14, minScore: 7 });
    if (sched?.ok) await Logs.add('info', `🤖 אוטופיילוט: תוכננו ${sched.scheduled || 0} פרסומים ל-${payload.days || 14} ימים`);

    const conclusions = [
      `נוצרו ${gen?.generated || 0} פוסטים מותאמים לפרופיל שלך.`,
      fpGroups.length ? `דורגו ${scored}/${fpGroups.length} קבוצות מה-footprint שלך.` : 'אין עדיין קבוצות — הרץ "🎯 סרוק קבוצות" קודם.',
      sched?.ok ? `נבנה לוז של ${sched.scheduled || 0} פרסומים ל-${payload.days || 14} ימים.` : '',
      '🧪 הכל ב-DRY RUN — שום דבר לא פורסם. בדוק ואשר בדשבורד לפני LIVE.',
    ].filter(Boolean);
    await setP({ running: false, phase: 'done', pct: 100, message: '✓ אוטופיילוט הכין תוכנית מלאה (DRY RUN)', conclusions, finishedAt: Date.now() });
    await Logs.add('info', '🤖 אוטופיילוט: תוכנית מוכנה לאישור (DRY RUN)');
  } catch (e) {
    await setP({ running: false, phase: 'error', error: String(e?.message || e) });
  }
};

// Full AI-Mode reset — clean restart from scratch (local + server). Module-level
// so the remote AI_MODE_FULL_RESET command works on a cold SW. Clears local scan
// memory + the hidden AI-Mode campaign/jobs/posts, then calls the server reset
// (abandon run + purge generated posts / scores / schedule). Brand profile kept.
self.resetAiModeAll = async (payload = {}) => {
  const setP = (patch) => setScanProgress({ mode: 'reset', ...patch });
  try {
    await setP({ running: true, phase: 'resetting', pct: 20, message: '🔄 מאפס תהליך AI…', error: null, conclusions: [], footprint: [], groupPosts: [] });
    // 1) Local: scan memory.
    await setSetting('aiScanState', {});
    await setSetting('scanFootprint', {});
    await setSetting('scanSeenKeys', []);
    // 2) Local: the hidden AI-Mode Autopilot campaign + its jobs + posts.
    const campId = await getSetting('aiModeCampaignId', null);
    if (campId) {
      try {
        const jobs = await Jobs.list();
        for (const j of jobs) if (j.campaignId === campId) { try { await Jobs.remove(j.id); } catch {} }
        const posts = await Posts.listByCampaign(campId);
        for (const p of posts) { try { await Posts.remove(p.id); } catch {} }
        try { await Campaigns.remove(campId); } catch {}
      } catch {}
      await setSetting('aiModeCampaignId', null);
    }
    // 3) Server: abandon the run + purge derived artifacts.
    const session = await getSession();
    if (session?.token) {
      await fetch(`${SYNC.endpoint}/api/v1/ai-mode/reset`, {
        method: 'POST',
        headers: { 'X-Session-Token': session.token, 'Content-Type': 'application/json' },
        body: JSON.stringify({ purge: payload.purge !== false }),
      }).catch(() => {});
    }
    await setP({ running: false, phase: 'done', pct: 100, message: '✅ תהליך ה-AI אופס — אפשר להתחיל מחדש נקי', finishedAt: Date.now() });
    await Logs.add('info', '🔄 מצב AI אופס לגמרי (מקומי + שרת) — מוכן להתחלה נקייה');
  } catch (e) {
    await setP({ running: false, phase: 'error', error: String(e?.message || e) });
    obLog.warn('ai_mode_reset_exception', { context: { msg: String(e?.message || e) } });
  }
};

// ═══ THE UNIFIED AGENT CYCLE (ADR-35) — one continuous loop ═══════════════
// Chains every piece into ONE run: identity/style → sync my groups → discover
// new groups by interest → generate style-matched content → score → schedule.
// DRY RUN by default (nothing publishes; the ADR-31 bridge handles live posting
// separately). Each step is best-effort so one failure doesn't abort the cycle.
// Module-level so the remote AI_AGENT_CYCLE_START command works on a cold SW.
self.runAgentCycle = async (payload = {}) => {
  const setP = (patch) => setScanProgress({ mode: 'agent', ...patch });
  const conclusions = [];
  try {
    const session = await getSession();
    if (!session?.token) { await setP({ running: false, phase: 'error', error: 'לא מחובר' }); return; }
    const hdr = { 'X-Session-Token': session.token, 'Content-Type': 'application/json' };
    const post = (p, b) => fetch(`${SYNC.endpoint}/api/v1/${p}`, { method: 'POST', headers: hdr, body: JSON.stringify(b || {}) }).then(r => r.json()).catch(() => ({}));
    const get = (p) => fetch(`${SYNC.endpoint}/api/v1/${p}`, { headers: hdr }).then(r => r.json()).catch(() => ({}));

    await setP({ running: true, phase: 'starting', pct: 3, message: '🤖 הסוכן מתחיל מחזור מלא (DRY RUN)…', error: null, conclusions: [], footprint: [], groupPosts: [] });

    // 0) Identity: a brand profile is required (who is this user).
    const prof = await get('ai-mode/brand-profile');
    if (!prof?.configured) { await setP({ running: false, phase: 'error', error: 'צריך פרופיל מותג קודם (פלאגין → 🤖 AI → פרופיל)' }); return; }

    // 1) Activate a DRY-RUN run.
    await setP({ phase: 'activating', pct: 8, message: 'מפעיל מצב AI (DRY RUN)…' });
    await post('ai-mode/activate', { dry_run: true });

    const safeMode = await getSetting('safeMode', false);
    const reuse = await getSetting('reusePublisherTab', true);

    // 2) SYNC the groups the user is a member of (scrape FB) + score them.
    let synced = 0;
    try {
      await setP({ phase: 'syncing', pct: 20, message: '👥 מסנכרן את הקבוצות שלך מפייסבוק…' });
      const tab = await acquirePublisherTab('https://www.facebook.com/groups/joins/', !!safeMode, reuse);
      await waitForTabComplete(tab.id);
      await new Promise(r => setTimeout(r, 2500));
      const scrape = await chrome.tabs.sendMessage(tab.id, { type: 'SCRAPE_MY_GROUPS' }).catch(() => null);
      const groups = scrape?.groups || [];
      for (const g of groups) {
        try { await Groups.upsertByFbId(g.fbGroupId || g.id, { name: g.name, url: g.url, memberCount: g.memberCount, isMember: 1, source: 'joined' }); } catch {}
      }
      synced = groups.length;
      if (groups.length) {
        await post('ai-mode/score-groups', { groups: groups.map(g => ({ fb_group_id: g.fbGroupId || g.id, name: g.name, url: g.url, member_count: g.memberCount })) });
      }
      conclusions.push(`סונכרנו ${synced} קבוצות שאתה חבר בהן.`);
    } catch { conclusions.push('סנכרון קבוצות דילג (ודא שטאב פייסבוק פתוח ומחובר).'); }

    // 3) ANALYZE the user's real posts (identity + style).
    await setP({ phase: 'analyzing', pct: 40, message: '📊 מנתח את הפוסטים שלך (זהות + סגנון)…' });
    const an = await post('ai-mode/analyze-history', {});
    const topics = an?.analysis?.topics || [];
    conclusions.push(an?.ok ? `נותחו הפוסטים שלך — ${topics.length} נושאים זוהו.` : 'אין ניתוח (הרץ 🔍 סרוק טיימליין קודם).');

    // 4) DISCOVER new groups by interest — ONE gated search per cycle (ADR-55).
    // Discovery is what flags accounts, so it goes through the same daily-cap +
    // min-gap gate as tracked discovery, and does at most one gentle search.
    let discovered = 0;
    try {
      const queries = topics.map(t => t.name).filter(Boolean);
      if (queries.length && await discoveryAllowed()) {
        const cfg = await loadThrottleConfig();
        const q = queries[0]; // just one query per cycle
        await setP({ phase: 'discovering', pct: 58, message: `🔎 מגלה קבוצות חדשות לפי: ${q}…` });
        const tab = await acquirePublisherTab(`https://www.facebook.com/search/groups/?q=${encodeURIComponent(q)}`, !!safeMode, reuse);
        await waitForTabComplete(tab.id);
        await new Promise(r => setTimeout(r, 2000 + Math.random() * 2000));
        await recordDiscovery();
        const r = await chrome.tabs.sendMessage(tab.id, { type: 'SCRAPE_GROUP_SEARCH', payload: { maxScrolls: cfg.discoveryMaxScrolls ?? 3, scrollPauseMs: discoveryScrollPauseMs(cfg) } }).catch(() => null);
        for (const g of (r?.results || [])) {
          try { await Groups.upsertByFbId(g.fbGroupId, { name: g.name, url: g.url, memberCount: g.memberCount, isMember: g.isMember ? 1 : 0, source: g.isMember ? 'joined' : 'discovered' }); discovered++; } catch {}
        }
      } else if (queries.length) {
        conclusions.push('גילוי קבוצות דולג הפעם (מגבלת קצב יומית — כדי לא להיראות כמו סורק).');
      }
      if (discovered) conclusions.push(`נמצאו ${discovered} קבוצות חדשות רלוונטיות (מועמדות).`);
    } catch { conclusions.push('גילוי קבוצות דילג.'); }

    // 5) GENERATE — few-shot on real posts (ADR-34), or from declared offerings
    // (ADR-36). If there's nothing to work from, the agent ASKS what to market.
    await setP({ phase: 'generating', pct: 74, message: '✨ מייצר תוכן בסגנון שלך…' });
    const gen = await post('ai-mode/generate-content', { count: Number(payload.count) || 12 });
    if (gen?.needsInput) {
      conclusions.push('🟡 אין מספיק היסטוריה ללמוד ממנה. ספר לי מה לשווק (פרויקטים/שירותים + לינקים) — בדשבורד → "🎯 מה לשווק".');
      await setP({
        running: false, phase: 'needs_input', pct: 74,
        message: '🟡 ספר לי מה לשווק — הוסף פרויקטים/שירותים עם לינקים ומידע, ואבנה תוכנית פרסום',
        conclusions, finishedAt: Date.now(),
      });
      await Logs.add('info', '🤖 הסוכן: אין היסטוריה מספקת — מבקש מהמשתמש מה לשווק (מה לשווק בדשבורד)');
      return;
    }
    conclusions.push(gen?.ok ? `נוצרו ${gen.generated || gen.saved || 0} פוסטים בסגנון שלך.` : 'יצירת תוכן נכשלה (בדוק מפתח Gemini).');

    // 6) BUILD the ramped, cooldown-aware schedule.
    await setP({ phase: 'scheduling', pct: 90, message: '📅 בונה לוז פרסום…' });
    const sched = await post('ai-mode/build-schedule', { days: Number(payload.days) || 14, minScore: 7 });
    conclusions.push(sched?.ok ? `נבנה לוז של ${sched.scheduled || 0} פרסומים על ${sched.eligibleGroups || 0} קבוצות.` : 'בניית לוז דילגה (צריך קבוצות מדורגות).');

    conclusions.push('🧪 הכל ב-DRY RUN — שום דבר לא פורסם. בדוק ואשר בדשבורד לפני LIVE.');
    await setP({ running: false, phase: 'done', pct: 100, message: '✓ הסוכן סיים מחזור מלא (DRY RUN)', conclusions, finishedAt: Date.now() });
    await Logs.add('info', `🤖 הסוכן: מחזור מלא הושלם — ${synced} סונכרנו, ${discovered} התגלו (DRY RUN)`);
  } catch (e) {
    await setP({ running: false, phase: 'error', error: String(e?.message || e), conclusions });
  }
};

// ═══ READ LINKS AUTONOMOUSLY → understand → draft posts (ADR-37) ══════════
// For each offering with a URL: open it in a background tab, extract the RENDERED
// page text (chrome.scripting — manifest has <all_urls>), send it to the server
// which understands the product and drafts engaging posts (status='draft'). The
// user then approves them in the dashboard (✓/✗). Module-level for cold-SW remote.
self.buildFromOfferings = async (payload = {}) => {
  const setP = (patch) => setScanProgress({ mode: 'offerings', ...patch });
  const conclusions = [];
  try {
    const session = await getSession();
    if (!session?.token) { await setP({ running: false, phase: 'error', error: 'לא מחובר' }); return; }
    const hdr = { 'X-Session-Token': session.token, 'Content-Type': 'application/json' };
    const get = (p) => fetch(`${SYNC.endpoint}/api/v1/${p}`, { headers: hdr }).then(r => r.json()).catch(() => ({}));
    const post = (p, b) => fetch(`${SYNC.endpoint}/api/v1/${p}`, { method: 'POST', headers: hdr, body: JSON.stringify(b || {}) }).then(r => r.json()).catch(() => ({}));

    await setP({ running: true, phase: 'starting', pct: 5, message: '🔗 קורא את הלינקים שלך…', error: null, conclusions: [], footprint: [], groupPosts: [] });
    const offs = (await get('ai-mode/offerings')).offerings || [];
    const withUrl = offs.filter(o => o.url);
    if (!withUrl.length) { await setP({ running: false, phase: 'needs_input', message: 'הוסף פרויקטים/שירותים עם לינקים ב"🎯 מה לשווק" קודם' }); return; }

    await post('ai-mode/activate', { dry_run: true });
    let total = 0;
    for (let i = 0; i < withUrl.length; i++) {
      const o = withUrl[i];
      const pct = 10 + Math.floor((i / withUrl.length) * 80);
      await setP({ phase: 'reading', pct, message: `🔗 פותח וקורא: ${o.title}…` });
      let text = '';
      try {
        let url = o.url; if (!/^https?:\/\//i.test(url)) url = 'https://' + url;
        const tab = await chrome.tabs.create({ url, active: false });
        try {
          await waitForTabComplete(tab.id);
          await new Promise(r => setTimeout(r, 3000)); // let JS render
          const [res] = await chrome.scripting.executeScript({
            target: { tabId: tab.id },
            func: () => (document.body ? document.body.innerText.slice(0, 8000) : ''),
          }).catch(() => [null]);
          text = res?.result || '';
        } finally { try { await chrome.tabs.remove(tab.id); } catch {} }
      } catch { /* extraction failed — server falls back to title+description */ }

      await setP({ phase: 'thinking', pct: pct + 3, message: `✨ מבין ומייצר פוסטים ל: ${o.title}…` });
      const r = await post('ai-mode/analyze-offering', { id: o.id, title: o.title, url: o.url, description: o.description, audience: o.audience, page_text: text, count: 3 });
      if (r?.ok) {
        total += r.generated || 0;
        conclusions.push(`${o.title}: ${r.generated || 0} טיוטות${r.understanding ? ` — הבנתי: ${String(r.understanding).slice(0, 70)}` : ''}`);
      } else {
        conclusions.push(`${o.title}: דילג (${r?.error || 'שגיאה'})`);
      }
      await new Promise(r => setTimeout(r, 1500)); // pace
    }
    conclusions.push(`✅ נוצרו ${total} טיוטות. אשר אותן בדשבורד (✓/✗) לפני פרסום.`);
    await setP({ running: false, phase: 'done', pct: 100, message: `✓ נקראו ${withUrl.length} לינקים · ${total} טיוטות מחכות לאישור`, conclusions, finishedAt: Date.now() });
    await Logs.add('info', `🔗 הסוכן קרא ${withUrl.length} לינקים ויצר ${total} טיוטות לאישור`);
  } catch (e) {
    await setP({ running: false, phase: 'error', error: String(e?.message || e), conclusions });
  }
};

// Timeline post scan — module-level so remote AI_SCAN_START works on a cold SW.
self.startHistoryScan = async (payload = {}) => {
          try {
            _scanAbort = false;
            obLog.event('ai_mode_history_scan_start');
            await setScanProgress({ running: true, phase: 'starting', pct: 3, error: null, result: null, startedAt: Date.now() });
            const { token } = await getSession();
            if (!token) { await setScanProgress({ running: false, phase: 'error', error: 'לא מחובר' }); return; }
            // MINIMUM EXPOSURE by default — a tiny FB session to avoid blocks.
            // "Scan deeper" raises these explicitly.
            const max = Number(payload?.maxPosts) || 15;
            const maxScrolls = Number(payload?.maxScrolls) || 6;

            // MEMORY of what we already scanned, so we never re-visit / re-process
            // the same posts (less exposure) and the footprint accumulates.
            const seenKeys = new Set((await getSetting('scanSeenKeys', [])) || []);
            const accumFp = (await getSetting('scanFootprint', {})) || {};
            const postKey = (p) => p.url || ((p.group_id || '') + '|' + String(p.text || '').slice(0, 48));

            // Step 1 — open/reuse FB profile tab
            await setScanProgress({ phase: 'opening_fb', pct: 10, message: 'פותח את פייסבוק ברקע…' });
            let tab;
            const existing = await chrome.tabs.query({ url: '*://*.facebook.com/me*' });
            if (existing.length > 0) {
              tab = existing[0];
            } else {
              tab = await chrome.tabs.create({ url: 'https://www.facebook.com/me', active: false });
              await new Promise((resolve) => {
                const lt = (tabId, info) => {
                  if (tabId === tab.id && info.status === 'complete') {
                    chrome.tabs.onUpdated.removeListener(lt);
                    resolve();
                  }
                };
                chrome.tabs.onUpdated.addListener(lt);
                setTimeout(() => { chrome.tabs.onUpdated.removeListener(lt); resolve(); }, 15000);
              });
              await new Promise(r => setTimeout(r, 2000));
            }

            // Enable live screenshots — activate the FB tab so the user can WATCH
            // the scan work and captureVisibleTab targets the right tab.
            _scanCapturing = true; _scanShots = []; _lastShotAt = 0;
            _scanTabId = tab.id; _scanWindowId = tab.windowId;
            try { await chrome.tabs.update(tab.id, { active: true }); } catch {}
            await new Promise(r => setTimeout(r, 600));
            await grabScanShot('פתיחת הפיד');

            // Step 2 — scrape (deep). The content script scrolls up to maxScrolls,
            // firing SCAN_SCROLL_TICK so we grab screenshots along the way.
            await setScanProgress({ phase: 'scraping', pct: 25, message: `סורק עד ${max} פוסטים (עומק ${maxScrolls} גלילות)…`, screenshots: _scanShots });
            const scrape = await chrome.tabs.sendMessage(tab.id, {
              type: 'SCRAPE_MY_FB_POSTS', payload: { maxPosts: max, maxScrolls },
            });
            await grabScanShot('סוף הסריקה');
            _scanCapturing = false;
            if (!scrape?.ok) {
              obLog.error('ai_mode_history_scrape_failed', { context: { error: scrape?.error } });
              await setScanProgress({ running: false, phase: 'error', error: scrape?.error || 'הסריקה נכשלה', screenshots: _scanShots });
              return;
            }
            const allPosts = scrape.posts || [];
            const screenshots = _scanShots.slice();
            if (_scanAbort) { await setScanProgress({ running: false, phase: 'error', error: '⏹ הסריקה נעצרה', screenshots }); return; }

            // Identity (best-effort)
            let fbIdentity = null;
            try {
              fbIdentity = await chrome.tabs.sendMessage(tab.id, { type: 'DETECT_FB_IDENTITY' });
              if (fbIdentity?.loggedIn) await setSetting('fbIdentity', fbIdentity);
            } catch {}

            // Step 2a — keep only posts we HAVEN'T scanned before (don't revisit).
            const freshPosts = [];
            let skipped = 0;
            for (const p of allPosts) {
              const k = postKey(p);
              if (seenKeys.has(k)) { skipped++; continue; }
              seenKeys.add(k);
              freshPosts.push(p);
            }

            // Step 2b — split OUTWARD (group) posts from own-wall/friend posts.
            // The user cares about what she publishes IN GROUPS / outward.
            const groupPosts = freshPosts.filter(p => p.group_id);
            const wallPosts  = freshPosts.filter(p => !p.group_id);

            // Step 2c — merge new group posts into the ACCUMULATED footprint
            // (grows across scans; groups already found are not re-scanned).
            const newGroupIds = new Set();
            for (const p of groupPosts) {
              newGroupIds.add(p.group_id);
              const f = accumFp[p.group_id] || (accumFp[p.group_id] = {
                fb_group_id: p.group_id, name: p.group_name || p.group_id,
                post_count: 0, likes: 0, comments: 0, shares: 0,
              });
              f.post_count++;
              f.likes += Number(p.likes) || 0;
              f.comments += Number(p.comments) || 0;
              f.shares += Number(p.shares) || 0;
              if (p.group_name && (!f.name || f.name === f.fb_group_id)) f.name = p.group_name;
            }
            const footprint = Object.values(accumFp).sort((a, b) => b.post_count - a.post_count);

            // Persist memory (cap the seen-set so storage stays small).
            try {
              await setSetting('scanSeenKeys', [...seenKeys].slice(-3000));
              await setSetting('scanFootprint', accumFp);
            } catch {}

            // Step 2d — AUTO-IMPORT only groups with NEW activity this run.
            let imported = 0;
            for (const gid of newGroupIds) {
              const g = accumFp[gid];
              try {
                await Groups.upsertByFbId(g.fb_group_id, {
                  name: g.name,
                  url: `https://www.facebook.com/groups/${g.fb_group_id}/`,
                  source: 'history-footprint',
                  footprintPosts: g.post_count,
                  footprintEngagement: g.likes + g.comments * 2 + g.shares * 3,
                });
                imported++;
              } catch {}
            }
            // This run's NEW group posts — shown to the user (not just a summary).
            const groupPostSamples = groupPosts.slice(0, 40).map(p => ({
              group_id: p.group_id, group_name: p.group_name || p.group_id,
              text: String(p.text || '').slice(0, 200),
              likes: p.likes || 0, comments: p.comments || 0, shares: p.shares || 0,
              url: p.url || null,
            }));
            const conclusions = buildScanConclusions({ groupPosts: groupPosts.length, wallPosts: wallPosts.length, footprint, skipped, analysis: null });
            await setScanProgress({
              phase: 'footprint', pct: 55,
              message: freshPosts.length
                ? `חדש: ${groupPosts.length} פוסטים בקבוצות · ${skipped} כבר נסרקו (דילוג) · footprint: ${footprint.length} קבוצות`
                : `אין פוסטים חדשים — כל ${skipped} שנסרקו כבר מוכרים. footprint: ${footprint.length} קבוצות`,
              footprint, imported,
              groupPosts: groupPostSamples,
              screenshots, conclusions,
              counts: { group: groupPosts.length, wall: wallPosts.length, total: allPosts.length, fresh: freshPosts.length, skipped },
            });

            // If nothing new was found, stop here — no re-upload, no re-analysis,
            // no extra exposure. We already know this ground.
            if (freshPosts.length === 0) {
              await setScanProgress({
                running: false, phase: 'done', pct: 100,
                message: `✓ אין חדש — ${skipped} פוסטים כבר נסרקו. footprint: ${footprint.length} קבוצות. (לכיסוי רחב יותר: "סרוק עמוק יותר")`,
                footprint, imported, groupPosts: [], screenshots, conclusions,
                counts: { group: 0, wall: 0, total: allPosts.length, fresh: 0, skipped },
                finishedAt: Date.now(),
              });
              return;
            }

            // Step 3 — upload only the FRESH posts (prefer group posts).
            const toUpload = groupPosts.length ? groupPosts : freshPosts;
            await setScanProgress({ phase: 'uploading', pct: 70, message: 'מעלה לניתוח…', footprint, imported, groupPosts: groupPostSamples, counts: { group: groupPosts.length, wall: wallPosts.length, total: allPosts.length, fresh: freshPosts.length, skipped } });
            const upload = await fetch(`${SYNC.endpoint}/api/v1/ai-mode/history-snapshots`, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
              body: JSON.stringify({ platform: 'facebook', posts: toUpload }),
            }).then(r => r.json());
            obLog.event('ai_mode_history_uploaded', { context: { count: toUpload.length, inserted: upload.inserted } });

            // Step 4 — analyze
            await setScanProgress({ phase: 'analyzing', pct: 85, message: 'ה-AI מנתח דפוסים…', footprint, imported, groupPosts: groupPostSamples, counts: { group: groupPosts.length, wall: wallPosts.length, total: allPosts.length } });
            const analyzeR = await fetch(`${SYNC.endpoint}/api/v1/ai-mode/analyze-history`, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
              body: JSON.stringify({}),
            }).then(r => r.json());
            if (!analyzeR.ok) {
              obLog.error('ai_mode_analysis_failed', { context: { error: analyzeR.error } });
              await setScanProgress({ running: false, phase: 'error', error: analyzeR.error || 'הניתוח נכשל', footprint, imported, groupPosts: groupPostSamples });
              return;
            }
            obLog.event('ai_mode_analysis_ok', { context: { topics: (analyzeR.analysis?.topics || []).length } });
            const doneConclusions = buildScanConclusions({ groupPosts: groupPosts.length, wallPosts: wallPosts.length, footprint, skipped, analysis: analyzeR.analysis });
            await setScanProgress({
              running: false, phase: 'done', pct: 100,
              message: `✓ הושלם — ${groupPosts.length} פוסטים חדשים בקבוצות · footprint מצטבר: ${footprint.length} קבוצות · ${(analyzeR.analysis?.topics || []).length} נושאים${skipped ? ` · ${skipped} דילוג` : ''}`,
              footprint, imported, groupPosts: groupPostSamples,
              screenshots, conclusions: doneConclusions,
              counts: { group: groupPosts.length, wall: wallPosts.length, total: allPosts.length, fresh: freshPosts.length, skipped },
              result: {
                scraped: allPosts.length,
                groupPosts: groupPosts.length,
                uploaded: upload.inserted || 0,
                analysis: analyzeR.analysis,
                identity: fbIdentity || null,
              },
              finishedAt: Date.now(),
            });
          } catch (e) {
            obLog.error('ai_mode_history_scan_exception', { context: { msg: String(e?.message || e) } });
            await setScanProgress({ running: false, phase: 'error', error: String(e?.message || e) });
          } finally { _scanCapturing = false; }
        };

async function loadThrottleConfig(platform = null) {
  const overrides = (await getSetting('throttleConfig', {})) || {};
  // Migrate only legacy INVALID values (>23) left over from the old 0-28 input.
  // A user-chosen 23 stays 23 — do NOT silently extend past midnight.
  if (overrides.activeHourEnd > 23) {
    overrides.activeHourEnd = 23;
    await setSetting('throttleConfig', overrides);
  }
  // Apply platform-specific profile on top of DEFAULTS, then user overrides
  const platformProfile = platform ? profileForPlatform(platform) : {};
  return { ...DEFAULTS, ...platformProfile, ...overrides };
}

const ALARM_NAME = 'fb-poster-tick';
const SYNC_ALARM = 'fb-poster-sync';
const AI_GENERATE_ALARM = 'fb-poster-ai-gen';
const AI_MODE_ALARM = 'organicai-ai-mode';
const DISCOVER_ALARM = 'fb-poster-discover';
const AUTO_JOIN_ALARM = 'fb-poster-auto-join';
const METRICS_ALARM = 'fb-poster-metrics';
const COMMENTS_ALARM = 'fb-poster-comments';
const EVENING_REFRESH_ALARM = 'fb-poster-evening-refresh';
const HUMANIZE_ALARM = 'fb-poster-humanize';
const HEARTBEAT_ALARM = 'fb-poster-heartbeat';
const LEADS_SCAN_ALARM = 'organicai-leads-scan';
const LI_GROWTH_ALARM = 'organicai-li-growth';
const TW_GROWTH_ALARM = 'organicai-tw-growth';
const RD_GROWTH_ALARM = 'organicai-rd-growth';
const LI_REPLY_ALARM = 'organicai-li-reply';
const HUNTER_ALARM = 'organicai-hunter-autorun';

function installAlarms() {
  chrome.alarms.create(ALARM_NAME, { periodInMinutes: 1 });
  chrome.alarms.create(SYNC_ALARM, { periodInMinutes: 1 });
  chrome.alarms.create(AI_GENERATE_ALARM, { periodInMinutes: 360 });
  chrome.alarms.create(AI_MODE_ALARM, { periodInMinutes: 60 }); // AI-Mode autonomous heartbeat (hourly, ADR-39)
  chrome.alarms.create(DISCOVER_ALARM, { periodInMinutes: 1440 });
  chrome.alarms.create(AUTO_JOIN_ALARM, { periodInMinutes: 240 });
  chrome.alarms.create(METRICS_ALARM, { periodInMinutes: 120 });
  chrome.alarms.create(COMMENTS_ALARM, { periodInMinutes: 5 });
  // Evening cron: forces a full refresh of ALL done posts' metrics once per day.
  // We use a 1-hour heartbeat and check internally if it's the configured time.
  chrome.alarms.create(EVENING_REFRESH_ALARM, { periodInMinutes: 60 });
  // Humanize alarm: runs every 8 hours, performs small human-like actions
  chrome.alarms.create(HUMANIZE_ALARM, { periodInMinutes: 480 });
  // Heartbeat: every 30s tells the server we're alive + pulls pending commands
  chrome.alarms.create(HEARTBEAT_ALARM, { periodInMinutes: 0.5 });
  // Leads scan: every 30 min, harvest comments from published posts and surface leads
  chrome.alarms.create(LEADS_SCAN_ALARM, { periodInMinutes: 30 });
  // LinkedIn growth pass: every 4 hours, if enabled + within work hours, picks
  // a keyword from liGrowthKeywords and engages with hot posts via the search
  // results page. Strict caps: ≤3 likes + ≤1 comment per run. Skipped if user
  // has hit any LinkedIn warning recently.
  chrome.alarms.create(LI_GROWTH_ALARM, { periodInMinutes: 240 });
  // Twitter/X growth pass: every 4 hours. Like-only by default (X penalizes
  // patterned engagement aggressively in 2026). Daily ceiling: 30 likes.
  chrome.alarms.create(TW_GROWTH_ALARM, { periodInMinutes: 240 });
  chrome.alarms.create(RD_GROWTH_ALARM, { periodInMinutes: 360 }); // every 6h — Reddit anti-spam is strict
  chrome.alarms.create(HUNTER_ALARM, { periodInMinutes: 60 }); // every hour — hunter auto-run
  // LinkedIn auto-reply to commenters: every 5 min, scan our LI posts from the
  // last 180 min, find new commenters not yet replied to, generate AI reply,
  // post it within the algorithm window. Disabled by default.
  chrome.alarms.create(LI_REPLY_ALARM, { periodInMinutes: 5 });
}

chrome.runtime.onInstalled.addListener(async (details) => {
  installAlarms();
  await Logs.add('info', `Extension ${details.reason}`);
  // Show onboarding on first install — but ONLY when the account actually needs
  // it. A returning user reinstalling (session survives via the storage.sync
  // mirror) whose key is already in the DB should get a ready-to-use plugin, not
  // a fresh wizard. So we probe the server first and open onboarding only if
  // something is genuinely missing. (ADR-30)
  if (details.reason === 'install') {
    const done = await getSetting('onboardingCompleted');
    if (!done) {
      let hasSession = false, hasKey = false;
      try {
        // Guard against a hung network on install — 5s cap, then treat as
        // "unknown" and fall back to showing onboarding.
        const probe = (async () => {
          const sess = await verifySession(); // restores + validates sync-mirror session
          if (!sess.ok) return;
          hasSession = true;
          const ks = await getServerKeyStatus();
          hasKey = !!ks.hasKey;
        })();
        await Promise.race([probe, new Promise((r) => setTimeout(r, 5000))]);
      } catch { /* fall through to onboarding */ }

      if (hasSession && hasKey) {
        // Fully configured returning user — skip the wizard entirely.
        await setSetting('onboardingCompleted', Date.now());
        await Logs.add('info', 'Setup restored from server (session + key) — onboarding skipped');
      } else {
        // Onboarding will bootstrap itself and skip whatever is already done
        // (e.g. jump straight to the Gemini step if only the key is missing).
        chrome.tabs.create({ url: chrome.runtime.getURL('onboarding/onboarding.html') });
      }
    }
  }
});

chrome.runtime.onStartup.addListener(installAlarms);

chrome.alarms.onAlarm.addListener(async (alarm) => {
  try {
    if (alarm.name === ALARM_NAME) {
      await applyCampaignSchedules();
      await syncAiModeSchedule(); // ADR-31: promote due AI-Mode schedule rows → jobs
      await tick();
      // After every tick we push new logs/jobs upstream so the popup view shows fresh data.
      try { await runSync(); } catch {}
    }
    if (alarm.name === SYNC_ALARM) {
      const r = await runSync();
      if (!r.skipped) await Logs.add('info', 'Sync done', r.totals);
    }
    if (alarm.name === AI_GENERATE_ALARM) {
      await topUpCampaignPosts();
    }
    if (alarm.name === AI_MODE_ALARM) {
      await runAiModeOrchestrator();
    }
    if (alarm.name === DISCOVER_ALARM) {
      await runTrackedDiscoveries();
    }
    if (alarm.name === AUTO_JOIN_ALARM) {
      await runAutoJoinBatch();
    }
    if (alarm.name === METRICS_ALARM) {
      await scrapeDueMetrics();
    }
    if (alarm.name === COMMENTS_ALARM) {
      await runCommentsQueue();
    }
    if (alarm.name === EVENING_REFRESH_ALARM) {
      await maybeRunEveningRefresh();
    }
    if (alarm.name === HUMANIZE_ALARM) {
      await runHumanizePass();
    }
    if (alarm.name === HEARTBEAT_ALARM) {
      await runHeartbeat();
    }
    if (alarm.name === LEADS_SCAN_ALARM) {
      await runLeadsScan();
    }
    if (alarm.name === LI_GROWTH_ALARM) {
      await runLinkedInGrowthPass();
    }
    if (alarm.name === RD_GROWTH_ALARM) {
      await runRedditGrowthPass();
    }
    if (alarm.name === HUNTER_ALARM) {
      await runHunterAutoRun();
    }
    if (alarm.name === TW_GROWTH_ALARM) {
      await runTwitterGrowthPass();
    }
    if (alarm.name === LI_REPLY_ALARM) {
      await runLinkedInAutoReplyPass();
    }
    // One-shot precision LinkedIn first-comment alarm (named li-first-comment-<jobId>)
    // — fires inside the 60-90s algorithm window for max reach boost.
    if (alarm.name.startsWith('li-first-comment-')) {
      await runCommentsQueue();
      try { chrome.alarms.clear(alarm.name); } catch {}
    }
  } catch (e) {
    await Logs.add('error', `alarm ${alarm.name} crashed`, { error: String(e) });
  }
});

// ---------- Leads Auto-Extraction ----------
// Every 30 min:
//  1. Ask worker which posts are due for re-scan
//  2. For each, open a background tab to the post URL
//  3. Ask the content script to harvest visible comments
//  4. POST comments to /api/v1/leads/scan — worker scores + dedups + inserts leads
//  5. Close the tab, throttle between scans
async function runLeadsScan() {
  const session = await getSession();
  if (!session.token) return;

  // Fetch pending scans
  let scans = [];
  try {
    const r = await fetch(`${SYNC.endpoint}/api/v1/leads/pending-scans`, {
      headers: { 'X-Session-Token': session.token },
    });
    const j = await r.json();
    if (!j.ok) throw new Error(j.error || 'pending-scans failed');
    scans = j.scans || [];
  } catch (e) {
    obLog.error('leads_scan_pending_fail', { context: { msg: String(e) } });
    await Logs.add('warn', 'leads-scan: pending-scans failed', { error: String(e) });
    return;
  }

  if (scans.length === 0) return;
  obLog.event('leads_scan_start', { context: { due: scans.length } });
  await Logs.add('info', `leads-scan: ${scans.length} posts due`);

  for (const scan of scans) {
    try {
      const comments = await harvestCommentsInBgTab(scan.post_url, scan.platform);
      if (!comments || !comments.length) {
        // Still report empty scans — server reschedules
        await fetch(`${SYNC.endpoint}/api/v1/leads/scan`, {
          method: 'POST',
          headers: { 'X-Session-Token': session.token, 'Content-Type': 'application/json' },
          body: JSON.stringify({ jobId: scan.job_id, postUrl: scan.post_url, comments: [] }),
        });
        continue;
      }
      const res = await fetch(`${SYNC.endpoint}/api/v1/leads/scan`, {
        method: 'POST',
        headers: { 'X-Session-Token': session.token, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          jobId: scan.job_id,
          postUrl: scan.post_url,
          comments,
        }),
      });
      const j = await res.json();
      if (j.ok && j.newLeads > 0) {
        await Logs.add('info', `leads-scan: +${j.newLeads} new leads from post`, { jobId: scan.job_id });
        // Notify the user
        try {
          chrome.notifications?.create?.(`lead-${scan.job_id}`, {
            type: 'basic',
            iconUrl: chrome.runtime.getURL('assets/icon-128.png'),
            title: `🌱 ${j.newLeads} ${j.newLeads === 1 ? 'ליד חדש' : 'לידים חדשים'}`,
            message: `OrganicAI מצא לידים בתגובות לפוסט שלך. פתח את הדשבורד.`,
          });
        } catch {}
      }
      // Throttle between scans — Gaussian-ish 15-25 sec
      const wait = 15000 + Math.random() * 10000;
      await new Promise(r => setTimeout(r, wait));
    } catch (e) {
      await Logs.add('warn', 'leads-scan: post failed', { jobId: scan.job_id, error: String(e) });
    }
  }
}

// Open the post URL in a background tab, ask the content script to harvest,
// then close the tab. Returns array of {author, authorUrl, text, timestamp}.
async function harvestCommentsInBgTab(postUrl, platform) {
  let tab;
  try {
    tab = await chrome.tabs.create({ url: postUrl, active: false });
    // Wait for the page to load + content script to be ready (up to 12s)
    const ready = await waitForTabReady(tab.id, 12000);
    if (!ready) throw new Error('tab not ready in time');
    // Send harvest message to the content script
    const response = await chrome.tabs.sendMessage(tab.id, {
      type: 'HARVEST_COMMENTS',
      platform: platform || 'facebook',
    }).catch(() => null);
    return response?.comments || [];
  } finally {
    if (tab?.id) {
      try { await chrome.tabs.remove(tab.id); } catch {}
    }
  }
}

function waitForTabReady(tabId, timeoutMs = 12000) {
  return new Promise((resolve) => {
    const t = setTimeout(() => { chrome.tabs.onUpdated.removeListener(h); resolve(false); }, timeoutMs);
    const h = (id, info) => {
      if (id === tabId && info.status === 'complete') {
        clearTimeout(t);
        chrome.tabs.onUpdated.removeListener(h);
        // Give the content script another moment to inject
        setTimeout(() => resolve(true), 1500);
      }
    };
    chrome.tabs.onUpdated.addListener(h);
  });
}

// ---------- Heartbeat + Remote Command Executor ----------
// Every 30s: pings the server (proves we're online), pulls any pending commands,
// executes them, posts results back.
// Which build of which browser this agent is (ADR-44). Read from the UA rather
// than guessed: Path A (ADR-43) puts this same extension inside Edge Canary /
// Quetta on Android, and the fleet must be able to tell a phone from a laptop —
// they have genuinely different abilities and different mobile DOM selectors.
function detectAgentPlatform() {
  const ua = navigator.userAgent || '';
  const android = /Android/i.test(ua);
  if (android && /EdgA|Edg\//i.test(ua)) return 'edge-android';
  if (android && /Quetta/i.test(ua)) return 'quetta-android';
  if (android) return 'chromium-android';
  if (/Macintosh|Mac OS X/i.test(ua)) return 'mac';
  if (/Windows/i.test(ua)) return 'windows';
  if (/Linux/i.test(ua)) return 'linux';
  return 'unknown';
}

// What this agent can ACTUALLY do right now — not what the product promises.
// `screenshot` is gated on the optional `debugger` permission, which is exactly
// why it is reported separately: without it the agent still browses fine, and the
// server can say so instead of letting a screenshot request die in a 28s timeout.
async function detectAgentCapabilities() {
  const caps = ['browser', 'publish', 'scan', 'sync'];
  if (await hasDebuggerPermission()) caps.push('screenshot');
  if (await getSetting('agentRecording', true)) caps.push('recording');
  return caps;
}

async function runHeartbeat() {
  const session = await getSession();
  if (!session.token) return; // not logged in — skip
  try {
    const manifest = chrome.runtime.getManifest();
    const res = await fetch(`${SYNC.endpoint}/devices/heartbeat`, {
      method: 'POST',
      headers: { 'X-Session-Token': session.token, 'Content-Type': 'application/json' },
      // The heartbeat doubles as agent registration (ADR-44). Recomputed every
      // beat, never cached: `screenshot` depends on an OPTIONAL permission the
      // user can grant or revoke at any moment, so a value captured at startup
      // would quietly lie for the rest of the session.
      body: JSON.stringify({
        version: manifest.version,
        platform: detectAgentPlatform(),
        capabilities: await detectAgentCapabilities(),
      }),
    });
    // A 401 here is USUALLY transient (server cold-start, a race, or a token
    // rotation) — NOT a real invalidation (login does not kill other sessions).
    // Confirm via /me before logging out, and only clear on a DEFINITIVE
    // rejection (e.g. admin password reset / logout-all). Never log the user
    // out on a transient blip — they stay connected unless truly invalidated.
    if (res.status === 401) {
      const v = await verifySession();
      if (!v.ok && !v.networkError) {
        await Logs.add('warn', '🚪 Session confirmed invalid — logging out.');
        await clearSession();
      }
      return;
    }
    const json = await res.json();
    if (!json.ok || !json.commands?.length) return;
    // Execute commands in order
    for (const cmd of json.commands) {
      try {
        const result = await executeRemoteCommand(cmd);
        await fetch(`${SYNC.endpoint}/devices/commands/${cmd.id}/result`, {
          method: 'POST',
          headers: { 'X-Session-Token': session.token, 'Content-Type': 'application/json' },
          body: JSON.stringify({ ok: true, result }),
        });
        await Logs.add('info', `🎛 Remote cmd ${cmd.type} executed`, { result });
      } catch (e) {
        await fetch(`${SYNC.endpoint}/devices/commands/${cmd.id}/result`, {
          method: 'POST',
          headers: { 'X-Session-Token': session.token, 'Content-Type': 'application/json' },
          body: JSON.stringify({ ok: false, error: String(e?.message || e) }),
        });
        await Logs.add('error', `🎛 Remote cmd ${cmd.type} failed`, { error: String(e) });
      }
    }
  } catch (e) {
    // Network down? Silently ignore — next heartbeat will retry
  }
}

// Vocabulary of remote commands the server can send.
// Add new types here — each gets its own handler.
// 🎥 Flight recorder — STRUCTURAL, deliberately not per-case.
// Every browser primitive is recorded HERE, in the dispatch path, so evidence
// is not left to whoever writes the next `case` to remember. That is exactly
// how extract/type/screenshot/close ended up unrecorded the first time.
//
// What gets recorded, and why the phases differ per primitive:
//  • navigate/click/type → `<phase>:before` AND `<phase>` (after). The PAIR is
//    the whole point: "the page looked like X, we acted, then Y happened" is
//    what makes a failure explainable weeks later. A lone after-shot cannot
//    distinguish "we clicked the wrong thing" from "the page had already changed".
//  • close → `close` BEFORE executing. The tab is destroyed by the command, so
//    a shot afterwards is of nothing. The last frame IS the evidence.
//  • extract/screenshot/wait → after only. They don't mutate the page, so a
//    before-shot would be a duplicate frame for double the latency.
// Throws are recorded too — a crashed step is the most important frame of all.
const RECORD_BEFORE = new Set(['BROWSER_NAVIGATE', 'BROWSER_CLICK', 'BROWSER_TYPE']);

async function executeRemoteCommand(cmd) {
  const type = cmd?.type || '';
  if (!type.startsWith('BROWSER_')) return executeRemoteCommandInner(cmd);

  const phase = type.slice('BROWSER_'.length).toLowerCase();
  // Resolve without creating: on the first navigate no agent tab exists yet and
  // that is fine — the before-shot is simply skipped, not an error.
  let tabId = null;
  try { tabId = await agentResolveTab(cmd?.payload?.tabId); } catch {}

  if (tabId != null && RECORD_BEFORE.has(type)) {
    await recordAgentStep({ tabId, action: 'agent', phase: `${phase}:before` });
  }
  if (tabId != null && type === 'BROWSER_CLOSE') {
    await recordAgentStep({ tabId, action: 'agent', phase: 'close', outcome: 'ok' });
  }

  try {
    const out = await executeRemoteCommandInner(cmd);
    if (type !== 'BROWSER_CLOSE') {
      await recordAgentStep({
        // NAVIGATE may have just created the tab — prefer the id it returns.
        tabId: out?.tabId ?? tabId,
        action: 'agent', phase,
        outcome: out?.ok ? 'ok' : 'failed',
        error: out?.ok ? null : (out?.error || null),
      });
    }
    return out;
  } catch (e) {
    await recordAgentStep({
      tabId, action: 'agent', phase, outcome: 'failed',
      error: String(e?.message || e),
    });
    throw e;
  }
}

async function executeRemoteCommandInner(cmd) {
  const { type, payload } = cmd;
  switch (type) {
    case 'PING':
      return { pong: true, at: Date.now() };

    case 'SYNC_NOW':
      return await runSync();

    case 'RUN_TICK_NOW':
      await tick();
      return { ok: true };

    case 'CAPTURE_SCREENSHOT': {
      // Remote browser screenshot — requestable via the API. Captures the
      // active tab's visible area, uploads it to R2, and returns the URL
      // (keeps the command result small — the image lives on the server).
      try {
        let winId;
        try {
          const [active] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
          winId = active?.windowId;
        } catch {}
        const dataUrl = await chrome.tabs.captureVisibleTab(winId, {
          format: 'jpeg', quality: Math.min(90, Math.max(20, Number(payload?.quality) || 60)),
        });
        const { getSession } = await import('../lib/auth.js');
        const { SYNC } = await import('../lib/config.js');
        const { token } = await getSession();
        const r = await fetch(`${SYNC.endpoint}/api/v1/screenshots`, {
          method: 'POST',
          headers: { 'X-Session-Token': token, 'Content-Type': 'application/json' },
          body: JSON.stringify({ dataUrl, note: payload?.note || null }),
        });
        const j = await r.json().catch(() => ({}));
        return j.ok ? { ok: true, url: j.url, capturedAt: Date.now() } : { ok: false, error: j.error || 'upload failed' };
      } catch (e) {
        return { ok: false, error: String(e?.message || e) };
      }
    }

    case 'AI_SCAN_START': {
      // Remote-trigger the post scan from the dashboard.
      if (typeof self.startHistoryScan === 'function') {
        self.startHistoryScan(payload || {});
        await Logs.add('info', '🎛 סריקת פוסטים הופעלה מרחוק');
        return { ok: true, started: true };
      }
      return { ok: false, error: 'scan not ready — plugin waking, retry in a moment' };
    }

    case 'AI_GROUP_SCAN_START': {
      if (typeof self.startGroupScan === 'function') {
        self.startGroupScan(payload || {});
        await Logs.add('info', '🎛 סריקת קבוצות הופעלה מרחוק');
        return { ok: true, started: true };
      }
      return { ok: false, error: 'group scan not ready' };
    }

    case 'AI_AUTOPILOT_START': {
      if (typeof self.runAutopilot === 'function') {
        self.runAutopilot(payload || {});
        await Logs.add('info', '🎛 אוטופיילוט (DRY RUN) הופעל מרחוק');
        return { ok: true, started: true };
      }
      return { ok: false, error: 'autopilot not ready' };
    }

    case 'AI_MODE_FULL_RESET': {
      if (typeof self.resetAiModeAll === 'function') {
        self.resetAiModeAll(payload || {});
        await Logs.add('info', '🎛 איפוס תהליך AI הופעל מרחוק');
        return { ok: true, started: true };
      }
      return { ok: false, error: 'reset not ready' };
    }

    case 'AI_AGENT_CYCLE_START': {
      if (typeof self.runAgentCycle === 'function') {
        self.runAgentCycle(payload || {});
        await Logs.add('info', '🎛 מחזור סוכן מלא הופעל מרחוק');
        return { ok: true, started: true };
      }
      return { ok: false, error: 'agent cycle not ready' };
    }

    case 'AI_BUILD_FROM_OFFERINGS': {
      if (typeof self.buildFromOfferings === 'function') {
        self.buildFromOfferings(payload || {});
        await Logs.add('info', '🎛 קריאת לינקים ובניית פוסטים הופעלה מרחוק');
        return { ok: true, started: true };
      }
      return { ok: false, error: 'build-from-offerings not ready' };
    }

    case 'AI_AUTONOMOUS_SET': {
      if (typeof self.setAutonomousMode === 'function') {
        const r = await self.setAutonomousMode(payload || {});
        await Logs.add('info', `🎛 מצב אוטונומי ${payload?.enabled ? 'הופעל' : 'כובה'} מרחוק`);
        return r;
      }
      return { ok: false, error: 'autonomous mode not ready' };
    }

    // ── Browser agent primitives (PLAN P1) — the observe→act loop ──────────
    case 'BROWSER_NAVIGATE': {
      const url = String(payload?.url || '');
      if (isBlockedAgentUrl(url)) return { ok: false, error: 'blocked or invalid url' };
      let tabId = await agentResolveTab(payload?.tabId);
      if (tabId == null) {
        const t = await chrome.tabs.create({ url, active: false }); // never steal focus
        tabId = t.id; _agentTabId = tabId;
        await waitForTabReady(tabId, 20000);
      } else {
        await navigateAndWait(tabId, url, 20000, false);
      }
      const t = await chrome.tabs.get(tabId);
      return { ok: true, tabId, url: t.url, title: t.title }; // recording: see the wrapper
    }

    case 'BROWSER_EXTRACT': {
      const tabId = await agentResolveTab(payload?.tabId);
      if (tabId == null) return { ok: false, error: 'no agent tab — BROWSER_NAVIGATE first' };
      // Returns page text PLUS interactive elements stamped with stable refs,
      // so the AI can say "click ref 12" instead of guessing a CSS selector.
      const out = await agentInject(tabId, (maxEls) => {
        const SEL = 'a[href],button,input,textarea,select,[role="button"],[role="link"],[contenteditable="true"]';
        const vis = [...document.querySelectorAll(SEL)].filter((e) => {
          const r = e.getBoundingClientRect();
          return r.width > 0 && r.height > 0 && getComputedStyle(e).visibility !== 'hidden';
        }).slice(0, maxEls);
        const elements = vis.map((e, i) => {
          e.setAttribute('data-oai-ref', String(i));
          const label = (e.innerText || e.value || e.getAttribute('aria-label') ||
                         e.getAttribute('placeholder') || e.getAttribute('title') || '').trim().replace(/\s+/g, ' ').slice(0, 80);
          return { ref: i, tag: e.tagName.toLowerCase(), type: e.getAttribute('type') || null, label, href: e.getAttribute('href') || null };
        });
        return {
          url: location.href, title: document.title,
          text: (document.body?.innerText || '').replace(/\s+/g, ' ').slice(0, 6000),
          elements,
        };
      }, [Math.min(Number(payload?.maxElements) || 60, 150)]);
      return { ok: true, ...(out || {}) };
    }

    case 'BROWSER_CLICK': {
      const tabId = await agentResolveTab(payload?.tabId);
      if (tabId == null) return { ok: false, error: 'no agent tab' };
      const selector = payload?.selector || (payload?.ref != null ? `[data-oai-ref="${String(payload.ref).replace(/"/g, '')}"]` : null);
      if (!selector) return { ok: false, error: 'ref or selector required' };
      const out = await agentInject(tabId, (sel) => {
        const el = document.querySelector(sel);
        if (!el) return { clicked: false, error: 'element not found' };
        el.scrollIntoView({ block: 'center' });
        // A bare el.click() is ignored by SPAs that listen on pointer/mouse events
        // (LinkedIn's composer is the case that exposed this: the click "succeeded"
        // but the modal never opened). Dispatch the full, ordered gesture with real
        // coordinates instead. We do NOT also call el.click() — that would fire a
        // second click and risk double-submitting. (ADR-52)
        const r = el.getBoundingClientRect();
        const x = r.left + r.width / 2, y = r.top + r.height / 2;
        const base = { bubbles: true, cancelable: true, composed: true, view: window,
                       clientX: x, clientY: y, button: 0, buttons: 1 };
        try { el.focus({ preventScroll: true }); } catch (e) {}
        const P = (t) => new PointerEvent(t, { ...base, pointerId: 1, isPrimary: true, pointerType: 'mouse' });
        const M = (t) => new MouseEvent(t, { ...base, detail: 1 });
        el.dispatchEvent(P('pointerover')); el.dispatchEvent(M('mouseover'));
        el.dispatchEvent(P('pointerdown')); el.dispatchEvent(M('mousedown'));
        el.dispatchEvent(new PointerEvent('pointerup', { ...base, buttons: 0, pointerId: 1, isPrimary: true, pointerType: 'mouse' }));
        el.dispatchEvent(new MouseEvent('mouseup', { ...base, buttons: 0, detail: 1 }));
        el.dispatchEvent(new MouseEvent('click', { ...base, buttons: 0, detail: 1 }));
        return { clicked: true, label: (el.innerText || '').trim().slice(0, 60) };
      }, [selector]);
      return { ok: !!out?.clicked, ...(out || {}) }; // recording: see the wrapper
    }

    case 'BROWSER_TYPE': {
      const tabId = await agentResolveTab(payload?.tabId);
      if (tabId == null) return { ok: false, error: 'no agent tab' };
      const selector = payload?.selector || (payload?.ref != null ? `[data-oai-ref="${String(payload.ref).replace(/"/g, '')}"]` : null);
      if (!selector) return { ok: false, error: 'ref or selector required' };
      const out = await agentInject(tabId, (sel, text) => {
        const el = document.querySelector(sel);
        if (!el) return { typed: false, error: 'element not found' };
        el.focus();
        if (el.isContentEditable) {
          // Rich editors (LinkedIn/Quill, Facebook/Lexical) keep their OWN model.
          // Assigning textContent leaves that model thinking the box is still
          // empty — the text shows but "Post" stays disabled. execCommand
          // ('insertText') goes through the real beforeinput/input path the
          // editor listens to, so its model updates too. (ADR-52)
          let ok = false;
          try {
            const sel = window.getSelection();
            const range = document.createRange();
            range.selectNodeContents(el);          // replace whatever is there
            sel.removeAllRanges(); sel.addRange(range);
            ok = document.execCommand('insertText', false, text);
          } catch (e) { ok = false; }
          if (!ok) el.textContent = text;          // last-resort fallback
        } else {
          // React/Vue read the native setter — bypass their value shadowing.
          const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
          const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
          setter ? setter.call(el, text) : (el.value = text);
        }
        el.dispatchEvent(new Event('input', { bubbles: true }));
        el.dispatchEvent(new Event('change', { bubbles: true }));
        return { typed: true };
      }, [selector, String(payload?.text ?? '')]);
      return { ok: !!out?.typed, ...(out || {}) };
    }

    case 'BROWSER_SCREENSHOT': {
      const tabId = await agentResolveTab(payload?.tabId);
      if (tabId == null) return { ok: false, error: 'no agent tab' };
      const dataUrl = await captureTabShot(tabId, Math.min(90, Math.max(20, Number(payload?.quality) || 50)));
      if (!dataUrl) return { ok: false, error: 'capture failed — grant the debugger permission (popup → 🎥) to shoot background tabs' };
      const { token } = await getSession();
      const r = await fetch(`${SYNC.endpoint}/api/v1/screenshots`, {
        method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
        body: JSON.stringify({ dataUrl }),
      }).then(x => x.json()).catch(() => ({}));
      return { ok: !!r?.ok, url: r?.url || null };
    }

    case 'BROWSER_WAIT': {
      const ms = Math.min(Math.max(Number(payload?.ms) || 1000, 100), 30000);
      await new Promise(r => setTimeout(r, ms));
      return { ok: true, waited: ms };
    }

    case 'BROWSER_CLOSE': {
      const tabId = await agentResolveTab(payload?.tabId);
      if (tabId != null) { try { await chrome.tabs.remove(tabId); } catch {} }
      if (tabId === _agentTabId) _agentTabId = null;
      return { ok: true };
    }

    case 'AI_SCAN_STOP': {
      _scanAbort = true;
      _scanCapturing = false;
      await Logs.add('info', '🎛 בקשת עצירת סריקה התקבלה מרחוק');
      return { ok: true, stopping: true };
    }

    case 'PAUSE_ALL': {
      const all = await Campaigns.list();
      for (const c of all.filter((x) => x.active)) {
        await Campaigns.upsert({ ...c, active: 0 });
      }
      await setSetting('remotePauseActive', true);
      return { pausedCampaigns: all.filter((x) => x.active).length };
    }

    case 'RESUME_ALL': {
      const all = await Campaigns.list();
      for (const c of all.filter((x) => !x.active)) {
        await Campaigns.upsert({ ...c, active: 1 });
      }
      await setSetting('remotePauseActive', false);
      return { resumedCampaigns: all.filter((x) => !x.active).length };
    }

    case 'SHOW_TOAST': {
      // Try to forward to popup if open
      try {
        chrome.runtime.sendMessage({ type: 'SHOW_TOAST', message: payload?.message || '', level: payload?.level || 'info' });
      } catch {}
      // Also create a notification fallback
      try {
        chrome.notifications.create({
          type: 'basic',
          iconUrl: chrome.runtime.getURL('icons/icon128.png'),
          title: payload?.title || 'OrganicAI',
          message: payload?.message || '',
        });
      } catch {}
      return { ok: true };
    }

    case 'RELOAD_EXTENSION':
      setTimeout(() => chrome.runtime.reload(), 500);
      return { ok: true, willReloadIn: 500 };

    case 'FORCE_UPDATE_CHECK': {
      // Asks Chrome to consult update.xml NOW instead of waiting up to 5h.
      // Reports back: 'no_update' (already latest), 'throttled' (Chrome
      // rate-limits update checks to one per ~hour), or 'update_available'
      // — in which case Chrome will install + reload the extension on its
      // own within a few seconds. We log the current version so the
      // dashboard can see the before/after.
      const currentVersion = chrome.runtime.getManifest().version;
      obLog.event('force_update_check_start', { context: { currentVersion } });
      return await new Promise((resolve) => {
        try {
          chrome.runtime.requestUpdateCheck((status, details) => {
            const err = chrome.runtime.lastError?.message;
            const result = {
              ok: !err,
              currentVersion,
              status: status || 'unknown',
              newVersion: details?.version || null,
              error: err || null,
            };
            obLog.event('force_update_check_done', { context: result });
            resolve(result);
          });
        } catch (e) {
          obLog.error('force_update_check_exception', { context: { msg: String(e?.message || e) } });
          resolve({ ok: false, currentVersion, error: String(e?.message || e) });
        }
      });
    }

    case 'COLLECT_DIAGNOSTICS': {
      const logs = await Logs.list();
      const recent = logs.slice(-50);
      const cfg = await getSetting('throttleConfig', {});
      const settings = {
        autoJoinEnabled: await getSetting('autoJoinEnabled', false),
        aiAutoGenerate: await getSetting('aiAutoGenerate', false),
        commentsEnabled: await getSetting('commentsEnabled', false),
      };
      return { recentLogs: recent, throttleConfig: cfg, settings, manifestVersion: chrome.runtime.getManifest().version };
    }

    case 'SET_THROTTLE': {
      await setSetting('throttleConfig', { ...await getSetting('throttleConfig', {}), ...(payload || {}) });
      return { ok: true };
    }

    case 'PUBLISH_NOW': {
      // payload: { jobId }
      if (!payload?.jobId) throw new Error('jobId required');
      const job = await Jobs.get(payload.jobId);
      if (!job) throw new Error('job not found');
      await Jobs.upsert({ ...job, scheduledAt: Date.now(), status: 'queued' });
      // Trigger immediate tick
      await tick();
      return { ok: true, jobId: payload.jobId };
    }

    default:
      throw new Error(`Unknown command type: ${type}`);
  }
}

// ---------- Comments queue runner ----------
// Posts due comments one at a time with humanlike pacing.
// Honors the same FB-warning backoff as auto-join.
async function runCommentsQueue() {
  const enabled = await getSetting('commentsEnabled', true);
  if (!enabled) return;

  // Respect FB warning backoff
  const lastWarnAt = (await getSetting('lastFbWarningAt', 0)) || 0;
  const backoff = (await getSetting('warningBackoffMs', 6 * 3600 * 1000)) || (6 * 3600 * 1000);
  if (Date.now() - lastWarnAt < backoff) return;

  const now = Date.now();
  const queued = await Comments.listByStatus('queued');
  const due = queued
    .filter((c) => c.approvedByUser && (c.scheduledAt || 0) <= now)
    .sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0))
    .slice(0, 2); // max 2 per cycle

  if (!due.length) return;
  await Logs.add('info', `💬 Comments queue: ${due.length} due`);

  const safeMode = await getSetting('safeMode', false);
  const reuse = await getSetting('reusePublisherTab', true);

  for (const c of due) {
    try {
      const tab = await acquirePublisherTab(c.parentPostUrl, !!safeMode, reuse);
      await waitForTabComplete(tab.id);
      await new Promise((r) => setTimeout(r, 3000));
      const r = await chrome.tabs.sendMessage(tab.id, {
        type: 'POST_COMMENT',
        payload: { text: c.text },
      });
      if (r?.warning) {
        await env_recordWarning(r.warning);
        await Comments.update(c, { status: 'failed', lastError: 'fb-warning' });
        await Logs.add('error', `💬 ⚠ FB warning during comment — stopping batch`);
        return;
      }
      if (r?.ok) {
        await Comments.update(c, { status: 'posted', postedAt: Date.now() });
        await Logs.add('info', `💬 ✅ Commented (${c.kind}) on ${c.parentPostUrl.slice(0, 60)}`);
      } else {
        await Comments.update(c, { status: 'failed', lastError: r?.error || 'unknown' });
        await Logs.add('warn', `💬 ❌ Comment failed: ${r?.error}`);
      }
      // 60-180 sec between comments
      const wait = 60000 + Math.random() * 120000;
      await new Promise((r) => setTimeout(r, wait));
    } catch (e) {
      await Comments.update(c, { status: 'failed', lastError: String(e) });
      await Logs.add('warn', `💬 Comment exception: ${String(e)}`);
    }
  }
}

// Auto-bump: after a successful publish, schedule a follow-up comment to
// boost the post's visibility.
// Platform-aware: LinkedIn rewards a FIRST-COMMENT within 30s (algorithm boost),
// while FB benefits from a delayed bump 2-6 hours later.
async function scheduleAutoBumpForJob(job, post, group) {
  if (!job.postUrl) return;
  const platform = group?.platform || 'facebook';

  // LinkedIn first-comment (within 30-60 seconds) — algorithm signal.
  // CRITICAL: the COMMENTS_ALARM runs every 5 min, which is way too slow for
  // LinkedIn's algorithm window. We schedule a one-shot precision alarm so the
  // comment lands at exactly the chosen second, inside LI's reach-decision
  // window (first 60-90s = highest boost).
  if (platform === 'linkedin') {
    const enabled = await getSetting('liFirstCommentEnabled', true);
    if (!enabled) return;
    const text = (await getSetting('liFirstCommentText')) ||
      'מצרף קישור עם תוכן נוסף בנושא בתגובה הראשונה 👇';
    const delayMs = 30 * 1000 + Math.random() * 60 * 1000; // 30-90 seconds
    const scheduledAt = Date.now() + delayMs;
    await Comments.enqueue({
      parentPostUrl: job.postUrl,
      parentJobId: job.id,
      kind: 'first-comment',
      text,
      scheduledAt,
      aiGenerated: false,
      approvedByUser: true,
    });
    // Precision-fire: create a unique one-shot alarm at the scheduled instant
    // so we hit the 60-90s LI algo window with sub-minute accuracy rather
    // than waiting up to 5 min for COMMENTS_ALARM.
    const oneShotName = `li-first-comment-${job.id}`;
    try {
      chrome.alarms.create(oneShotName, { when: scheduledAt + 2000 });
    } catch (e) {
      // Fall back gracefully — COMMENTS_ALARM will eventually pick it up
    }
    await Logs.add('info', `💬 LinkedIn first-comment scheduled in ${Math.round(delayMs / 1000)}s (precision alarm) for ${job.id.slice(0, 8)}`);
    return;
  }

  // FB / generic bump
  const enabled = await getSetting('autoBumpEnabled', false);
  if (!enabled) return;
  const text = (await getSetting('autoBumpText')) || 'מעלה למעלה ⬆ שיגיע לכמה שיותר אנשים';
  const delayMs = (2 + Math.random() * 4) * 3600 * 1000;
  await Comments.enqueue({
    parentPostUrl: job.postUrl,
    parentJobId: job.id,
    kind: 'bump',
    text,
    scheduledAt: Date.now() + delayMs,
    aiGenerated: false,
    approvedByUser: true,
  });
  await Logs.add('info', `💬 Auto-bump scheduled in ${Math.round(delayMs / 3600000)}h for job ${job.id.slice(0, 8)}`);
}

// ---------- Wave 2: Humanize pass ----------
// Periodically performs small "look like a real user" actions on FB:
// watches a couple of Stories, varies reactions, RSVPs to events,
// wishes birthdays. Each is gated by its own setting.
async function runHumanizePass() {
  const enabled = await getSetting('humanizeEnabled', false);
  if (!enabled) return;

  // Respect FB warning backoff
  const lastWarnAt = (await getSetting('lastFbWarningAt', 0)) || 0;
  const backoff = (await getSetting('warningBackoffMs', 6 * 3600 * 1000)) || (6 * 3600 * 1000);
  if (Date.now() - lastWarnAt < backoff) return;

  await Logs.add('info', '🎭 Humanize pass starting');
  const safeMode = await getSetting('safeMode', false);
  const reuse = await getSetting('reusePublisherTab', true);

  // Pick ONE action per pass (don't do everything every cycle)
  const actions = [];
  if (await getSetting('humanizeStories', true)) actions.push('stories');
  if (await getSetting('humanizeReactions', true)) actions.push('reactions');
  if (await getSetting('humanizeBirthdays', false)) actions.push('birthdays');
  if (await getSetting('humanizeRsvp', false)) actions.push('rsvp');
  if (!actions.length) return;

  const pick = actions[Math.floor(Math.random() * actions.length)];
  const urls = {
    stories: 'https://www.facebook.com/',
    reactions: 'https://www.facebook.com/',
    birthdays: 'https://www.facebook.com/events/birthdays/',
    rsvp: 'https://www.facebook.com/events/',
  };
  const msgTypes = {
    stories: 'HUMANIZE_STORIES',
    reactions: 'HUMANIZE_REACTIONS',
    birthdays: 'HUMANIZE_BIRTHDAYS',
    rsvp: 'HUMANIZE_RSVP',
  };

  try {
    const tab = await acquirePublisherTab(urls[pick], !!safeMode, reuse);
    await waitForTabComplete(tab.id);
    await new Promise((r) => setTimeout(r, 3000));
    const r = await chrome.tabs.sendMessage(tab.id, { type: msgTypes[pick], payload: {} });
    if (r?.ok) await Logs.add('info', `🎭 Humanize ${pick}: ${r.count || 0} actions`);
    else await Logs.add('warn', `🎭 Humanize ${pick} failed: ${r?.error}`);
  } catch (e) {
    await Logs.add('warn', `🎭 Humanize ${pick} exception: ${String(e)}`);
  }
}

// ---------- LinkedIn organic-growth pass ----------
// THE growth flywheel for LinkedIn (2026 research): commenting on hot posts in
// your niche drives 7x more profile views than posting alone. Personal profiles
// get 8x more engagement than company pages. The 80/20 rule says spend 80% of
// LinkedIn time engaging on others' content, 20% posting your own.
//
// What this does:
//   1. Picks ONE keyword from the user's liGrowthKeywords list (round-robin).
//   2. Opens https://www.linkedin.com/search/results/content/?keywords=<kw>&sortBy="date_posted"
//   3. Sends SEARCH_ENGAGE message to the LI content script with strict caps.
//   4. Logs results.
//
// Safeguards:
//   - Disabled by default (liGrowthEnabled setting). User must opt in.
//   - Skipped outside work hours (9-19) and on weekends (Fri-Sat in Israel).
//   - Skipped if any LI warning was seen in last `linkedinBackoffMs` (default 24h).
//   - Caps: 3 likes + 0-1 comment per run. With 4h alarm + 9h work day = max
//     9 likes + 3 comments per workday (well below LI safe threshold).
//   - Respects remotePauseActive global pause.
async function runLinkedInGrowthPass() {
  if (await getSetting('remotePauseActive', false)) return;
  const enabled = await getSetting('liGrowthEnabled', false);
  if (!enabled) return;

  // Work-hours gate (default 9-19 Israel time, weekdays only)
  const now = new Date();
  const day = now.getDay(); // 0=Sun, 5=Fri, 6=Sat — Israel weekend is Fri-Sat
  const startHour = Number(await getSetting('liGrowthStartHour', 9));
  const endHour = Number(await getSetting('liGrowthEndHour', 19));
  if (day === 5 || day === 6) return;
  if (now.getHours() < startHour || now.getHours() >= endHour) return;

  // Backoff gate
  const lastWarnAt = (await getSetting('lastLinkedInWarningAt', 0)) || 0;
  const backoff = (await getSetting('linkedinBackoffMs', 24 * 3600 * 1000)) || (24 * 3600 * 1000);
  if (Date.now() - lastWarnAt < backoff) {
    await Logs.add('info', '🔍 LI growth pass skipped — within warning backoff');
    return;
  }

  const keywords = (await getSetting('liGrowthKeywords', [])) || [];
  if (!keywords.length) return;

  // Round-robin: pick the next keyword by index, persist for next run
  let idx = Number(await getSetting('liGrowthKeywordIdx', 0)) || 0;
  if (idx >= keywords.length) idx = 0;
  const keyword = keywords[idx];
  obLog.event('li_growth_start', { context: { keyword, kwIdx: idx } });
  await setSetting('liGrowthKeywordIdx', idx + 1);

  // Comment text — only used when comment-on-search is enabled AND a value
  // exists. Empty = like-only mode (safer default).
  const allowComment = await getSetting('liGrowthCommentEnabled', false);
  const commentText = allowComment ? (await getSetting('liGrowthCommentText', '') || '') : '';
  // AI mode generates a unique value-add comment per post (requires Gemini key)
  const aiCommentEnabled = allowComment && !!(await getSetting('liGrowthAiCommentEnabled', false)) && !!(await getGeminiKey());
  // Skip authors the user has flagged
  const skipAuthorUrls = (await getSetting('liGrowthSkipAuthors', [])) || [];

  const url = `https://www.linkedin.com/search/results/content/?keywords=${encodeURIComponent(keyword)}&sortBy=%22date_posted%22`;
  await Logs.add('info', `🔍 LI growth pass: keyword="${keyword}"`);

  let tab;
  try {
    tab = await chrome.tabs.create({ url, active: false });
    const ready = await waitForTabReady(tab.id, 15000);
    if (!ready) throw new Error('search tab not ready');
    // Extra dwell — LI is heavy
    await new Promise(r => setTimeout(r, 4000));

    const response = await chrome.tabs.sendMessage(tab.id, {
      type: 'SEARCH_ENGAGE',
      payload: {
        maxLikes: Number(await getSetting('liGrowthMaxLikes', 3)) || 3,
        maxComments: allowComment && (aiCommentEnabled || commentText) ? 1 : 0,
        commentText,
        aiCommentEnabled,
        skipAuthorUrls,
        minBodyChars: 80,
        throttle: { typingMinMsPerChar: 60, typingMaxMsPerChar: 200 },
      },
    }).catch(e => ({ ok: false, error: String(e?.message || e) }));

    if (response?.ok) {
      const liked = response.results?.liked?.length || 0;
      const commented = response.results?.commented?.length || 0;
      await Logs.add('info', `🔍 LI growth done: +${liked} likes, +${commented} comments on "${keyword}"`);
    } else if (response?.warning) {
      await setSetting('lastLinkedInWarningAt', Date.now());
      await Logs.add('error', `🔍 LI warning during growth pass: ${response.warning.kind}`, response.warning);
    } else {
      await Logs.add('warn', `🔍 LI growth pass: ${response?.error || 'no response'}`);
    }
  } catch (e) {
    await Logs.add('warn', `🔍 LI growth pass exception: ${String(e)}`);
  } finally {
    if (tab?.id) { try { await chrome.tabs.remove(tab.id); } catch {} }
  }
}

// ---------- LinkedIn auto-reply pass ----------
// Closes the engagement feedback loop within LinkedIn's algorithm window.
// LinkedIn boosts posts whose author replies to commenters within ~90 min
// of the comment landing. The first-comment booster (already implemented)
// handles the 30-90s post-publish window; THIS pass handles every subsequent
// commenter throughout the first 3 hours.
//
// Flow per cycle:
//   1. Find LI jobs where status='done', completedAt in last `liReplyWindowMin`
//      (default 180 min), has postUrl
//   2. For each (max 3 per cycle):
//      a. Get tracking[jobId] = { authorKey: timestamp } from settings
//      b. Open postUrl in background tab
//      c. HARVEST_COMMENTS (existing handler)
//      d. For each new commenter (key=authorUrl|author, not in tracking, not self):
//         - Generate AI reply via Gemini (with original post as context)
//         - Send REPLY_TO_COMMENT to content script
//         - Update tracking[jobId][authorKey] = now
//         - Cap: 2 replies per post per cycle
//         - Pause 45-90s between replies
//      e. Save tracking back
//      f. Close tab
//
// Safety:
//   - Disabled by default (liAutoReplyEnabled)
//   - Requires Gemini key
//   - 24h backoff on any LI warning (uses linkedinBackoffMs)
//   - Cap: 6 replies per cycle (3 posts × 2 replies)
//   - Tracking prevents double-reply across cycles
//   - Tracking auto-pruned for jobs older than 7 days
async function runLinkedInAutoReplyPass() {
  if (await getSetting('remotePauseActive', false)) return;
  const enabled = await getSetting('liAutoReplyEnabled', false);
  if (!enabled) return;
  if (!(await getGeminiKey())) { obLog.warn('li_reply_no_gemini'); return; }
  obLog.event('li_reply_pass_start');

  const lastWarnAt = (await getSetting('lastLinkedInWarningAt', 0)) || 0;
  const backoff = (await getSetting('linkedinBackoffMs', 24 * 3600 * 1000)) || (24 * 3600 * 1000);
  if (Date.now() - lastWarnAt < backoff) return;

  const windowMin = Number(await getSetting('liReplyWindowMin', 180)) || 180;
  const windowMs = windowMin * 60 * 1000;
  const cutoff = Date.now() - windowMs;

  // Find candidate jobs: linkedin platform + status done + within window + has postUrl
  const allJobs = await Jobs.list();
  const allGroups = await Groups.list();
  const groupById = new Map(allGroups.map(g => [g.id, g]));
  const candidates = allJobs
    .filter(j => j.status === 'done' && j.postUrl && (j.completedAt || 0) >= cutoff)
    .filter(j => (groupById.get(j.groupId)?.platform || 'facebook') === 'linkedin')
    .sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0))
    .slice(0, 3); // max 3 posts per cycle

  if (!candidates.length) return;

  // Load tracking + prune entries for jobs we no longer care about
  const tracking = (await getSetting('liAutoReplyTracking', {})) || {};
  const sevenDaysAgo = Date.now() - 7 * 24 * 3600 * 1000;
  for (const jid of Object.keys(tracking)) {
    const job = allJobs.find(j => j.id === jid);
    if (!job || (job.completedAt || 0) < sevenDaysAgo) delete tracking[jid];
  }

  const selfHandle = ((await getSetting('liSelfHandle', '')) || '').toLowerCase().replace(/^\/+|\/+$/g, '');
  const userExpertise = (await getSetting('userExpertise', '')) || '';

  let totalRepliesSent = 0;
  const PER_POST_CAP = 2;
  const TOTAL_CAP_PER_CYCLE = 6;

  for (const job of candidates) {
    if (totalRepliesSent >= TOTAL_CAP_PER_CYCLE) break;
    if (!tracking[job.id]) tracking[job.id] = {};
    const replied = tracking[job.id];

    // Get the original post text (for Gemini context). Try to fetch via repo.
    let originalPostText = '';
    try {
      const post = await Posts.get(job.postId);
      originalPostText = (post?.text || '').slice(0, 800);
    } catch {}

    let tab;
    try {
      tab = await chrome.tabs.create({ url: job.postUrl, active: false });
      const ready = await waitForTabReady(tab.id, 15000);
      if (!ready) throw new Error('post tab not ready');
      await new Promise(r => setTimeout(r, 3500));

      const harvest = await chrome.tabs.sendMessage(tab.id, {
        type: 'HARVEST_COMMENTS',
        platform: 'linkedin',
      }).catch(() => ({ ok: false, comments: [] }));

      const comments = (harvest?.comments || []);
      if (!comments.length) continue;

      let perPostSent = 0;
      for (const c of comments) {
        if (perPostSent >= PER_POST_CAP) break;
        if (totalRepliesSent >= TOTAL_CAP_PER_CYCLE) break;
        const authorKey = (c.authorUrl || c.author || '').toLowerCase();
        if (!authorKey) continue;
        if (replied[authorKey]) continue; // already replied
        // Skip self
        if (selfHandle && authorKey.includes(selfHandle)) continue;
        // Skip if comment is too short to warrant AI reply
        if ((c.text || '').length < 12) continue;

        // Generate AI reply
        const aiResp = await geminiGenerateLinkedInReply(c.text, {
          commentAuthor: c.author || '',
          originalPostText,
          userExpertise,
        });
        if (!aiResp.ok) {
          await Logs.add('info', `💬 LI auto-reply: AI declined for ${c.author} (${aiResp.error})`);
          replied[authorKey] = Date.now(); // mark as "processed" so we don't keep retrying
          continue;
        }

        // Post the reply via content-script REPLY_TO_COMMENT
        const post = await chrome.tabs.sendMessage(tab.id, {
          type: 'REPLY_TO_COMMENT',
          payload: {
            authorMatch: c.author,
            authorUrl: c.authorUrl,
            commentSnippet: (c.text || '').slice(0, 60),
            replyText: aiResp.text,
            throttle: { typingMinMsPerChar: 60, typingMaxMsPerChar: 200 },
          },
        }).catch(e => ({ ok: false, error: String(e?.message || e) }));

        if (post?.warning) {
          await setSetting('lastLinkedInWarningAt', Date.now());
          await Logs.add('error', `💬 LI auto-reply hit warning ${post.warning.kind} — pausing 24h`, post.warning);
          // Abort the entire cycle
          tracking[job.id] = replied;
          await setSetting('liAutoReplyTracking', tracking);
          if (tab?.id) { try { await chrome.tabs.remove(tab.id); } catch {} }
          return;
        }

        if (post?.ok) {
          replied[authorKey] = Date.now();
          perPostSent++;
          totalRepliesSent++;
          await Logs.add('info', `💬 LI auto-reply ✓ to ${c.author} on ${job.id.slice(0, 8)}: "${aiResp.text.slice(0, 60)}…"`);
          // Pause 45-90s between replies on the same post
          await new Promise(r => setTimeout(r, 45000 + Math.random() * 45000));
        } else {
          await Logs.add('warn', `💬 LI auto-reply failed for ${c.author}: ${post?.error || 'unknown'}`);
          replied[authorKey] = Date.now(); // mark to avoid infinite retry
        }
      }

      tracking[job.id] = replied;
    } catch (e) {
      await Logs.add('warn', `💬 LI auto-reply pass exception on job ${job.id.slice(0,8)}: ${String(e)}`);
    } finally {
      if (tab?.id) { try { await chrome.tabs.remove(tab.id); } catch {} }
    }

    // Pause between posts (30-90s) so we don't navigate too fast
    if (totalRepliesSent < TOTAL_CAP_PER_CYCLE) {
      await new Promise(r => setTimeout(r, 30000 + Math.random() * 60000));
    }
  }

  await setSetting('liAutoReplyTracking', tracking);
  if (totalRepliesSent > 0) {
    await Logs.add('info', `💬 LI auto-reply cycle done: ${totalRepliesSent} replies across ${candidates.length} posts`);
  }
}

// ---------- Twitter/X growth pass ----------
// Same shape as runLinkedInGrowthPass but for X. Like-only (no reply yet —
// X's algorithmic abuse detection is harsher than LI's). Disabled by default.
// ─── Hunter auto-run — discover prospects + log to server ──────────
// Calls /api/v1/hunter/auto-run which iterates all user's active services
// with auto_run=1. Server respects daily caps. We just trigger + log.
// ─── Hunter — open prospect site in background tab, fill+submit form ──
// Called from background after auto-send tries email/whatsapp and prospect
// has only a website (no contact). Or from popup explicitly.
async function fillProspectContactForm({ url, name, email, phone, message, company, autoSubmit = true }) {
  let tab;
  try {
    tab = await chrome.tabs.create({ url, active: false });
    const ready = await waitForTabReady(tab.id, 15000);
    if (!ready) throw new Error('tab not ready');
    await new Promise(r => setTimeout(r, 2500)); // let JS render
    // First detect
    const detect = await chrome.tabs.sendMessage(tab.id, { type: 'DETECT_CONTACT_FORM' })
      .catch(e => ({ ok: false, error: String(e?.message || e) }));
    if (!detect?.found) {
      return { ok: false, error: detect?.error || 'no contact form on page', formUrl: url };
    }
    // Then fill + submit
    const fill = await chrome.tabs.sendMessage(tab.id, {
      type: 'FILL_CONTACT_FORM',
      payload: { name, email, phone, message, company, autoSubmit },
    }).catch(e => ({ ok: false, error: String(e?.message || e) }));
    // Wait a moment for submission to register
    if (fill?.submitted) await new Promise(r => setTimeout(r, 2500));
    return { ...fill, formScore: detect.formScore, detectedFields: detect.fields };
  } catch (e) {
    return { ok: false, error: String(e?.message || e) };
  } finally {
    if (tab?.id) { try { await chrome.tabs.remove(tab.id); } catch {} }
  }
}

async function runHunterAutoRun() {
  if (await getSetting('remotePauseActive', false)) return;
  try {
        const session = await getSession();
    if (!session?.token) return;
    const r = await fetch(`${SYNC.endpoint}/api/v1/hunter/auto-run`, {
      method: 'POST',
      headers: { 'X-Session-Token': session.token, 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    });
    const j = await r.json().catch(() => ({}));
    if (j.ok) {
      if (j.discovered > 0) {
        await Logs.add('info', `🎯 Hunter auto-run: ${j.discovered} prospects discovered across ${j.services?.length || 0} services`);
      }
      if (j.queuedForms > 0) {
        await Logs.add('info', `📝 Hunter: ${j.queuedForms} prospects queued for contact-form fill`);
      }
    } else {
      obLog.warn('hunter_autorun_failed', { context: { error: j.error } });
    }
  } catch (e) {
    obLog.error('hunter_autorun_exception', { context: { msg: String(e?.message || e) } });
  }
  // After auto-run, drain any contact-form fills the server queued for us.
  try { await drainHunterFormQueue(); } catch (e) {
    obLog.warn('hunter_formqueue_exception', { context: { msg: String(e?.message || e) } });
  }
}

// ─── Hunter — drain the server's contact-form queue ────────────────
// The server can't fill forms (no browser), so when a prospect has only a
// website it queues the fill here. We pull pending items, open each site in
// a background tab, fill + submit the form, and report the result back.
async function drainHunterFormQueue() {
  if (await getSetting('remotePauseActive', false)) return;
  const session = await getSession();
  if (!session?.token) return;

  const r = await fetch(`${SYNC.endpoint}/api/v1/hunter/form-queue?limit=3`, {
    headers: { 'X-Session-Token': session.token },
  });
  const j = await r.json().catch(() => ({}));
  const queue = (j && j.ok && Array.isArray(j.queue)) ? j.queue : [];
  if (!queue.length) return;

  await Logs.add('info', `📝 Hunter form-fill: processing ${queue.length} site(s)`);
  for (const item of queue) {
    let result;
    try {
      result = await fillProspectContactForm({
        url: item.url, message: item.message, name: item.name || item.company || '',
        company: item.company || '', email: '', phone: '', autoSubmit: true,
      });
    } catch (e) {
      result = { ok: false, error: String(e?.message || e) };
    }
    // Report back regardless of outcome so the server can advance the prospect.
    try {
      await fetch(`${SYNC.endpoint}/api/v1/hunter/form-queue/${item.id}/result`, {
        method: 'POST',
        headers: { 'X-Session-Token': session.token, 'Content-Type': 'application/json' },
        body: JSON.stringify({
          ok: !!result?.ok, submitted: !!result?.submitted,
          no_form: result?.error?.includes('no contact form') || false,
          form_score: result?.formScore ?? null,
          error: result?.error || null,
        }),
      });
    } catch (e) {
      obLog.warn('hunter_formresult_failed', { context: { msg: String(e?.message || e) } });
    }
    // Gentle pause between sites so we don't hammer / look like a bot
    await new Promise(res => setTimeout(res, 8000 + Math.random() * 7000));
  }
}

// ─── Browser-side Google SERP discovery ("VHunter via Google") ─────
// Opens a Google search in a background tab, scrapes business/lead signals
// via content/google-serp.js, and ingests them as Hunter prospects — no
// Google CSE API key required. Best-effort (Google may show a captcha).
async function runGoogleDiscover(query, country) {
  const session = await getSession();
  if (!session?.token) return { ok: false, error: 'not logged in' };
  if (!query) return { ok: false, error: 'no query' };
  const gl = country ? `&gl=${encodeURIComponent(country)}` : '';
  const url = `https://www.google.com/search?q=${encodeURIComponent(query)}&num=30${gl}`;
  let tab;
  try {
    tab = await chrome.tabs.create({ url, active: false });
    const ready = await waitForTabReady(tab.id, 15000);
    if (!ready) throw new Error('tab not ready');
    await new Promise(r => setTimeout(r, 2500)); // let results render
    const serp = await chrome.tabs.sendMessage(tab.id, { type: 'SCRAPE_GOOGLE_SERP' })
      .catch(e => ({ ok: false, error: String(e?.message || e) }));
    if (!serp?.ok) return { ok: false, error: serp?.error || 'scrape failed (Google captcha?)' };
    const r = await fetch(`${SYNC.endpoint}/api/v1/hunter/discover-browser`, {
      method: 'POST',
      headers: { 'X-Session-Token': session.token, 'Content-Type': 'application/json' },
      body: JSON.stringify({ query, organic: serp.organic || [], local: serp.local || [] }),
    });
    const j = await r.json().catch(() => ({}));
    if (j.ok) await Logs.add('info', `🔎 גילוי גוגל "${query}": ${j.added} עסקים נוספו (${serp.organic?.length || 0} אורגני, ${serp.local?.length || 0} מקומי)`);
    return j;
  } catch (e) {
    return { ok: false, error: String(e?.message || e) };
  } finally {
    if (tab?.id) { try { await chrome.tabs.remove(tab.id); } catch {} }
  }
}

// ─── AI-Mode orchestrator (the perpetual loop) ─────────────────────
// Runs every 6h. ONLY acts when there is an ACTIVE, non-paused AI-Mode run.
// It keeps the pipeline fed — tops up generated content and (re)builds the
// ramped, cooldown-aware schedule — so the posting tick never runs dry. It
// does NOT publish anything itself: posting stays on the throttled tick, which
// respects dry_run + the 5→15/day ramp + per-group cooldown. Safe by design.
// Autonomous heartbeat (ADR-39). Runs hourly. ONLY acts when the user flipped
// the master switch (agentAutonomous) AND there's an active, non-paused run. It
// keeps the pipeline fed — generates content (from offerings/style), tops up, and
// (re)builds the ramped, cooldown-aware schedule — so the agent works on its own,
// consistently, without the user touching anything. Publishing stays on the
// minute-tick + ADR-31 bridge (all human-like throttle gates apply); comments on
// the comments alarm. Safe by design: honors dry_run + all caps.
async function runAiModeOrchestrator() {
  if (await getSetting('remotePauseActive', false)) return;
  if (!(await getSetting('agentAutonomous', false))) return; // master switch OFF → manual mode
  const session = await getSession();
  if (!session?.token) return;
  const hdr = { 'X-Session-Token': session.token, 'Content-Type': 'application/json' };
  try {
    const state = await fetch(`${SYNC.endpoint}/api/v1/ai-mode/state`, { headers: hdr })
      .then(r => r.json()).catch(() => ({}));
    if (!state?.active || !state.run) return; // no active run — nothing to do
    if (['paused', 'done', 'failed'].includes(state.run.status)) return;

    await Logs.add('info', `🤖 סוכן אוטונומי — פעימה (${state.run.dry_run ? 'DRY RUN' : 'LIVE'}): מרענן תוכן ולוז`);
    // 1) Generate/top-up content so the schedule always has fresh posts. Uses the
    //    server's source priority: real-post style → offerings → needs_input.
    const gen = await fetch(`${SYNC.endpoint}/api/v1/ai-mode/generate-content`, {
      method: 'POST', headers: hdr, body: JSON.stringify({ count: 8 }),
    }).then(r => r.json()).catch(() => ({}));
    if (gen?.needsInput) {
      await Logs.add('warn', '🤖 סוכן אוטונומי: אין ממה לייצר — הוסף "מה לשווק" (לינקים) בדשבורד');
    }
    // 2) Extend the ramped, cooldown-aware, policy-respecting schedule.
    const sched = await fetch(`${SYNC.endpoint}/api/v1/ai-mode/build-schedule`, {
      method: 'POST', headers: hdr, body: JSON.stringify({ days: 30 }),
    }).then(r => r.json()).catch(() => ({}));
    if (sched?.ok) {
      await Logs.add('info', `🤖 לוז עודכן: ${sched.scheduled} פרסומים על ${sched.eligibleGroups} קבוצות (ramp ${sched.rampStart}→${sched.rampMax})`);
    }
  } catch (e) {
    obLog.warn('ai_mode_orchestrator_exception', { context: { msg: String(e?.message || e) } });
  }
}

// Master switch for autonomous mode (ADR-39). Enabling activates a run (dry_run
// per `live`) and turns the hourly heartbeat on; disabling stops it. One toggle —
// after this the user does nothing; the agent works consistently on its own.
self.setAutonomousMode = async ({ enabled, live = false } = {}) => {
  await setSetting('agentAutonomous', !!enabled);
  const session = await getSession();
  if (!session?.token) return { ok: false, error: 'not logged in' };
  const hdr = { 'X-Session-Token': session.token, 'Content-Type': 'application/json' };
  if (enabled) {
    // Ensure there's an active run of the right mode (DRY RUN default; LIVE gated).
    await fetch(`${SYNC.endpoint}/api/v1/ai-mode/activate`, {
      method: 'POST', headers: hdr, body: JSON.stringify({ dry_run: !live }),
    }).catch(() => {});
    await Logs.add('info', `🟢 סוכן אוטונומי הופעל (${live ? 'LIVE — יפרסם בפועל' : 'DRY RUN — טיוטות לאישור'}) — עובד לבד מעכשיו`);
    try { await runAiModeOrchestrator(); } catch {} // immediate first heartbeat
  } else {
    await fetch(`${SYNC.endpoint}/api/v1/ai-mode/stop`, {
      method: 'POST', headers: hdr, body: JSON.stringify({ reason: 'autonomous_off' }),
    }).catch(() => {});
    await Logs.add('info', '🔴 סוכן אוטונומי כובה — חוזר למצב ידני');
  }
  return { ok: true, enabled: !!enabled, live: !!live };
};

// ─── AI-Mode schedule CONSUMER (ADR-31) — the missing publish bridge ───────
// The server plans posts into ai_scheduled_publish; nothing published them
// before. This pulls DUE, LIVE rows and promotes each into a real local publish
// job, so it flows through the SAME proven tick→runJob→PUBLISH_POST chain with
// all safety/throttle/cooldown/daily-cap gates. Outcomes are reported back via
// reportAiScheduleStatus (called from tickInner). Runs on the main alarm, before
// tick(). No-ops unless there's an active LIVE run with due rows.
function parseFbGroupId(url) {
  if (!url) return null;
  const m = String(url).match(/\/groups\/([^/?#]+)/i);
  return m ? m[1] : null;
}

async function getOrCreateAiModeCampaign() {
  let id = await getSetting('aiModeCampaignId', null);
  if (id) {
    const existing = await Campaigns.get(id);
    if (existing) {
      if (existing.status !== 'active') await Campaigns.update(existing, { status: 'active' });
      return id;
    }
  }
  const camp = await Campaigns.create({ name: '🤖 AI-Mode Autopilot', rotation: 'lru' });
  await Campaigns.update(camp, { status: 'active', startedAt: Date.now() });
  await setSetting('aiModeCampaignId', camp.id);
  return camp.id;
}

async function reportAiScheduleStatus(scheduleId, status, error = null) {
  try {
    const session = await getSession();
    if (!session?.token) return;
    await fetch(`${SYNC.endpoint}/api/v1/ai-mode/schedule/${scheduleId}/status`, {
      method: 'POST',
      headers: { 'X-Session-Token': session.token, 'Content-Type': 'application/json' },
      body: JSON.stringify({ status, error: error ? String(error).slice(0, 300) : undefined }),
    });
  } catch (e) {
    obLog.warn('ai_schedule_status_report_failed', { context: { scheduleId, status, msg: String(e?.message || e) } });
  }
}

async function syncAiModeSchedule() {
  if (await getSetting('remotePauseActive', false)) return;
  const session = await getSession();
  if (!session?.token) return;
  const hdr = { 'X-Session-Token': session.token, 'Content-Type': 'application/json' };

  let due = [];
  try {
    const r = await fetch(`${SYNC.endpoint}/api/v1/ai-mode/schedule/due?limit=10`, { headers: hdr })
      .then(r => r.json()).catch(() => ({}));
    if (!r?.ok) return;
    due = r.due || [];
  } catch { return; }
  if (!due.length) return;

  const campId = await getOrCreateAiModeCampaign();
  for (const row of due) {
    try {
      // Multi-platform routing (ADR-40 follow-up): facebook targets a GROUP
      // (parse its id from the url); linkedin/twitter target the user's own
      // FEED, which has no group id — key it by a stable pseudo-id instead.
      // runJob then routes the actual publish on grp.platform.
      const platform = row.platform || 'facebook';
      let targetKey;
      if (platform === 'facebook') {
        targetKey = parseFbGroupId(row.group_url);
        if (!targetKey) { await reportAiScheduleStatus(row.id, 'skipped', 'no group id in url'); continue; }
      } else {
        targetKey = `feed:${platform}`;
      }
      if (!row.text || !row.text.trim()) { await reportAiScheduleStatus(row.id, 'skipped', 'empty post text'); continue; }

      // 🧪 DRY RUN — rehearse the whole path (claim, group/text validation,
      // reporting) but NEVER enqueue a publish job, so nothing reaches Facebook.
      // Today the worker's due-query only serves dry_run=0 rows, so this branch
      // is inert; it exists so a rehearsal can be enabled safely once every
      // device runs v1.0.69+. Do NOT relax the worker filter before then —
      // an older build has no dry-run branch and WOULD publish for real.
      if (row.dry_run) {
        await reportAiScheduleStatus(row.id, 'published', 'DRY RUN — לא פורסם בפועל');
        await Logs.add('info', `🧪 DRY RUN [${platform}]: היה מפרסם ל"${row.group_name || targetKey}" — לא פורסם בפועל`);
        continue;
      }

      const grp = await Groups.upsertByFbId(targetKey, {
        name: row.group_name || targetKey,
        url: row.group_url,
        isMember: true,
        source: 'ai-mode',
        platform,
      });
      const localPost = await Posts.save({
        campaignId: campId,
        title: row.topic || 'AI-Mode',
        text: row.text,
        postType: 'text',
        source: 'ai-mode',
        aiPostId: row.post_id,
      });
      // Mark queued FIRST (dedup guard: the due query only returns 'planned'
      // rows, so this prevents a second tick re-enqueuing the same row).
      await reportAiScheduleStatus(row.id, 'queued');
      try {
        await Jobs.enqueue(campId, grp.id, row.scheduled_at || Date.now(), {
          aiScheduleId: row.id,
          chosenPostId: localPost.id,
        });
      } catch (e) {
        // Without this the row would sit at 'queued' FOREVER: the due-query only
        // returns 'planned', so it is never retried and the failure never
        // surfaces anywhere. Mark it skipped with the reason so it's visible.
        await reportAiScheduleStatus(row.id, 'skipped', `enqueue failed: ${e?.message || e}`);
        await Logs.add('error', `🤖 מצב AI [${platform}]: הזמנת פרסום ל"${row.group_name || targetKey}" נכשלה — ${String(e?.message || e)}`);
        continue;
      }
      await Logs.add('info', `🤖 מצב AI [${platform}]: הוזמן פרסום ל"${row.group_name || targetKey}" (יפורסם בתור המווסת)`);
    } catch (e) {
      obLog.warn('ai_mode_materialize_exception', { context: { rowId: row.id, msg: String(e?.message || e) } });
    }
  }
}

// ─── Reddit growth pass (mirrors TW pattern, more conservative) ────
// Daily caps: 6 upvotes total across all subs. No commenting in v1 —
// Reddit anti-spam karma/age requirements make AI comments risky.
async function runRedditGrowthPass() {
  if (await getSetting('remotePauseActive', false)) return;
  const enabled = await getSetting('rdGrowthEnabled', false);
  if (!enabled) return;

  // Work-hours gate (Reddit is global, default 9-23 Israel = lots of overlap with US)
  const now = new Date();
  const startHour = Number(await getSetting('rdGrowthStartHour', 9));
  const endHour = Number(await getSetting('rdGrowthEndHour', 23));
  if (now.getHours() < startHour || now.getHours() >= endHour) return;

  // Reddit uses subreddits — user provides r/<sub> names
  const subs = (await getSetting('rdGrowthSubreddits', [])) || [];
  if (!subs.length) return;

  let idx = Number(await getSetting('rdGrowthSubIdx', 0)) || 0;
  if (idx >= subs.length) idx = 0;
  let sub = subs[idx];
  if (sub.startsWith('r/')) sub = sub.slice(2);
  await setSetting('rdGrowthSubIdx', idx + 1);

  // Daily ceiling (manual reset by date)
  const today = new Date().toISOString().slice(0, 10);
  const dailyKey = `rdGrowthDay_${today}`;
  const dayCount = Number(await getSetting(dailyKey, 0)) || 0;
  const maxPerDay = Number(await getSetting('rdGrowthMaxPerDay', 6)) || 6;
  if (dayCount >= maxPerDay) {
    await Logs.add('info', `🔍 RD growth skipped — daily ceiling ${maxPerDay} reached`);
    return;
  }

  obLog.event('rd_growth_start', { context: { sub, idx } });
  const url = `https://www.reddit.com/r/${encodeURIComponent(sub)}/hot/`;
  await Logs.add('info', `🔍 RD growth pass: r/${sub}`);

  let tab;
  try {
    tab = await chrome.tabs.create({ url, active: false });
    const ready = await waitForTabReady(tab.id, 12000);
    if (!ready) throw new Error('tab not ready');
    await new Promise(r => setTimeout(r, 4000)); // Reddit lazy-loads, extra wait
    const skipAuthors = (await getSetting('rdGrowthSkipAuthors', [])) || [];
    const maxUpvotes = Math.min(3, maxPerDay - dayCount);
    const response = await chrome.tabs.sendMessage(tab.id, {
      type: 'SEARCH_ENGAGE',
      payload: { maxUpvotes, skipAuthors, minBodyChars: 60 },
    }).catch(e => ({ ok: false, error: String(e?.message || e) }));
    if (response?.ok) {
      const upvoted = response.results?.upvoted?.length || 0;
      await setSetting(dailyKey, dayCount + upvoted);
      await Logs.add('info', `🔍 RD growth done: +${upvoted} upvotes on r/${sub}`);
    } else {
      await Logs.add('warn', `🔍 RD growth pass: ${response?.error || 'no response'}`);
    }
  } catch (e) {
    await Logs.add('warn', `🔍 RD growth exception: ${String(e)}`);
  } finally {
    if (tab?.id) { try { await chrome.tabs.remove(tab.id); } catch {} }
  }
}

async function runTwitterGrowthPass() {
  if (await getSetting('remotePauseActive', false)) return;
  const enabled = await getSetting('twGrowthEnabled', false);
  if (!enabled) return;

  // Work-hours gate (default 9-22 — X has different peak times than LI)
  const now = new Date();
  const startHour = Number(await getSetting('twGrowthStartHour', 9));
  const endHour = Number(await getSetting('twGrowthEndHour', 22));
  if (now.getHours() < startHour || now.getHours() >= endHour) return;

  const keywords = (await getSetting('twGrowthKeywords', [])) || [];
  if (!keywords.length) return;

  let idx = Number(await getSetting('twGrowthKeywordIdx', 0)) || 0;
  obLog.event('tw_growth_start', { context: { kwIdx: idx } });
  if (idx >= keywords.length) idx = 0;
  const keyword = keywords[idx];
  await setSetting('twGrowthKeywordIdx', idx + 1);

  const skipHandles = (await getSetting('twGrowthSkipHandles', [])) || [];
  const url = `https://x.com/search?q=${encodeURIComponent(keyword)}&f=live`;
  await Logs.add('info', `🔍 TW growth pass: keyword="${keyword}"`);

  let tab;
  try {
    tab = await chrome.tabs.create({ url, active: false });
    const ready = await waitForTabReady(tab.id, 12000);
    if (!ready) throw new Error('tab not ready');
    await new Promise(r => setTimeout(r, 3500));
    const response = await chrome.tabs.sendMessage(tab.id, {
      type: 'SEARCH_ENGAGE',
      payload: {
        maxLikes: Number(await getSetting('twGrowthMaxLikes', 5)) || 5,
        skipHandles,
        minBodyChars: 40,
      },
    }).catch(e => ({ ok: false, error: String(e?.message || e) }));
    if (response?.ok) {
      const liked = response.results?.liked?.length || 0;
      await Logs.add('info', `🔍 TW growth done: +${liked} likes on "${keyword}"`);
    } else {
      await Logs.add('warn', `🔍 TW growth pass: ${response?.error || 'no response'}`);
    }
  } catch (e) {
    await Logs.add('warn', `🔍 TW growth exception: ${String(e)}`);
  } finally {
    if (tab?.id) { try { await chrome.tabs.remove(tab.id); } catch {} }
  }
}

// ---------- Evening cron: full metrics refresh ----------
// Runs once per day at the user-configured hour (default 21:00 = 9 PM).
// Marks ALL done jobs with postUrl as "due" → next METRICS_ALARM tick
// will scrape them in slow batches.
async function maybeRunEveningRefresh() {
  const enabled = await getSetting('eveningRefreshEnabled', true);
  if (!enabled) return;
  const targetHour = Number(await getSetting('eveningRefreshHour', 21)) || 21; // default 9 PM
  const now = new Date();
  if (now.getHours() !== targetHour) return;

  // Make sure we don't re-run within the same day
  const lastRunDay = await getSetting('eveningRefreshLastDay');
  const todayKey = now.toISOString().slice(0, 10);
  if (lastRunDay === todayKey) return;
  await setSetting('eveningRefreshLastDay', todayKey);

  // Mark every done job with a URL as tracking_due_at = now → METRICS_ALARM will pick them up
  const allJobs = await Jobs.list();
  const dueJobs = allJobs.filter((j) => j.status === 'done' && j.postUrl);
  for (const j of dueJobs) {
    await Jobs.update(j, { trackingDueAt: Date.now() });
  }
  await Logs.add('info', `🌙 Evening refresh: ${dueJobs.length} posts queued for metrics scrape`);
}

// ---------- Engagement metrics scraping ----------
// Visits each job whose tracking_due_at has passed, scrapes engagement, saves snapshot.
// Reschedules next snapshot: 6h → 24h → 72h → 7d cadence.
async function scrapeDueMetrics() {
  const enabled = await getSetting('metricsTrackingEnabled', true);
  if (!enabled) return;

  const now = Date.now();
  const allJobs = await Jobs.list();
  const due = allJobs
    .filter((j) => j.status === 'done' && j.postUrl && (j.trackingDueAt || 0) <= now)
    .sort((a, b) => (a.trackingDueAt || 0) - (b.trackingDueAt || 0))
    .slice(0, 3); // small batch per cycle (don't burn FB)

  if (!due.length) return;
  await Logs.add('info', `📊 Metrics: scraping ${due.length} posts`);

  const safeMode = await getSetting('safeMode', false);
  const reuse = await getSetting('reusePublisherTab', true);

  for (let i = 0; i < due.length; i++) {
    const job = due[i];
    try {
      const tab = await acquirePublisherTab(job.postUrl, !!safeMode, reuse);
      await waitForTabComplete(tab.id);
      await new Promise((r) => setTimeout(r, 2000));

      const r = await chrome.tabs.sendMessage(tab.id, { type: 'SCRAPE_POST_ENGAGEMENT' });
      if (!r?.ok) {
        await Logs.add('warn', `📊 Could not scrape metrics for job ${job.id}`);
        continue;
      }
      const hoursSince = (now - (job.completedAt || 0)) / 3600000;

      // Save snapshot locally — sync will push to D1
      // We don't have a Metrics repo locally; use server endpoint directly.
      try {
                    const { token } = await getSession();
        await fetch(`${SYNC.endpoint}/metrics`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
          body: JSON.stringify({
            jobId: job.id, groupId: job.groupId, postUrl: job.postUrl,
            hoursSincePublish: Number(hoursSince.toFixed(1)),
            ...r.data,
          }),
        });
        await Logs.add('info', `📊 ${r.data.reactions}r/${r.data.comments}c/${r.data.shares}s for job ${job.id.slice(0,8)}`);
      } catch (e) {
        await Logs.add('warn', `📊 Metrics upload failed: ${String(e)}`);
      }

      // Reschedule next scrape
      let nextDelayMs;
      if (hoursSince < 12) nextDelayMs = 18 * 3600 * 1000;       // next at 24h
      else if (hoursSince < 36) nextDelayMs = 48 * 3600 * 1000;  // next at 72h
      else if (hoursSince < 96) nextDelayMs = 7 * 24 * 3600 * 1000; // next at 7d
      else nextDelayMs = null; // stop tracking
      if (nextDelayMs) {
        await Jobs.update(job, { trackingDueAt: Date.now() + nextDelayMs });
      } else {
        await Jobs.update(job, { trackingDueAt: null });
      }

      // Human-like delay between scrapes
      if (i < due.length - 1) await new Promise((r) => setTimeout(r, 30000));
    } catch (e) {
      await Logs.add('warn', `📊 Metrics exception for job ${job.id}: ${String(e)}`);
    }
  }
}

// Restriction level: derived from policies.
// instant+instant → low (best — easy to use)
// approval+instant OR instant+approval → medium
// approval+approval → high (hardest)
function computeRestrictionLevel(postPolicy, joinPolicy) {
  const post = postPolicy === 'approval' ? 1 : 0;
  const join = joinPolicy === 'approval' ? 1 : 0;
  const total = post + join;
  if (total === 0) return 'low';
  if (total === 1) return 'medium';
  return 'high';
}

// ---------- Auto-join orchestrator ----------
// Goes through groups marked wantToJoin=1 and joins them slowly.
// Stops immediately on any FB warning. Honors adaptive backoff.
//
// Human-like rules (FB-safe limits):
//   - Daily cap: max ~8 joins/day (FB starts warning >10-15)
//   - Weekly cap: max ~35/week
//   - Only during active hours (no joins at 3am — looks like a bot)
//   - Min gap of 25-45 min from last join (no machine-gun bursts)
//   - 15% chance of "human pause" — wait 15-40 min extra (coffee break)
//   - Skip days randomly (≈1 in 5 cycles do nothing)
//   - Pre-browse the group page (scroll + read) before clicking join
async function runAutoJoinBatch() {
  const enabled = await getSetting('autoJoinEnabled', false);
  if (!enabled) return;

  // === 1. Adaptive backoff after FB warning ===
  const lastWarningAt = (await getSetting('lastFbWarningAt', 0)) || 0;
  const backoffMs = (await getSetting('warningBackoffMs', 6 * 3600 * 1000)) || (6 * 3600 * 1000);
  if (Date.now() - lastWarningAt < backoffMs) {
    const hoursLeft = Math.ceil((backoffMs - (Date.now() - lastWarningAt)) / 3600000);
    await Logs.add('info', `🛑 Auto-join paused due to recent FB warning (${hoursLeft}h left)`);
    return;
  }

  // === 2. Active-hours guard — bots run 24/7, humans sleep ===
  const cfg = await loadThrottleConfig('facebook');
  const hour = new Date().getHours() + (new Date().getMinutes() / 60);
  const startH = cfg.activeHourStart ?? 8;
  const endH = cfg.activeHourEnd ?? 23;
  const inActiveHours = endH > 24
    ? (hour >= startH || hour <= (endH - 24))
    : (hour >= startH && hour <= endH);
  if (!inActiveHours) {
    await Logs.add('info', `🌙 Auto-join: outside active hours (${startH}-${endH}), skipping`);
    return;
  }

  // === 3. Random skip — ~1 in 5 cycles do nothing (real users aren't consistent) ===
  if (Math.random() < 0.18) {
    await Logs.add('info', `🎲 Auto-join: random rest day (skipping cycle)`);
    return;
  }

  // === 4. Min gap from last join (25-45 min) ===
  const lastJoinAt = (await getSetting('lastAutoJoinAt', 0)) || 0;
  const minGapMs = 25 * 60 * 1000 + Math.random() * 20 * 60 * 1000;
  if (Date.now() - lastJoinAt < minGapMs) {
    const minLeft = Math.ceil((minGapMs - (Date.now() - lastJoinAt)) / 60000);
    await Logs.add('info', `⏳ Auto-join: only ${Math.round((Date.now() - lastJoinAt)/60000)}min since last join, need ${Math.round(minGapMs/60000)}min — skipping`);
    return;
  }

  // === 5. Daily & weekly caps ===
  const history = (await getSetting('autoJoinHistory', [])) || [];
  const now = Date.now();
  const day = 24 * 3600 * 1000;
  const recentDay = history.filter((t) => now - t < day).length;
  const recentWeek = history.filter((t) => now - t < 7 * day).length;
  const dailyCap = (await getSetting('autoJoinDailyCap', 8)) || 8;
  const weeklyCap = (await getSetting('autoJoinWeeklyCap', 35)) || 35;
  if (recentDay >= dailyCap) {
    await Logs.add('info', `📊 Auto-join: daily cap reached (${recentDay}/${dailyCap}) — stopping until tomorrow`);
    return;
  }
  if (recentWeek >= weeklyCap) {
    await Logs.add('info', `📊 Auto-join: weekly cap reached (${recentWeek}/${weeklyCap}) — stopping for a few days`);
    return;
  }

  // === 6. Pick wanted groups ===
  const allGroups = await Groups.list();
  const wanted = allGroups.filter((g) => g.wantToJoin && !g.isMember);
  if (!wanted.length) return;

  // Smaller batch as we approach the daily cap — taper off naturally
  const room = dailyCap - recentDay;
  const baseBatch = Math.min(3, room, wanted.length);
  // 15% chance of a "human pause" — only join 1 this cycle (coffee break feel)
  const batchSize = (Math.random() < 0.15 && baseBatch > 1) ? 1 : baseBatch;
  const batch = wanted.slice(0, batchSize);
  await Logs.add('info', `🤖 Auto-join: ${batch.length} groups this cycle (${recentDay}/${dailyCap} today, ${recentWeek}/${weeklyCap} this week)`);

  const safeMode = await getSetting('safeMode', false);
  const reuse = await getSetting('reusePublisherTab', true);

  for (let i = 0; i < batch.length; i++) {
    const g = batch[i];
    try {
      // Optional Gemini pre-check
      const aiEnabled = await getSetting('aiJoinSafetyCheck', true);
      if (aiEnabled && (await getGeminiKey())) {
        const ok = await aiJoinPrecheck(g);
        if (!ok.proceed) {
          await Groups.upsertByFbId(g.fbGroupId, {
            wantToJoin: 0,
            aiJoinScore: ok.score,
            aiJoinReason: ok.reason,
          });
          await Logs.add('warn', `🤖 Skipped "${g.name}" — AI score ${ok.score}/10: ${ok.reason}`);
          continue;
        }
        await Groups.upsertByFbId(g.fbGroupId, { aiJoinScore: ok.score, aiJoinReason: ok.reason });
      }

      const tab = await acquirePublisherTab(g.url, !!safeMode, reuse);
      await waitForTabComplete(tab.id);

      // === Human-like pre-browse: scroll/read group page 8-22s before joining ===
      const browseMs = 8000 + Math.random() * 14000;
      await Logs.add('info', `  👀 Browsing "${g.name}" for ${Math.round(browseMs/1000)}s before joining`);
      try { await chrome.tabs.sendMessage(tab.id, { type: 'HUMAN_BROWSE_PAGE', durationMs: browseMs }); } catch {}
      await new Promise((r) => setTimeout(r, browseMs));

      const r = await chrome.tabs.sendMessage(tab.id, { type: 'JOIN_GROUP' });

      // Handle FB warning detected by content script
      if (r?.warning) {
        await env_recordWarning(r.warning);
        await Logs.add('error', `🛑 FB warning encountered — backing off auto-join for 6h`);
        return; // stop the whole batch
      }

      if (r?.ok) {
        await Groups.upsertByFbId(g.fbGroupId, {
          wantToJoin: 0,
          joinPolicy: r.joinPolicy || 'unknown',
          isMember: (r.alreadyMember || r.joinPolicy === 'instant') ? 1 : 0,
          source: 'joined',
        });
        // Track join in history for daily/weekly caps + gap enforcement
        await setSetting('lastAutoJoinAt', Date.now());
        const newHistory = ((await getSetting('autoJoinHistory', [])) || []).concat(Date.now())
          .filter((t) => Date.now() - t < 14 * 24 * 3600 * 1000); // keep last 14 days
        await setSetting('autoJoinHistory', newHistory);
        await Logs.add('info', `  ✓ Joined "${g.name}" → ${r.alreadyMember ? 'already' : r.joinPolicy}`);
      } else {
        await Logs.add('warn', `  ✗ Join failed for "${g.name}": ${r?.error || 'unknown'}`);
      }

      // Inter-join delay — human-like: usually 4-9 min, occasionally a 12-25 min break
      if (i < batch.length - 1) {
        const longPause = Math.random() < 0.25; // 25% chance of "got distracted" break
        const wait = longPause
          ? (12 * 60000 + Math.random() * 13 * 60000)    // 12-25 min
          : (4 * 60000 + Math.random() * 5 * 60000);     // 4-9 min
        await Logs.add('info', `  ⏳ Waiting ${Math.round(wait / 60000)}min before next join${longPause ? ' (long pause)' : ''}`);
        await new Promise((r) => setTimeout(r, wait));
      }
    } catch (e) {
      await Logs.add('error', `Auto-join exception for "${g.name}": ${String(e)}`);
    }
  }
  await Logs.add('info', `🤖 Auto-join cycle complete`);
}

// Records an FB warning and triggers adaptive backoff.
async function env_recordWarning(warning) {
  await setSetting('lastFbWarningAt', Date.now());
  // Escalating backoff: each consecutive warning doubles the wait.
  const current = (await getSetting('warningBackoffMs', 6 * 3600 * 1000)) || (6 * 3600 * 1000);
  const next = Math.min(current * 2, 48 * 3600 * 1000); // cap at 48h
  await setSetting('warningBackoffMs', next);
  await Logs.add('error', `⚠ FB Warning [${warning.kind}]: "${warning.message?.slice(0, 100)}". Backoff now ${Math.round(next / 3600000)}h`, warning);
}

// Gemini reviews the group's surface (name + member count) — returns { proceed, score, reason }.
async function aiJoinPrecheck(group) {
  try {
    const prompt = `אני שוקל להצטרף אוטומטית לקבוצת פייסבוק. דרג סיכון להצטרפות לקבוצה.

קבוצה: "${group.name}"
חברים: ${group.memberCount || 'לא ידוע'}
פרטיות: ${group.privacy || 'unknown'}
מדיניות הצטרפות: ${group.joinPolicy || 'unknown'}

החזר JSON: {"score": 1-10, "proceed": true|false, "reason": "הסבר קצר"}
- score 1-3 = בטוח, proceed=true
- score 4-6 = בינוני, proceed=true
- score 7-10 = סיכון גבוה (קבוצת ספאם/הונאה/אסורה), proceed=false`;

    const r = await geminiText(prompt, { maxTokens: 200, temperature: 0.1 });
    if (!r.ok) return { proceed: true, score: 5, reason: 'AI unavailable, proceeding by default' };
    const cleaned = r.text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
    const p = JSON.parse(cleaned);
    return { proceed: !!p.proceed, score: p.score, reason: p.reason };
  } catch (e) {
    return { proceed: true, score: 5, reason: 'AI check failed: ' + String(e) };
  }
}

// Re-run all discoveries marked as auto-tracked. Saves fresh results so the
// user always sees the latest groups for keywords they care about.
// ── Group-discovery safety gate (ADR-55) ────────────────────────────────────
// Searching is what tripped FB's flag. Every group SEARCH (from any caller) must
// pass through here: it enforces the daily cap + min-gap + active hours, and
// records the timestamp so the next search sees the history. Returns true if the
// search may proceed, false (and logs why) if it must be skipped this time.
async function discoveryAllowed() {
  const history = (await getSetting('discoverySearchHistory', [])) || [];
  const gate = canDiscoverNow({ history, cfg: await loadThrottleConfig(), now: new Date() });
  if (!gate.ok) {
    await Logs.add('info', `🛑 גילוי קבוצות נדחה (${gate.reason}) — ממתין ~${Math.round((gate.retryAfterMs || 0) / 60000)} דק' כדי לא להיראות כמו סורק`);
    return false;
  }
  return true;
}
async function recordDiscovery() {
  const history = (await getSetting('discoverySearchHistory', [])) || [];
  history.push(Date.now());
  // keep only the last 48h so the list can't grow unbounded
  const cutoff = Date.now() - 48 * 3600 * 1000;
  await setSetting('discoverySearchHistory', history.filter((ts) => ts >= cutoff));
}

async function runTrackedDiscoveries() {
  const all = await Discoveries.list();
  const tracked = all.filter((d) => d.autoTrack);
  if (!tracked.length) return;

  for (const d of tracked) {
    // Gate BEFORE each query — one search may be allowed while the next isn't.
    if (!(await discoveryAllowed())) return; // capped/too-soon → stop the whole pass
    try {
      const cfg = await loadThrottleConfig();
      const safeMode = await getSetting('safeMode', false);
      const reuse = await getSetting('reusePublisherTab', true);
      const url = `https://www.facebook.com/search/groups/?q=${encodeURIComponent(d.query)}`;
      const tab = await acquirePublisherTab(url, !!safeMode, reuse);
      await waitForTabComplete(tab.id);
      await new Promise((r) => setTimeout(r, 2000 + Math.random() * 2000)); // read the page like a human
      await recordDiscovery(); // count it the moment we commit to searching
      const r = await chrome.tabs.sendMessage(tab.id, {
        type: 'SCRAPE_GROUP_SEARCH',
        payload: { maxScrolls: cfg.discoveryMaxScrolls ?? 3, scrollPauseMs: discoveryScrollPauseMs(cfg) },
      });
      if (r?.ok) {
        await Discoveries.upsertByQuery(d.query, r.results, { autoTrack: true });
        for (const g of r.results) {
          await Groups.upsertByFbId(g.fbGroupId, {
            name: g.name, url: g.url, memberCount: g.memberCount,
            privacy: g.privacy, joinPolicy: g.joinPolicy,
            isMember: g.isMember ? 1 : 0,
            source: g.isMember ? 'joined' : 'discovered',
          });
        }
        await Logs.add('info', `🔁 Auto-discovered ${r.results.length} groups for "${d.query}"`);
      }
      // One search per pass is the safest cadence — stop after the first success.
      return;
    } catch (e) {
      await Logs.add('warn', `🔁 Tracked discovery failed for "${d.query}": ${String(e)}`);
    }
  }
}

// For each active campaign, if the post pool is below target, ask Gemini to
// generate more variations based on existing post style.
async function topUpCampaignPosts() {
  const enabled = await getSetting('aiAutoGenerate', true);
  if (!enabled) return;
  const target = Number(await getSetting('aiTargetPostCount', 8)) || 8;
  if (!(await getGeminiKey())) return;

  const campaigns = await Campaigns.list();
  for (const c of campaigns) {
    if (c.status !== 'active') continue;
    const posts = await Posts.listByCampaign(c.id);
    if (posts.length >= target) continue;
    const needed = target - posts.length;
    if (!posts.length) continue; // can't infer style with zero examples

    const examples = posts.slice(0, 3).map((p) => p.text).filter(Boolean);
    const seed = posts[0].text.slice(0, 60); // hint topic from first post
    try {
      const r = await geminiGeneratePosts({
        briefDescription: `המשך הקמפיין "${c.name}" — שמור על אותו טון כמו הדוגמאות. רעיון מרכזי: "${seed}"`,
        existingExamples: examples,
        count: Math.min(needed, 3),
        postType: 'text',
      });
      if (r.ok && r.variations.length) {
        for (const v of r.variations) {
          await Posts.save({
            campaignId: c.id,
            title: '🤖 ' + (v.title || v.text.slice(0, 30)),
            text: v.text,
            weight: 1,
            postType: 'text',
            mediaUrls: [],
            tags: ['ai-generated'],
          });
        }
        await Logs.add('info', `🤖 Auto-generated ${r.variations.length} posts for "${c.name}"`);
      }
    } catch (e) {
      await Logs.add('warn', `Auto-gen failed for "${c.name}": ${String(e)}`);
    }
  }
}

// Apply scheduled campaign transitions:
//   draft/paused → active   when scheduleStartAt arrives
//   active        → done    when scheduleEndAt arrives
async function applyCampaignSchedules() {
  const now = Date.now();
  const campaigns = await Campaigns.list();
  for (const c of campaigns) {
    // Auto-start
    if ((c.status === 'draft' || c.status === 'paused') &&
        c.scheduleStartAt && c.scheduleStartAt <= now &&
        (!c.scheduleEndAt || c.scheduleEndAt > now)) {
      // Mirror the START_CAMPAIGN handler logic
      const posts = await Posts.listByCampaign(c.id);
      if (posts.length >= 2 && (c.groupIds || []).length) {
        const cfg = await loadThrottleConfig();
        const minGapMs = Math.max((cfg.interPostMinSec || 180) * 1000, 180000);
        let scheduledAt = now;
        for (const gid of c.groupIds) {
          await Jobs.enqueue(c.id, gid, scheduledAt);
          scheduledAt += Math.max(nextInterPostDelayMs(cfg), minGapMs);
        }
        await Campaigns.update(c, { status: 'active', startedAt: now, pausedAt: null });
        await Logs.add('info', `⏰ Auto-started campaign "${c.name}" (scheduled, ≥3min apart)`);
      } else {
        await Logs.add('warn', `⏰ Schedule fired for "${c.name}" but campaign is incomplete (need 2+ posts and 1+ groups)`);
      }
    }
    // Auto-end
    if (c.status === 'active' && c.scheduleEndAt && c.scheduleEndAt <= now) {
      await Campaigns.update(c, { status: 'done' });
      await Logs.add('info', `⏰ Auto-ended campaign "${c.name}" (scheduled)`);
    }
  }
}

// ---------- Queue tick ----------

async function tick() {
  const now = Date.now();
  const cfg = await loadThrottleConfig();

  // CONCURRENCY GUARD: tick runs every minute, but runJob can take 60-120 sec
  // (human-like typing). Without a lock, two ticks would run in parallel and
  // step on each other's tabs. Use a persisted lock that survives SW restart.
  const lockUntil = (await getSetting('tickLockUntil', 0)) || 0;
  if (now < lockUntil) return; // another tick is in progress
  // Hold the lock for up to 5 minutes (generous — covers slow typing + uploads)
  await setSetting('tickLockUntil', now + 5 * 60 * 1000);

  try {
    return await tickInner(now, cfg);
  } finally {
    await setSetting('tickLockUntil', 0);
  }
}

async function tickInner(now, cfg) {
  const nextAllowedAt = (await getSetting('nextAllowedAt', 0)) || 0;
  if (now < nextAllowedAt) return;

  const queued = await Jobs.listByStatus('queued');
  const ready = queued
    .filter((j) => (j.scheduledAt || 0) <= now)
    .sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0));
  if (!ready.length) return;

  const allJobs = await Jobs.list();

  for (const job of ready) {
    const group = await Groups.get(job.groupId);
    // Reload config with platform-specific profile (LinkedIn etc.)
    const platformCfg = await loadThrottleConfig(group?.platform);
    const gate = canPostNow({ jobs: allJobs, group, cfg: platformCfg, now: new Date() });
    if (!gate.ok) {
      // Push global cooldown out by the gate's retry hint
      await setSetting('nextAllowedAt', Date.now() + (gate.retryAfterMs || 60000));
      await Logs.add('info', `Throttled: ${gate.reason}`, { retryAfterMs: gate.retryAfterMs });
      return;
    }

    await Jobs.update(job, { status: 'running', attempts: (job.attempts || 0) + 1 });
    await runJob(job, platformCfg);

    // AI-Mode schedule round-trip (ADR-31): if this job came from the server
    // schedule, report the real outcome so the row isn't re-published.
    try {
      const fresh = await Jobs.get(job.id);
      if (fresh?.aiScheduleId) {
        if (fresh.status === 'done') await reportAiScheduleStatus(fresh.aiScheduleId, 'published');
        else if (fresh.status === 'failed') await reportAiScheduleStatus(fresh.aiScheduleId, 'skipped', fresh.lastError);
      }
    } catch {}

    // Use the platform's own delay distribution
    await setSetting('nextAllowedAt', Date.now() + nextInterPostDelayMs(platformCfg));
    return;
  }
}

// ---------- Run a single job ----------

async function runJob(job, cfg) {
  const group = await Groups.get(job.groupId);
  const campaign = await Campaigns.get(job.campaignId);
  if (!group || !campaign) {
    await Jobs.update(job, { status: 'failed', lastError: 'group or campaign not found' });
    await Logs.add('error', 'job missing group/campaign', { jobId: job.id }, job.id);
    return;
  }

  // Honor pause: if the campaign is paused, push this job back to queued
  // and let the next tick reconsider it.
  if (campaign.status !== 'active') {
    await Jobs.update(job, { status: 'queued' });
    await Logs.add('info', `Campaign ${campaign.name} not active — skipping`, {}, job.id);
    return;
  }

  // Pick a post NOW (smart-ai uses group context, other strategies don't need it).
  // A pre-assigned chosenPostId (AI-Mode schedule jobs, ADR-31) wins — it carries
  // the exact post the server paired with this group+time, so honor it verbatim.
  let post = null;
  if (job.chosenPostId) { try { post = await Posts.get(job.chosenPostId); } catch {} }
  if (!post) post = await Posts.pickForCampaign(campaign, group);
  if (!post) {
    await Jobs.update(job, { status: 'failed', lastError: 'campaign has no posts' });
    await Logs.add('error', `Campaign ${campaign.name} has no posts`, {}, job.id);
    return;
  }
  await Jobs.update(job, { chosenPostId: post.id });

  // PRE-FLIGHT SAFETY CHECK — if enabled and Gemini key is available
  const safetyEnabled = await getSetting('aiSafetyCheck', true);
  if (safetyEnabled && await getGeminiKey()) {
    try {
      const s = await geminiSafetyCheck(post.text);
      if (s.ok) {
        await Logs.add('info', `🛡 Safety check: ${s.risk} (${s.score}/10) — ${s.reason}`, s, job.id);
        if (s.risk === 'high') {
          await Jobs.update(job, { status: 'failed', lastError: `🛡 Skipped: safety risk ${s.score}/10 — ${s.reason}` });
          await Logs.add('warn', `🛡 ABORTING publish due to high safety risk`, s, job.id);
          return;
        }
      }
    } catch (e) {
      await Logs.add('warn', `🛡 Safety check failed: ${String(e)} — proceeding anyway`);
    }
  }

  // Marketplace posts skip the group entirely — they publish to FB Marketplace.
  if (post.postType?.startsWith('marketplace-')) {
    await runMarketplaceJob(job, post, group, cfg);
    return;
  }

  // Other non-group channels: profile, page, story, reel, event, share, album
  const channelTypes = ['profile', 'page', 'story', 'reel', 'event', 'share', 'album'];
  if (channelTypes.includes(post.postType)) {
    await runChannelJob(job, post, cfg);
    return;
  }

  await Logs.add('info', `Opening group ${group.name}`, { groupUrl: group.url }, job.id);
  const safeMode = await getSetting('safeMode', false);
  // Reuse a single dedicated tab across jobs (configurable). Default ON so the
  // user doesn't drown in tabs.
  const reuseTab = await getSetting('reusePublisherTab', true);
  const tab = await acquirePublisherTab(group.url, !!safeMode, reuseTab);

  // Wait for the tab to finish loading.
  await waitForTabComplete(tab.id);
  // The content script needs a moment after `complete` before its message
  // listener is reliably registered, especially after navigation.
  await new Promise((r) => setTimeout(r, 600));

  try {
    // Bundle media: read each Blob, transfer as ArrayBuffer + mime so the
    // content script can rebuild File objects on the page side.
    const mediaRows = await Media.listByPost(post.id);
    const mediaPayload = [];
    for (const m of mediaRows) {
      try {
        const buf = await m.blob.arrayBuffer();
        mediaPayload.push({
          name: `${m.id}.${(m.mimeType || 'image/jpeg').split('/')[1] || 'bin'}`,
          mimeType: m.mimeType || 'application/octet-stream',
          buffer: Array.from(new Uint8Array(buf)), // structured-cloned across boundary
        });
      } catch (e) {
        await Logs.add('warn', `Skipping unreadable media ${m.id}`, { error: String(e) }, job.id);
      }
    }

    // 🎥 Flight recorder — the page as the agent found it, right before acting.
    await recordAgentStep({
      tabId: tab.id, action: 'publish', phase: 'before',
      platform: group?.platform || 'facebook', jobId: job.id,
    });

    const result = await chrome.tabs.sendMessage(tab.id, {
      type: 'PUBLISH_POST',
      payload: {
        text: post.text,
        title: post.title || null,
        media: mediaPayload,
        throttle: cfg,
        postType: post.postType || 'text',
        colorIndex: post.colorIndex || 0,
        linkUrl: post.linkUrl || null,
        pollQuestion: post.pollQuestion || null,
        pollOptions: post.pollOptions || null,
        gifQuery: post.gifQuery || null,
        feeling: post.feeling || null,
        mentions: post.mentions || null,
        checkinLocation: post.checkinLocation || null,
        platform: group?.platform || 'facebook',
      },
    });
    // 🎥 …and immediately after, with the outcome. This pair is what makes a
    // failure explainable later ("it looked like X, we acted, then Y happened").
    await recordAgentStep({
      tabId: tab.id, action: 'publish',
      phase: result?.warning ? 'warning' : (result?.ok ? 'after' : 'error'),
      platform: group?.platform || 'facebook', jobId: job.id,
      outcome: result?.warning ? 'warning' : (result?.ok ? 'ok' : 'failed'),
      error: result?.ok ? null : (result?.error || result?.warning?.kind || 'publish failed'),
    });
    // If the result is "optimistic" (we didn't see a clear signal), ask Gemini
    // to look at the actual page screenshot for a definitive answer.
    if (result?.ok && /optimistic/i.test(result.signal || '')) {
      const refined = await refineWithGemini(tab.id, post.text);
      if (refined) {
        result.signal = refined.signal;
        await Logs.add('info', `🤖 Gemini refined outcome: ${refined.signal} (${refined.confidence}) — ${refined.explanation}`, refined, job.id);
      }
    }

    // Push logs/results to D1 BEFORE updating job state — gives the user live visibility
    try { await runSync(); } catch {}
    if (result && result.ok) {
      await Jobs.update(job, { status: 'done', completedAt: Date.now(), lastError: null });

      // Derive post policy. Any signal mentioning pending/approval → group needs review.
      const postPolicy = /pending|approval/i.test(result.signal || '') ? 'approval' : 'instant';
      const meta = result.groupMeta || {};
      // Compute restriction level from policies (low/medium/high)
      const restrictionLevel = computeRestrictionLevel(postPolicy, group.joinPolicy);
      await Groups.upsertByFbId(group.fbGroupId, {
        lastPostedAt: Date.now(),
        postPolicy,
        lastPublishSignal: result.signal,
        restrictionLevel,
        ...(meta.memberCount ? { memberCount: meta.memberCount } : {}),
        ...(meta.privacy && meta.privacy !== 'unknown' ? { privacy: meta.privacy } : {}),
        ...(meta.avatarUrl ? { avatarUrl: meta.avatarUrl } : {}),
        ...(meta.coverUrl ? { coverUrl: meta.coverUrl } : {}),
        ...(meta.description ? { description: meta.description } : {}),
        ...(meta.rulesText ? { rulesText: meta.rulesText } : {}),
        ...(meta.adminCount ? { adminCount: meta.adminCount } : {}),
        ...(meta.postsPerDay ? { postsPerDay: meta.postsPerDay } : {}),
        ...(meta.language ? { language: meta.language } : {}),
        ...(meta.location ? { location: meta.location } : {}),
        ...(meta.topics?.length ? { topics: meta.topics } : {}),
        lastScrapedAt: meta.lastScrapedAt || Date.now(),
      });

      await Posts.markUsed(post);
      obLog.event('publish_ok', { context: { platform: group?.platform || 'facebook', groupId: group.id, postId: post.id, jobId: job.id, policy: postPolicy } });

      // Capture the FB permalink of our just-published post for engagement tracking.
      let postUrl = null;
      try {
        const snippet = (post.text || '').replace(/\s+/g, ' ').trim().slice(0, 40);
        const r = await chrome.tabs.sendMessage(tab.id, { type: 'FIND_POST_URL', payload: { snippet } });
        if (r?.ok) postUrl = r.url;
      } catch {}
      // Schedule first metrics scrape in 6 hours, second cycle handles 24h/72h naturally.
      const trackingDueAt = Date.now() + 6 * 3600 * 1000;
      await Jobs.update(job, { postUrl, trackingDueAt });

      // Optionally schedule a self-bump comment to boost visibility
      if (postUrl) await scheduleAutoBumpForJob({ ...job, postUrl }, post, group);

      // Optionally pin the post to profile/page
      if (postUrl && post.pinAfterPublish) {
        try {
          // Wait a moment for FB to finish processing
          await new Promise((r) => setTimeout(r, 3000));
          // Navigate the publisher tab to the post URL and trigger pin
          await chrome.tabs.update(tab.id, { url: postUrl });
          await waitForTabComplete(tab.id);
          await new Promise((r) => setTimeout(r, 2500));
          const r = await chrome.tabs.sendMessage(tab.id, { type: 'PIN_POST' });
          if (r?.ok) await Logs.add('info', `📌 Post pinned`, {}, job.id);
          else await Logs.add('warn', `📌 Pin failed: ${r?.error}`, {}, job.id);
        } catch (e) {
          await Logs.add('warn', `📌 Pin exception: ${String(e)}`, {}, job.id);
        }
      }

      const policyEmoji = postPolicy === 'approval' ? '⏳' : '🚀';
      await Logs.add('info', `${policyEmoji} Published "${post.title}" to ${group.name} (${postPolicy})${postUrl ? ' · 🔗 URL captured' : ''}`, {
        groupId: group.id, postId: post.id, campaignId: campaign.id,
        signal: result.signal, postPolicy, memberCount: meta.memberCount, privacy: meta.privacy,
      }, job.id);
      // Don't close the tab — next job will navigate it. We close only when
      // the queue is empty.
      await maybeCloseIdlePublisherTab();
      return;
    } else if (result?.warning) {
      // Platform-specific warning detected (LinkedIn, FB, etc.) — full backoff
      await env_recordWarning(result.warning);
      await Jobs.update(job, { status: 'failed', lastError: `Warning: ${result.warning.kind}` });
      await Logs.add('error', `⚠ ${group.platform || 'platform'} warning [${result.warning.kind}] — backoff engaged`, result.warning, job.id);
      obLog.error('publish_platform_warning', { context: { platform: group?.platform, kind: result.warning.kind, jobId: job.id } });
      return;
    } else {
      const err = (result && result.error) || 'unknown content-script error';
      obLog.error('publish_failed', { context: { platform: group?.platform, err: String(err).slice(0, 300), jobId: job.id } });
      if (/stuck|pending|approval|ממתין|אישור/i.test(err)) {
        await Groups.upsertByFbId(group.fbGroupId, {
          postPolicy: 'approval',
          lastPublishSignal: `failure-but-maybe-pending: ${err.slice(0, 80)}`,
        });
        await Logs.add('warn', `⚠ Publish failed BUT signal hints at pending approval — group marked as 'approval'`, { error: err }, job.id);
      } else {
        await Logs.add('error', 'Publish failed', { error: err }, job.id);
      }
      await Jobs.update(job, { status: 'failed', lastError: err });
    }
  } catch (e) {
    await Jobs.update(job, { status: 'failed', lastError: String(e) });
    await Logs.add('error', 'sendMessage failed', { error: String(e) }, job.id);
  }
  // On failure we deliberately LEAVE THE TAB OPEN so the user can inspect
  // what FB looked like at the moment of failure (DOM, dialogs, captcha...).
  // If reusePublisherTab is on, the next job will navigate this same tab away.
  await Logs.add('warn', `Tab left open for inspection (id=${tab.id}).`, {}, job.id);
  // Push the failure logs upstream immediately so the popup view stays fresh.
  try { await runSync(); } catch {}
}

// ---------- Gemini-powered publish outcome refinement ----------
// Called when DOM-based detection is uncertain. Captures the current tab
// screenshot and asks Gemini to classify what happened.
async function refineWithGemini(tabId, postText) {
  const key = await getGeminiKey();
  if (!key) return null; // No key — silently skip
  try {
    const dataUrl = await chrome.tabs.captureVisibleTab(undefined, { format: 'jpeg', quality: 70 });
    if (!dataUrl) return null;
    const r = await geminiClassifyPublish(dataUrl, postText);
    if (!r.ok) {
      await Logs.add('warn', `🤖 Gemini classification failed: ${r.error}`);
      return null;
    }
    // Map Gemini's status → our signal vocabulary
    const map = {
      published: 'gemini-confirmed-published',
      pending: 'gemini-confirmed-pending-approval',
      failed: 'gemini-confirmed-failed',
      unknown: 'gemini-unknown',
    };
    return {
      signal: map[r.status] || 'gemini-unknown',
      confidence: r.confidence,
      explanation: r.explanation,
      raw: r.status,
    };
  } catch (e) {
    await Logs.add('warn', `🤖 Gemini refine error: ${String(e)}`);
    return null;
  }
}

// ---------- Other channels publishing ----------
// Profile / Page / Story / Reel / Event / Share / Album
async function runChannelJob(job, post, cfg) {
  const type = post.postType;
  const URLS = {
    profile: 'https://www.facebook.com/me',
    page: post.targetPageId ? `https://www.facebook.com/${post.targetPageId}` : null,
    story: 'https://www.facebook.com/stories/create/',
    reel: 'https://www.facebook.com/reel/create/',
    event: 'https://www.facebook.com/events/create/',
    share: post.shareSourceUrl,
    album: 'https://www.facebook.com/me',
  };
  const url = URLS[type];
  if (!url) {
    await Jobs.update(job, { status: 'failed', lastError: `Missing URL for ${type}` });
    await Logs.add('error', `Channel ${type}: missing required URL/page-id`, {}, job.id);
    return;
  }

  await Logs.add('info', `📡 Channel publish (${type}): "${post.title}"`, { url }, job.id);
  const safeMode = await getSetting('safeMode', false);
  const reuse = await getSetting('reusePublisherTab', true);

  const tab = await acquirePublisherTab(url, !!safeMode, reuse);
  await waitForTabComplete(tab.id);
  await new Promise((r) => setTimeout(r, 2500));

  // Bundle media
  const mediaRows = await Media.listByPost(post.id);
  const mediaPayload = [];
  for (const m of mediaRows) {
    try {
      const buf = await m.blob.arrayBuffer();
      mediaPayload.push({
        name: `${m.id}.${(m.mimeType || 'image/jpeg').split('/')[1] || 'bin'}`,
        mimeType: m.mimeType || 'application/octet-stream',
        buffer: Array.from(new Uint8Array(buf)),
      });
    } catch {}
  }

  try {
    const result = await chrome.tabs.sendMessage(tab.id, {
      type: 'PUBLISH_CHANNEL',
      payload: {
        channel: type,
        title: post.title,
        text: post.text,
        media: mediaPayload,
        eventData: post.eventData || null,
        throttle: cfg,
      },
    });
    try { await runSync(); } catch {}
    if (result?.ok) {
      await Jobs.update(job, {
        status: 'done', completedAt: Date.now(), lastError: null,
        postUrl: result.url || null,
      });
      await Posts.markUsed(post);
      await Logs.add('info', `📡 ✅ ${type} published`, { signal: result.signal, url: result.url }, job.id);
    } else {
      await Jobs.update(job, { status: 'failed', lastError: result?.error || 'unknown' });
      await Logs.add('error', `📡 ❌ ${type} failed: ${result?.error}`, {}, job.id);
    }
  } catch (e) {
    await Jobs.update(job, { status: 'failed', lastError: String(e) });
    await Logs.add('error', `📡 ${type} exception: ${String(e)}`, {}, job.id);
  }

  await setSetting('nextAllowedAt', Date.now() + nextInterPostDelayMs(cfg));
  await maybeCloseIdlePublisherTab();
}

// ---------- Marketplace publishing ----------
// Publishes to FB Marketplace instead of a group. The composer is a structured
// form, not free-text. Each category has its own URL + fields.
async function runMarketplaceJob(job, post, group, cfg) {
  const category = post.marketplaceCategory || 'item';
  const URLS = {
    item: 'https://www.facebook.com/marketplace/create/item',
    vehicle: 'https://www.facebook.com/marketplace/create/vehicle',
    'property-sale': 'https://www.facebook.com/marketplace/create/propertyforsale',
    'property-rent': 'https://www.facebook.com/marketplace/create/rental',
  };
  const url = URLS[category];
  if (!url) {
    await Jobs.update(job, { status: 'failed', lastError: `Unknown marketplace category: ${category}` });
    return;
  }

  await Logs.add('info', `🛒 Marketplace publish (${category}): "${post.title}"`, {}, job.id);
  const safeMode = await getSetting('safeMode', false);
  const reuse = await getSetting('reusePublisherTab', true);

  const tab = await acquirePublisherTab(url, !!safeMode, reuse);
  await waitForTabComplete(tab.id);
  await new Promise((r) => setTimeout(r, 2000));

  // Bundle media
  const mediaRows = await Media.listByPost(post.id);
  const mediaPayload = [];
  for (const m of mediaRows) {
    try {
      const buf = await m.blob.arrayBuffer();
      mediaPayload.push({
        name: `${m.id}.${(m.mimeType || 'image/jpeg').split('/')[1] || 'bin'}`,
        mimeType: m.mimeType || 'application/octet-stream',
        buffer: Array.from(new Uint8Array(buf)),
      });
    } catch {}
  }

  try {
    const result = await chrome.tabs.sendMessage(tab.id, {
      type: 'PUBLISH_MARKETPLACE',
      payload: {
        category,
        title: post.title,
        text: post.text,
        media: mediaPayload,
        data: post.marketplaceData || {},
        throttle: cfg,
      },
    });
    try { await runSync(); } catch {}
    if (result?.ok) {
      await Jobs.update(job, {
        status: 'done', completedAt: Date.now(), lastError: null,
        postUrl: result.url || null,
      });
      await Posts.markUsed(post);
      await Logs.add('info', `🛒 ✅ Marketplace listing published`, { signal: result.signal, url: result.url }, job.id);
    } else {
      await Jobs.update(job, { status: 'failed', lastError: result?.error || 'unknown' });
      await Logs.add('error', `🛒 ❌ Marketplace publish failed: ${result?.error}`, {}, job.id);
    }
  } catch (e) {
    await Jobs.update(job, { status: 'failed', lastError: String(e) });
    await Logs.add('error', `🛒 Marketplace exception: ${String(e)}`, {}, job.id);
  }

  await setSetting('nextAllowedAt', Date.now() + nextInterPostDelayMs(cfg));
  await maybeCloseIdlePublisherTab();
}

// ---------- Publisher tab management ----------
// One reusable tab across many jobs. Stored in settings so it survives SW
// suspensions. If the user closes the tab manually, we create a fresh one.

async function acquirePublisherTab(url, active, reuse) {
  if (!reuse) {
    return chrome.tabs.create({ url, active });
  }
  const existingId = await getSetting('publisherTabId');
  if (existingId) {
    try {
      const tab = await chrome.tabs.get(existingId);
      if (tab) {
        // Reusing an existing tab: only navigate the URL. We DO NOT force it
        // active again — if the user moved it to the background, leave it
        // there. This is what lets the user keep working in other tabs while
        // jobs continue to run.
        await chrome.tabs.update(existingId, { url });
        return { id: existingId };
      }
    } catch {
      // Tab is gone — fall through to create a new one
    }
  }
  // First creation only respects safeMode. After this, navigations don't
  // re-steal focus.
  const tab = await chrome.tabs.create({ url, active });
  await setSetting('publisherTabId', tab.id);
  return tab;
}

function waitForTabComplete(tabId, timeoutMs = 30000) {
  return new Promise((resolve, reject) => {
    const start = Date.now();
    const listener = (id, info) => {
      if (id === tabId && info.status === 'complete') {
        chrome.tabs.onUpdated.removeListener(listener);
        resolve();
      }
    };
    chrome.tabs.onUpdated.addListener(listener);
    const tick = setInterval(() => {
      if (Date.now() - start > timeoutMs) {
        clearInterval(tick);
        chrome.tabs.onUpdated.removeListener(listener);
        reject(new Error('Tab load timeout'));
      }
    }, 1000);
  });
}

// Clear the stored tab id if the user closes the publisher tab manually.
chrome.tabs.onRemoved.addListener(async (tabId) => {
  const stored = await getSetting('publisherTabId');
  if (stored === tabId) {
    await setSetting('publisherTabId', null);
  }
});

// After every job, if the queue is empty, close the publisher tab as a courtesy.
async function maybeCloseIdlePublisherTab() {
  const queued = await Jobs.listByStatus('queued');
  const running = await Jobs.listByStatus('running');
  if (queued.length || running.length) return;
  const id = await getSetting('publisherTabId');
  if (id) {
    try { await chrome.tabs.remove(id); } catch {}
    await setSetting('publisherTabId', null);
    await Logs.add('info', 'Queue empty — closed publisher tab');
  }
}

// ---------- Message routing from popup / content ----------

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  (async () => {
    try {
      if (msg.type === 'PING') return sendResponse({ ok: true, pong: Date.now() });

      // Who is logged into Facebook right now? Finds an open FB tab and asks
      // the content script. Caches the result so the popup can show it even
      // when no FB tab is currently open.
      if (msg.type === 'GOOGLE_DISCOVER') {
        const r = await runGoogleDiscover(msg.payload?.query || '', msg.payload?.country);
        return sendResponse(r);
      }

      if (msg.type === 'GET_FB_IDENTITY') {
        const fbTabs = await chrome.tabs.query({ url: ['*://*.facebook.com/*'] });
        for (const t of fbTabs) {
          try {
            const id = await chrome.tabs.sendMessage(t.id, { type: 'DETECT_FB_IDENTITY' });
            if (id?.loggedIn) { await setSetting('fbIdentity', id); return sendResponse(id); }
          } catch { /* content script not ready on this tab */ }
        }
        const cached = await getSetting('fbIdentity', null);
        return sendResponse(cached || { ok: true, loggedIn: false, note: 'אין טאב פייסבוק פתוח' });
      }

      if (msg.type === 'ENQUEUE_BATCH') {
        const { campaignId, groupIds } = msg.payload;
        const cfg = await loadThrottleConfig();
        // Guarantee at least interPostMinSec between jobs (default 180s = 3 min)
        const minGapMs = Math.max((cfg.interPostMinSec || 180) * 1000, 180000);
        let scheduledAt = Date.now();
        for (const gid of groupIds) {
          await Jobs.enqueue(campaignId, gid, scheduledAt);
          scheduledAt += Math.max(nextInterPostDelayMs(cfg), minGapMs);
        }
        return sendResponse({ ok: true, count: groupIds.length });
      }

      if (msg.type === 'START_CAMPAIGN') {
        const campaign = await Campaigns.get(msg.payload.campaignId);
        if (!campaign) return sendResponse({ ok: false, error: 'הקמפיין לא נמצא' });
        const posts = await Posts.listByCampaign(campaign.id);
        if (posts.length < 2) {
          return sendResponse({ ok: false, error: 'הקמפיין צריך לפחות 2 פוסטים לגיוון' });
        }
        const groupIds = campaign.groupIds || [];
        if (!groupIds.length) {
          return sendResponse({ ok: false, error: 'לא נבחרו קבוצות יעד. גלול למטה ב"קבוצות יעד" וסמן לפחות אחת.' });
        }
        const cfg = await loadThrottleConfig();
        const minGapMs = Math.max((cfg.interPostMinSec || 180) * 1000, 180000);
        let scheduledAt = Date.now();
        for (const gid of groupIds) {
          await Jobs.enqueue(campaign.id, gid, scheduledAt);
          scheduledAt += Math.max(nextInterPostDelayMs(cfg), minGapMs);
        }
        await Campaigns.update(campaign, { status: 'active', startedAt: Date.now(), pausedAt: null });
        await Logs.add('info', `Campaign "${campaign.name}" started — ${groupIds.length} groups, ${posts.length} posts`);
        return sendResponse({ ok: true, count: groupIds.length });
      }

      if (msg.type === 'PAUSE_CAMPAIGN') {
        const campaign = await Campaigns.get(msg.payload.campaignId);
        if (!campaign) return sendResponse({ ok: false, error: 'campaign not found' });
        await Campaigns.update(campaign, { status: 'paused', pausedAt: Date.now() });
        await Logs.add('info', `Campaign "${campaign.name}" paused`);
        return sendResponse({ ok: true });
      }

      if (msg.type === 'RESUME_CAMPAIGN') {
        const campaign = await Campaigns.get(msg.payload.campaignId);
        if (!campaign) return sendResponse({ ok: false, error: 'campaign not found' });
        await Campaigns.update(campaign, { status: 'active', pausedAt: null });
        await Logs.add('info', `Campaign "${campaign.name}" resumed`);
        return sendResponse({ ok: true });
      }

      if (msg.type === 'RUN_NEXT_NOW') {
        // Debug: bypass throttler and run the earliest queued job immediately.
        const queued = await Jobs.listByStatus('queued');
        const ready = queued.sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0));
        if (!ready.length) return sendResponse({ ok: false, error: 'אין משימות בתור' });
        const job = ready[0];
        const cfg = await loadThrottleConfig();
        await Jobs.update(job, { status: 'running', attempts: (job.attempts || 0) + 1 });
        await Logs.add('info', `🐞 Manual run: forcing job ${job.id}`);
        try { await runJob(job, cfg); } catch (e) {
          await Logs.add('error', 'Manual run crashed', { error: String(e) }, job.id);
          return sendResponse({ ok: false, error: String(e?.message || e) });
        }
        return sendResponse({ ok: true });
      }

      if (msg.type === 'MARK_DONE') {
        // For when a job was actually published but our detection missed the signal.
        const job = await Jobs.get(msg.payload.jobId);
        if (!job) return sendResponse({ ok: false, error: 'משימה לא נמצאה' });
        await Jobs.update(job, {
          status: 'done',
          completedAt: Date.now(),
          lastError: null,
        });
        await Logs.add('info', `Job ${job.id} manually marked as done`);
        return sendResponse({ ok: true });
      }

      if (msg.type === 'MARK_ALL_FAILED_DONE') {
        // Bulk: mark every currently-failed job as done.
        const failed = await Jobs.listByStatus('failed');
        for (const job of failed) {
          await Jobs.update(job, { status: 'done', completedAt: Date.now(), lastError: null });
        }
        await Logs.add('info', `Bulk-marked ${failed.length} failed jobs as done`);
        return sendResponse({ ok: true, count: failed.length });
      }

      if (msg.type === 'REQUEUE_JOB') {
        const job = await Jobs.get(msg.payload.jobId);
        if (!job) return sendResponse({ ok: false, error: 'משימה לא נמצאה' });
        // Stagger: schedule this requeued job AFTER any others already queued,
        // with a fresh Gaussian gap. Guarantees ≥3 min spacing.
        const cfg = await loadThrottleConfig();
        const queued = await Jobs.listByStatus('queued');
        const lastScheduled = queued.reduce((max, j) => Math.max(max, j.scheduledAt || 0), 0);
        const newScheduledAt = Math.max(lastScheduled, Date.now()) + nextInterPostDelayMs(cfg);
        await Jobs.update(job, {
          status: 'queued',
          attempts: 0,
          lastError: null,
          completedAt: null,
          chosenPostId: null,
          scheduledAt: newScheduledAt,
        });
        await Logs.add('info', `Job ${job.id} requeued for ${new Date(newScheduledAt).toLocaleString('he-IL')}`);
        return sendResponse({ ok: true, scheduledAt: newScheduledAt });
      }

      if (msg.type === 'QUEUE_ETA') {
        // Returns when the next queued job is expected to run.
        const queued = await Jobs.listByStatus('queued');
        if (!queued.length) return sendResponse({ ok: true, nextAt: null });
        const next = queued.sort((a, b) => (a.scheduledAt || 0) - (b.scheduledAt || 0))[0];
        const nextAllowedAt = (await getSetting('nextAllowedAt', 0)) || 0;
        const effectiveAt = Math.max(next.scheduledAt || 0, nextAllowedAt);
        return sendResponse({ ok: true, nextAt: effectiveAt, jobId: next.id });
      }

      if (msg.type === 'FORCE_REFRESH_METRICS') {
        const job = await Jobs.get(msg.payload?.jobId);
        if (!job) return sendResponse({ ok: false, error: 'job not found' });
        await Jobs.update(job, { trackingDueAt: Date.now() });
        await Logs.add('info', `📊 Force refresh queued for job ${job.id.slice(0, 8)}`);
        return sendResponse({ ok: true });
      }

      if (msg.type === 'SYNC_NOW') {
        try {
          const r = await runSync();
          return sendResponse({ ok: true, result: r });
        } catch (e) {
          await Logs.add('error', 'Sync failed', { error: String(e) });
          return sendResponse({ ok: false, error: String(e?.message || e) });
        }
      }

      // === AI Mode Sprint 3: generate content from analysis ===
      if (msg.type === 'AI_MODE_GENERATE_CONTENT') {
        try {
          obLog.event('ai_mode_generate_start');
          const { token } = await getSession();
          if (!token) return sendResponse({ ok: false, error: 'not logged in' });
          
          const r = await fetch(`${SYNC.endpoint}/api/v1/ai-mode/generate-content`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
            body: JSON.stringify({ count: msg.payload?.count || 20 }),
          }).then(r => r.json());
          obLog.event('ai_mode_generate_done', { context: { generated: r.generated } });
          return sendResponse(r);
        } catch (e) {
          obLog.error('ai_mode_generate_exception', { context: { msg: String(e?.message || e) } });
          return sendResponse({ ok: false, error: String(e?.message || e) });
        }
      }

      // === AI Mode Sprint 4: scrape groups + score them ===
      if (msg.type === 'AI_MODE_SCORE_GROUPS') {
        try {
          obLog.event('ai_mode_score_groups_start');
          const { token } = await getSession();
          if (!token) return sendResponse({ ok: false, error: 'not logged in' });
          // Open the groups page and scrape (reuses the existing handler).
          let tab;
          const existing = await chrome.tabs.query({ url: '*://*.facebook.com/groups/joins/*' });
          if (existing.length > 0) {
            tab = existing[0];
            // Don't steal focus — keep the plugin view on top while scraping.
          } else {
            // Background tab (active:false) so the plugin/dashboard stays on top.
            tab = await chrome.tabs.create({ url: 'https://www.facebook.com/groups/joins/', active: false });
            await new Promise((resolve) => {
              const lt = (tabId, info) => {
                if (tabId === tab.id && info.status === 'complete') {
                  chrome.tabs.onUpdated.removeListener(lt);
                  resolve();
                }
              };
              chrome.tabs.onUpdated.addListener(lt);
              setTimeout(() => { chrome.tabs.onUpdated.removeListener(lt); resolve(); }, 15000);
            });
            await new Promise(r => setTimeout(r, 2000));
          }
          const scrape = await chrome.tabs.sendMessage(tab.id, { type: 'SCRAPE_MY_GROUPS' });
          if (!scrape?.ok) return sendResponse({ ok: false, error: scrape?.error || 'group scrape failed' });
          // Now ask the server to score them.
          
          const r = await fetch(`${SYNC.endpoint}/api/v1/ai-mode/score-groups`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
            body: JSON.stringify({
              groups: (scrape.groups || []).map(g => ({
                fb_group_id: g.fbGroupId || g.id,
                name: g.name,
                url: g.url,
                member_count: g.memberCount,
              })),
            }),
          }).then(r => r.json());
          obLog.event('ai_mode_score_groups_done', { context: { scored: r.scored } });
          return sendResponse(r);
        } catch (e) {
          obLog.error('ai_mode_score_groups_exception', { context: { msg: String(e?.message || e) } });
          return sendResponse({ ok: false, error: String(e?.message || e) });
        }
      }

      // === AI Mode Sprint 5: build the schedule ===
      if (msg.type === 'AI_MODE_BUILD_SCHEDULE') {
        try {
          obLog.event('ai_mode_schedule_start');
          const { token } = await getSession();
          if (!token) return sendResponse({ ok: false, error: 'not logged in' });
          
          const r = await fetch(`${SYNC.endpoint}/api/v1/ai-mode/build-schedule`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'X-Session-Token': token },
            body: JSON.stringify({ days: msg.payload?.days || 14, minScore: msg.payload?.minScore || 7 }),
          }).then(r => r.json());
          obLog.event('ai_mode_schedule_done', { context: { scheduled: r.scheduled } });
          return sendResponse(r);
        } catch (e) {
          obLog.error('ai_mode_schedule_exception', { context: { msg: String(e?.message || e) } });
          return sendResponse({ ok: false, error: String(e?.message || e) });
        }
      }

      // === AI Mode Sprint 2: scrape my own FB posts, upload + trigger analysis ===
      // payload: { maxPosts? }   default 30
      // Flow: open facebook.com/me → wait for load → ask content script to
      // scrape → POST to /ai-mode/history-snapshots → POST to /ai-mode/analyze-history
      // Content script scrolled during a scan — grab a screenshot (throttled).
      if (msg.type === 'SCAN_SCROLL_TICK') {
        if (_scanCapturing) { grabScanShot(`גלילה ${msg.scrolls || ''} · ${msg.found || 0} פוסטים`); }
        return; // no response needed
      }

      // Phase B — direct group scan (her posts inside her groups).
      if (msg.type === 'AI_MODE_SCAN_GROUPS') {
        try { sendResponse({ ok: true, started: true }); } catch {}
        if (typeof self.startGroupScan === 'function') self.startGroupScan(msg.payload || {});
        return true;
      }

      // Autopilot (DRY RUN) — full planning loop in one shot.
      if (msg.type === 'AI_AUTOPILOT_START') {
        try { sendResponse({ ok: true, started: true }); } catch {}
        if (typeof self.runAutopilot === 'function') self.runAutopilot(msg.payload || {});
        return true;
      }

      // Full reset — clean restart from scratch (local + server).
      if (msg.type === 'AI_MODE_FULL_RESET') {
        try { sendResponse({ ok: true, started: true }); } catch {}
        if (typeof self.resetAiModeAll === 'function') self.resetAiModeAll(msg.payload || {});
        return true;
      }

      // The unified agent cycle (ADR-35) — the whole loop in one run.
      if (msg.type === 'AI_AGENT_CYCLE_START') {
        try { sendResponse({ ok: true, started: true }); } catch {}
        if (typeof self.runAgentCycle === 'function') self.runAgentCycle(msg.payload || {});
        return true;
      }

      // Read the offering links autonomously → understand → draft posts (ADR-37).
      if (msg.type === 'AI_BUILD_FROM_OFFERINGS') {
        try { sendResponse({ ok: true, started: true }); } catch {}
        if (typeof self.buildFromOfferings === 'function') self.buildFromOfferings(msg.payload || {});
        return true;
      }

      // Autonomous mode master switch (ADR-39).
      if (msg.type === 'AI_AUTONOMOUS_SET') {
        (async () => {
          const r = typeof self.setAutonomousMode === 'function' ? await self.setAutonomousMode(msg.payload || {}) : { ok: false };
          try { sendResponse(r); } catch {}
        })();
        return true;
      }

      if (msg.type === 'AI_MODE_SCAN_HISTORY') {
        // ACK immediately so the popup doesn't block; all progress flows through
        // storage (aiScanState) + AI_SCAN_PROGRESS pushes. The scan continues in
        // the SW even if the popup is closed.
        try { sendResponse({ ok: true, started: true }); } catch {}
        if (typeof self.startHistoryScan === 'function') self.startHistoryScan(msg.payload || {});
        return true;
      }

      // Reset the scan memory (seen posts + accumulated footprint) so the next
      // scan starts fresh. Use when the user explicitly wants a full re-scan.
      if (msg.type === 'AI_MODE_RESET_SCAN_MEMORY') {
        try {
          await setSetting('scanSeenKeys', []);
          await setSetting('scanFootprint', {});
          await setSetting('aiScanState', {});
          return sendResponse({ ok: true });
        } catch (e) { return sendResponse({ ok: false, error: String(e?.message || e) }); }
      }

      if (msg.type === 'SYNC_TEST') {
        return sendResponse(await testConnection());
      }

      if (msg.type === 'GET_THROTTLE_CONFIG') {
        return sendResponse({ ok: true, config: await loadThrottleConfig() });
      }

      if (msg.type === 'SET_THROTTLE_CONFIG') {
        await setSetting('throttleConfig', msg.payload || {});
        return sendResponse({ ok: true });
      }

      if (msg.type === 'JOIN_GROUPS') {
        // payload: { groupIds: string[] }  (array of internal group ids)
        const { groupIds } = msg.payload || {};
        if (!Array.isArray(groupIds) || !groupIds.length) {
          return sendResponse({ ok: false, error: 'אין קבוצות נבחרות' });
        }
        const safeMode = await getSetting('safeMode', false);
        const reuse = await getSetting('reusePublisherTab', true);
        const results = { instant: 0, approval: 0, alreadyMember: 0, failed: 0 };

        for (let i = 0; i < groupIds.length; i++) {
          const g = await Groups.get(groupIds[i]);
          if (!g) { results.failed++; continue; }
          await Logs.add('info', `🤝 Joining "${g.name}" (${i + 1}/${groupIds.length})`);
          try {
            const tab = await acquirePublisherTab(g.url, !!safeMode, reuse);
            await waitForTabComplete(tab.id);
            await new Promise((r) => setTimeout(r, 1500));
            const r = await chrome.tabs.sendMessage(tab.id, { type: 'JOIN_GROUP' });
            if (r?.ok) {
              const updates = {
                joinPolicy: r.joinPolicy || 'unknown',
                isMember: (r.alreadyMember || r.joinPolicy === 'instant') ? 1 : 0,
              };
              if (updates.isMember && g.source === 'discovered') updates.source = 'joined';
              await Groups.upsertByFbId(g.fbGroupId, updates);
              if (r.alreadyMember) results.alreadyMember++;
              else if (r.joinPolicy === 'instant') results.instant++;
              else if (r.joinPolicy === 'approval') results.approval++;
              await Logs.add('info', `  → ${r.alreadyMember ? 'already member' : r.joinPolicy}`);
            } else {
              results.failed++;
              await Logs.add('error', `  → join failed: ${r?.error || 'unknown'}`);
            }
          } catch (e) {
            results.failed++;
            await Logs.add('error', `  → exception: ${String(e)}`);
          }
          // Inter-join delay: longer than posting since FB is strict on join velocity.
          // 90-180 seconds between joins.
          if (i < groupIds.length - 1) {
            const wait = 90000 + Math.random() * 90000;
            await Logs.add('info', `Waiting ${Math.round(wait / 1000)}s before next join`);
            await new Promise((r) => setTimeout(r, wait));
          }
        }
        await Logs.add('info', `Join batch done: ${JSON.stringify(results)}`);
        return sendResponse({ ok: true, results });
      }

      if (msg.type === 'DISCOVER_GROUPS') {
        // payload: { keyword }
        const keyword = String(msg.payload?.keyword || '').trim();
        if (!keyword) return sendResponse({ ok: false, error: 'נדרשת מילת מפתח' });
        const url = `https://www.facebook.com/search/groups/?q=${encodeURIComponent(keyword)}`;
        await Logs.add('info', `Discovering groups for "${keyword}"`);
        const safeMode = await getSetting('safeMode', false);
        const reuse = await getSetting('reusePublisherTab', true);
        const tab = await acquirePublisherTab(url, !!safeMode, reuse);
        await waitForTabComplete(tab.id);
        await new Promise((r) => setTimeout(r, 1500)); // FB lazy-loads
        try {
          const result = await chrome.tabs.sendMessage(tab.id, {
            type: 'SCRAPE_GROUP_SEARCH',
            payload: { maxScrolls: 10, scrollPauseMs: 1500 },
          });
          if (!result?.ok) {
            await Logs.add('error', `Discovery failed: ${result?.error || 'unknown'}`);
            return sendResponse({ ok: false, error: result?.error || 'scrape failed' });
          }
          // Save to discoveries store + bulk-upsert as Groups with source='discovered'
          // Upsert so re-searching the same keyword doesn't create duplicates.
          const autoTrack = !!msg.payload?.autoTrack;
          await Discoveries.upsertByQuery(keyword, result.results, { autoTrack });
          for (const r of result.results) {
            await Groups.upsertByFbId(r.fbGroupId, {
              name: r.name,
              url: r.url,
              memberCount: r.memberCount,
              privacy: r.privacy,
              joinPolicy: r.joinPolicy,
              isMember: r.isMember ? 1 : 0,
              source: r.isMember ? 'joined' : 'discovered',
            });
          }
          await Logs.add('info', `Discovered ${result.results.length} groups for "${keyword}"`);
          return sendResponse({ ok: true, count: result.results.length, results: result.results });
        } catch (e) {
          await Logs.add('error', `Discovery error: ${String(e)}`);
          return sendResponse({ ok: false, error: String(e?.message || e) });
        }
      }

      if (msg.type === 'CONTENT_LOG') {
        // Forwarded from content script — store in Logs so the user can see
        // exactly what happened on the FB page.
        const { level, message, meta } = msg.payload || {};
        await Logs.add(level || 'info', message || '(no message)', meta || {});
        return sendResponse({ ok: true });
      }

      // ---- "Run now" handlers — let the user test each automation immediately ----
      if (msg.type === 'RUN_LI_GROWTH_NOW') {
        // Bypass the work-hours/weekday gate by temporarily flagging this run.
        // We just call the function and let its existing checks decide. If
        // the user is outside the window we report why.
        runLinkedInGrowthPass().catch(e => Logs.add('warn', `manual LI growth: ${e}`));
        return sendResponse({ ok: true, message: 'Triggered LI growth pass (check Logs)' });
      }
      if (msg.type === 'HUNTER_FILL_FORM') {
        const result = await fillProspectContactForm(msg.payload || {});
        return sendResponse(result);
      }
      if (msg.type === 'RUN_RD_GROWTH_NOW') {
        runRedditGrowthPass().catch(e => Logs.add('warn', `manual RD growth: ${e}`));
        return sendResponse({ ok: true, queued: true });
      }
      if (msg.type === 'RUN_TW_GROWTH_NOW') {
        runTwitterGrowthPass().catch(e => Logs.add('warn', `manual TW growth: ${e}`));
        return sendResponse({ ok: true, message: 'Triggered TW growth pass (check Logs)' });
      }
      if (msg.type === 'RUN_LI_REPLY_NOW') {
        runLinkedInAutoReplyPass().catch(e => Logs.add('warn', `manual LI reply: ${e}`));
        return sendResponse({ ok: true, message: 'Triggered LI auto-reply pass (check Logs)' });
      }

      if (msg.type === 'AUDIT_LI_PROFILE') {
        // Two-step: (1) find the LinkedIn tab, (2) ask content script to scrape,
        // (3) feed scraped data to Gemini. Returns the full audit result.
        const tabs = await chrome.tabs.query({ url: ['https://www.linkedin.com/in/*'] });
        if (!tabs.length) {
          return sendResponse({ ok: false, error: 'אין טאב LinkedIn פתוח עם פרופיל (/in/...). פתח את הפרופיל שלך ונסה שוב.' });
        }
        const tab = tabs[0];
        let scrape;
        try {
          scrape = await chrome.tabs.sendMessage(tab.id, { type: 'AUDIT_PROFILE' });
        } catch (e) {
          return sendResponse({ ok: false, error: 'content script not loaded — refresh the LinkedIn tab' });
        }
        if (!scrape?.ok) return sendResponse({ ok: false, error: scrape?.error || 'scrape failed' });

        // Call Gemini for the audit (uses user's BYOK key)
        const audit = await geminiAuditLinkedInProfile(scrape.profile);
        if (!audit.ok) return sendResponse({ ok: false, error: audit.error });

        return sendResponse({
          ok: true,
          audit,
          profile: {
            name: scrape.profile.name,
            headline: scrape.profile.headline,
            url: scrape.profile.profileUrl,
          },
        });
      }

      if (msg.type === 'GENERATE_LI_REPLY') {
        // Per-comment AI reply for the LinkedIn auto-reply pass. Same pattern
        // as GENERATE_LI_COMMENT but uses the reply-specific prompt + context.
        const expertise = (await getSetting('userExpertise', '')) || '';
        const { commentText, commentAuthor, originalPostText } = msg.payload || {};
        const r = await geminiGenerateLinkedInReply(commentText || '', {
          commentAuthor: commentAuthor || '',
          originalPostText: originalPostText || '',
          userExpertise: expertise,
        });
        return sendResponse(r);
      }

      if (msg.type === 'GENERATE_LI_COMMENT') {
        // Per-post AI comment for the LinkedIn growth flywheel.
        // Called by content/linkedin-content.js#humanlikeSearchEngage when
        // liGrowthAiCommentEnabled is set. Falls back gracefully so the
        // growth pass never crashes the whole run on a Gemini failure.
        const expertise = (await getSetting('userExpertise', '')) || '';
        const { postText, authorName } = msg.payload || {};
        const r = await geminiGenerateLinkedInComment(postText || '', {
          authorName: authorName || '',
          userExpertise: expertise,
        });
        return sendResponse(r);
      }

      if (msg.type === 'SCRAPE_MY_GROUPS_RESULT') {
        // payload: [{ fbGroupId, name, url, memberCount }]
        for (const g of msg.payload) {
          await Groups.upsertByFbId(g.fbGroupId, {
            name: g.name, url: g.url, memberCount: g.memberCount,
            isMember: 1, source: 'joined',
          });
        }
        await Logs.add('info', `Imported ${msg.payload.length} groups`);
        return sendResponse({ ok: true });
      }

      sendResponse({ ok: false, error: 'unknown message type' });
    } catch (e) {
      sendResponse({ ok: false, error: String(e) });
    }
  })();
  return true; // keep channel open for async sendResponse
});
