// Content script: runs on facebook.com tabs.
// IMPORTANT: every meaningful step calls reportStep(), which both:
//   (a) console.logs to the page's DevTools
//   (b) sends a CONTENT_LOG message to the background so it appears in the
//       Logs tab — even if the tab is in the background.
//
// FB's DOM is heavily obfuscated. The SELECTORS object is the place to tweak.

const SELECTORS = {
  // Composer trigger ("כאן כותבים...") — multiple fallback strategies.
  composerOpen: [
    '[role="button"][aria-label*="כאן כותבים"]',
    '[role="button"][aria-label*="Write something" i]',
    '[role="button"][aria-label*="Create" i][aria-label*="post" i]',
    '[role="button"][aria-label*="כתב"]',
    '[role="button"][aria-label*="הוסיפו פוסט"]',
    '[role="button"][aria-label*="צור פוסט"]',
  ],
  // The dialog that opens
  dialog: '[role="dialog"]',
  // The contenteditable inside the dialog
  editable: [
    '[role="dialog"] [contenteditable="true"][role="textbox"]',
    '[role="dialog"] [contenteditable="true"]',
  ],
  // Publish/Post button. NOTE: FB Hebrew uses "פרסום" (noun form), not "פרסם".
  submit: [
    '[role="dialog"] [aria-label="פרסום"]',
    '[role="dialog"] [aria-label="פרסם"]',
    '[role="dialog"] [aria-label="Post"]',
    '[role="dialog"] [aria-label="Publish"]',
  ],
  groupCardLinks: 'a[href*="/groups/"]',
  fileInput: '[role="dialog"] input[type="file"]',
  // "צילום/וידאו" is the typical Hebrew button. Match broadly.
  addPhotoBtn: [
    '[role="dialog"] [aria-label*="צילום"]',
    '[role="dialog"] [aria-label*="וידאו"]',
    '[role="dialog"] [aria-label*="תמונ"]',
    '[role="dialog"] [aria-label*="Photo" i]',
    '[role="dialog"] [aria-label*="video" i]',
  ],
};

// ---------- Reporting ----------

function reportStep(level, message, meta = {}) {
  console.log(`[fb-poster][${level}] ${message}`, meta);
  try {
    chrome.runtime.sendMessage({ type: 'CONTENT_LOG', payload: { level, message, meta } });
  } catch { /* runtime may be gone if extension reloaded */ }
}

// ---------- Utilities ----------

function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }

function querySelectorAny(selectors, root = document) {
  const arr = Array.isArray(selectors) ? selectors : [selectors];
  for (const sel of arr) {
    try {
      const el = root.querySelector(sel);
      if (el) return el;
    } catch { /* invalid pseudo, skip */ }
  }
  return null;
}

// Find composer button by visible text content as a fallback heuristic.
function findComposerByText() {
  const candidates = document.querySelectorAll('[role="button"], div[role="button"], [role="textbox"]');
  const phrases = [
    'כאן כותבים', 'כתבו משהו', 'צרו פוסט', 'הוסיפו פוסט', 'מה דעתך',
    'Write something', 'Create post', "What's on your mind",
  ];
  for (const el of candidates) {
    const txt = (el.innerText || el.textContent || '').trim();
    if (!txt || txt.length > 80) continue;
    if (phrases.some((p) => txt.includes(p))) return el;
  }
  return null;
}

function waitFor(selectors, { timeout = 15000, root = document } = {}) {
  return new Promise((resolve, reject) => {
    const start = Date.now();
    const found = querySelectorAny(selectors, root);
    if (found) return resolve(found);
    const observer = new MutationObserver(() => {
      const el = querySelectorAny(selectors, root);
      if (el) { observer.disconnect(); resolve(el); }
      else if (Date.now() - start > timeout) {
        observer.disconnect();
        reject(new Error(`Timeout waiting for ${JSON.stringify(selectors)}`));
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });
  });
}

function extractGroupIdFromUrl(href) {
  const m = href.match(/\/groups\/([^/?#]+)/);
  return m ? m[1] : null;
}

// ---------- Typing simulation ----------

function* typingPlan(text, cfg) {
  const min = cfg.typingMinMsPerChar ?? 40;
  const max = cfg.typingMaxMsPerChar ?? 140;
  const pauseChance = cfg.typingPauseChance ?? 0.08;
  const pauseMin = cfg.typingPauseMinMs ?? 400;
  const pauseMax = cfg.typingPauseMaxMs ?? 1800;
  for (const ch of text) {
    let d = min + Math.random() * (max - min);
    if (/[.,!?\n]/.test(ch)) d *= 2.5;
    if (Math.random() < pauseChance) d += pauseMin + Math.random() * (pauseMax - pauseMin);
    yield { ch, delayMs: Math.floor(d) };
  }
}

// Dispatch the FULL keyboard event sequence for one character, so FB sees
// exactly what a real keyboard would emit. Order matches the browser's:
//   keydown -> beforeinput -> input (insertion happens here) -> keyup
//
// This is the difference between "looks like paste" and "looks like typing":
// without keydown/keyup, FB's React handlers don't fire the natural way.
function dispatchKeyChar(el, ch) {
  const keyCode = ch.length === 1 ? ch.charCodeAt(0) : 0;
  const isEnter = ch === '\n';
  const keyName = isEnter ? 'Enter' : ch;
  const codeName = isEnter ? 'Enter' : `Key${ch.toUpperCase()}`;

  const opts = {
    key: keyName,
    code: codeName,
    keyCode,
    which: keyCode,
    bubbles: true,
    cancelable: true,
    composed: true,
  };

  el.dispatchEvent(new KeyboardEvent('keydown', opts));

  // beforeinput tells React "I'm about to insert this character"
  el.dispatchEvent(new InputEvent('beforeinput', {
    inputType: 'insertText',
    data: ch,
    bubbles: true,
    cancelable: true,
    composed: true,
  }));

  // Actually insert. execCommand triggers the right React internals on FB.
  const ok = document.execCommand && document.execCommand('insertText', false, ch);
  if (!ok) {
    // Fallback: synthesize an input event ourselves
    el.dispatchEvent(new InputEvent('input', {
      inputType: 'insertText',
      data: ch,
      bubbles: true,
      cancelable: true,
      composed: true,
    }));
  }

  el.dispatchEvent(new KeyboardEvent('keyup', opts));
}

async function typeIntoEditable(el, text, cfg) {
  // Focus + position cursor naturally
  el.focus();
  // Click into the field once with a real mouse-like event so React knows
  // we're "interacting"
  el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
  el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
  el.dispatchEvent(new MouseEvent('click', { bubbles: true }));

  for (const { ch, delayMs } of typingPlan(text, cfg)) {
    dispatchKeyChar(el, ch);
    await sleep(delayMs);
  }
}

// ---------- Publish flow ----------

async function publishPost(payload) {
  const {
    text = '', media = [], throttle = {},
    postType = 'text', colorIndex = 0, linkUrl = null,
    pollQuestion = null, pollOptions = null,
    gifQuery = null, feeling = null,
    mentions = null, checkinLocation = null,
  } = payload || {};
  reportStep('info', `📍 Starting publish on ${location.pathname} (type=${postType})`);

  let bodyText = text;
  // For link posts: append the URL to the text so FB auto-fetches the preview.
  if (postType === 'link' && linkUrl) {
    bodyText = bodyText ? `${bodyText}\n\n${linkUrl}` : linkUrl;
  }
  // Colored & poll & gif posts don't support media.
  let mediaList = media;
  if (['colored', 'poll', 'gif'].includes(postType) && mediaList.length) {
    reportStep('warn', `⚠ ${postType} posts do not support media — dropping attachments`);
    mediaList = [];
  }

  // 1) Open composer
  reportStep('info', 'Looking for composer button...');
  let opener = querySelectorAny(SELECTORS.composerOpen);
  if (!opener) opener = findComposerByText();
  if (!opener) {
    try { opener = await waitFor(SELECTORS.composerOpen, { timeout: 8000 }); }
    catch { /* fall through */ }
  }
  if (!opener) {
    reportStep('error', '❌ Composer button not found — selectors need update');
    throw new Error('Composer button not found. Selectors need adjustment.');
  }
  reportStep('info', `✓ Found composer button (text: "${(opener.innerText || '').slice(0, 50)}")`);
  await sleep(randBetween(throttle.preClickMinMs ?? 800, throttle.preClickMaxMs ?? 2500));
  opener.click();

  // 2) Wait for dialog
  reportStep('info', 'Waiting for dialog to open...');
  try { await waitFor(SELECTORS.dialog, { timeout: 10000 }); }
  catch {
    reportStep('error', '❌ Dialog did not open after clicking composer');
    throw new Error('Dialog did not open');
  }
  reportStep('info', '✓ Dialog opened');

  // (2.5) Some groups show the "Membership Check" / rules dialog BEFORE the
  // composer. Handle it transparently, then wait for the REAL composer.
  await sleep(800);
  const preHandled = await tryHandleGroupRulesDialog();
  if (preHandled) {
    reportStep('info', '📜 Pre-composer rules dialog handled — waiting for real composer');
    // Give FB a moment to open the actual composer
    await sleep(1500);
    try { await waitFor(SELECTORS.dialog, { timeout: 8000 }); }
    catch {
      reportStep('error', '❌ Real composer did not open after rules consent');
      throw new Error('Composer did not open after consent');
    }
  }

  // 3) Find editable
  reportStep('info', 'Looking for text input...');
  let editable;
  try { editable = await waitFor(SELECTORS.editable, { timeout: 8000 }); }
  catch {
    reportStep('error', '❌ Text input not found inside dialog');
    throw new Error('Editable area not found');
  }
  reportStep('info', '✓ Text input found, typing...');

  // (Pre-4) If postType is 'colored', try to enable the colored-background mode
  // BEFORE typing — FB enables it via an "Aa" toggle next to the editable.
  if (postType === 'colored') {
    try {
      await enableColoredBackground(colorIndex);
      reportStep('info', `🎨 Colored background enabled (index ${colorIndex})`);
    } catch (e) {
      reportStep('warn', `⚠ Could not enable colored bg: ${e.message} — falling back to plain text`);
    }
  }

  // (Pre-4) For feeling posts: trigger the feeling/activity picker before typing
  if (postType === 'feeling' && feeling) {
    try { await pickFeeling(feeling); reportStep('info', `😊 Feeling set: ${feeling}`); }
    catch (e) { reportStep('warn', `⚠ Could not set feeling: ${e.message} — continuing with plain text`); }
  }

  // 4) Type the main body
  await sleep(randBetween(400, 1200));
  if (bodyText) {
    await typeIntoEditable(editable, bodyText, throttle);
    reportStep('info', `✓ Typed ${bodyText.length} characters`);
  }

  // 4.5) @mentions — append after main text
  if (mentions?.length) {
    for (const m of mentions) {
      await sleep(500 + Math.random() * 800);
      dispatchKeyChar(editable, ' ');
      dispatchKeyChar(editable, '@');
      await sleep(300);
      for (const ch of m) {
        dispatchKeyChar(editable, ch);
        await sleep(50 + Math.random() * 100);
      }
      await sleep(1200);
      const suggestion = document.querySelector('[role="option"], [role="listbox"] [role="button"]');
      if (suggestion) {
        suggestion.click();
        reportStep('info', `  ✓ Tagged @${m}`);
        await sleep(500);
      } else {
        reportStep('warn', `  ⚠ No suggestion for @${m}`);
      }
    }
  }

  // 4.6) Check-in / Location
  if (checkinLocation) {
    try {
      const checkInBtn = querySelectorAny([
        '[role="dialog"] [aria-label*="Check" i]',
        '[role="dialog"] [aria-label*="צ\'ק"]',
        '[role="dialog"] [aria-label*="מיקום"]',
        '[role="dialog"] [aria-label*="Location" i]',
      ]);
      if (checkInBtn) {
        checkInBtn.click();
        await sleep(1200);
        const searchInput = document.querySelector('[role="dialog"] input[type="text"]:not([readonly])');
        if (searchInput) {
          searchInput.focus();
          for (const ch of checkinLocation) {
            dispatchKeyChar(searchInput, ch);
            await sleep(60 + Math.random() * 80);
          }
          await sleep(1500);
          const firstSugg = document.querySelector('[role="dialog"] [role="option"], [role="dialog"] li[role="button"]');
          if (firstSugg) {
            firstSugg.click();
            reportStep('info', `  ✓ Checked in: ${checkinLocation}`);
            await sleep(800);
          }
        }
      } else {
        reportStep('warn', 'Check-in button not found');
      }
    } catch (e) {
      reportStep('warn', `Check-in failed: ${e.message}`);
    }
  }

  // (Post-4) Poll: open poll panel and fill question + options
  if (postType === 'poll' && pollQuestion && pollOptions?.length >= 2) {
    try { await fillPoll(pollQuestion, pollOptions, throttle); reportStep('info', '📊 Poll filled'); }
    catch (e) { reportStep('error', `❌ Poll fill failed: ${e.message}`); throw e; }
  }

  // (Post-4) GIF: open GIF picker, search, pick first result
  if (postType === 'gif' && gifQuery) {
    try { await pickGif(gifQuery); reportStep('info', '🎉 GIF attached'); }
    catch (e) { reportStep('error', `❌ GIF pick failed: ${e.message}`); throw e; }
  }

  // 5) Attach media if any
  if (mediaList && mediaList.length) {
    reportStep('info', `Attaching ${mediaList.length} media files...`);
    try { await attachMediaToComposer(mediaList); reportStep('info', '✓ Media attached'); }
    catch (e) { reportStep('error', `❌ Media attach failed: ${e.message}`); throw e; }
  }

  // 6) Submit — wait for FB to enable "פרסום" after our typing
  reportStep('info', 'Waiting for Post button to become enabled...');
  await sleep(randBetween(throttle.preClickMinMs ?? 800, throttle.preClickMaxMs ?? 2500));
  let submit = querySelectorAny(SELECTORS.submit);
  if (!submit) {
    reportStep('error', '❌ Post/Submit button not found in dialog');
    throw new Error('Submit button not found');
  }
  // Poll for enabled state
  const enableStart = Date.now();
  while (submit.getAttribute('aria-disabled') === 'true') {
    if (Date.now() - enableStart > 10000) {
      reportStep('error', '❌ Post button stayed disabled after 10s');
      throw new Error('Submit button disabled');
    }
    await sleep(300);
    submit = querySelectorAny(SELECTORS.submit) || submit;
  }
  // Capture references BEFORE clicking so we can detect "this dialog is gone"
  // even if a new dialog opens (FB sometimes opens a share dialog after).
  const submittedDialog = document.querySelector(SELECTORS.dialog);
  reportStep('info', `✓ Clicking Post button (label="${submit.getAttribute('aria-label')}")`);
  submit.click();

  // (Pre-7) Capture group metadata BEFORE the dialog closes — easier to scrape
  // from the group page than after navigation. We grab member count + privacy.
  const groupMeta = scrapeGroupMetadata();

  // Snippet of our text we'll search for in the feed after publish.
  // Use a unique chunk from the middle of the text (more reliable than the
  // beginning, which might be a common phrase).
  const snippet = text.replace(/\s+/g, ' ').trim().slice(0, 40);

  // 7) Confirm publish — OPTIMISTIC model:
  //   We watch for ~8 seconds. If we see a clear failure signal, fail. Otherwise,
  //   assume success. Rationale: FB always shows visible errors on failure
  //   ("standards", "try again", red banner). Absence of error = success.
  reportStep('info', 'Watching for outcome...');
  const outcome = await detectPublishOutcome(submittedDialog, 12000, snippet);
  reportStep('info', `📤 Outcome: ${outcome.signal} (${outcome.success ? 'success' : 'failure'})`);
  if (!outcome.success) {
    throw new Error(`Publish rejected: ${outcome.signal}`);
  }
  return { ok: true, signal: outcome.signal, groupMeta };
}

// Returns { success, signal }. Strongly biased toward "success" — only a
// clear failure marker triggers a negative result.
//
// `textSnippet` is a unique chunk of OUR submitted text. We search the page
// feed for it — if it appears, the post is DEFINITELY visible (= success).
// This is the most reliable signal because it's the same thing the user
// would visually check.
async function detectPublishOutcome(submittedDialog, timeoutMs = 12000, textSnippet = '') {
  const start = Date.now();
  let rulesDialogHandled = false;
  const failureSignals = [
    'violated', 'community standards', 'try again', 'something went wrong',
    'מפר', 'תקני הקהילה', 'נסה שוב', 'משהו השתבש', 'הפוסט נדחה',
    'cannot post', 'unable to post', 'rate limit', 'too many',
  ];
  const pendingApprovalTexts = [
    'ממתין לאישור', 'ממתינ', 'ממתין לסקירה', 'ממתין לבדיקה',
    'הפוסט שלך ממתין', 'הופנה לאישור', 'הופנה לסקירה',
    'נשלח לאישור', 'נשלח לבדיקה', 'נשלח לסקירה',
    'מנהל יבחן', 'מנהל יסקור', 'יבחן על-ידי',
    'pending approval', 'pending review', 'sent for review', 'sent for approval',
    'awaiting approval', 'awaiting review', 'queued for review',
    'admin will review', 'will be reviewed', 'reviewed by',
  ];
  const successToastTexts = [
    'פוסט פורסם', 'הפוסט שלך', 'הפוסט נשלח', 'הופץ', 'פורסם בהצלחה', 'שותף',
    'shared to', 'posted to', 'your post', 'posted', 'shared with',
  ];

  let lastDialogText = '';
  while (Date.now() - start < timeoutMs) {
    // (Side channel) Try to capture the OUR-post URL whenever we find it.
  // Reused later for engagement tracking.

  // STRONGEST SIGNAL: our text is visible in the page feed → 100% published.
    // This is what a human would check — they'd scroll down and see the post.
    if (textSnippet && textSnippet.length >= 15) {
      const found = findPostInFeed(textSnippet);
      if (found) {
        if (found.pending) {
          return { success: true, signal: 'post-visible-pending-approval', detail: found.marker };
        }
        return { success: true, signal: 'post-visible-in-feed' };
      }
    }

    // SIGNAL: group rules consent dialog appeared — handle it transparently
    // and keep watching for the real outcome.
    if (!rulesDialogHandled) {
      const handled = await tryHandleGroupRulesDialog();
      if (handled) {
        rulesDialogHandled = true;
        // Extend the watch window since rules handling takes time
        timeoutMs = Math.max(timeoutMs, (Date.now() - start) + 15000);
        reportStep('info', '📜 Group rules dialog auto-handled — continuing to watch');
        await sleep(800);
        continue;
      }
    }

    // SIGNAL: explicit success toast → done
    const status = document.querySelector('[role="status"], [aria-live="polite"], [aria-live="assertive"]');
    if (status) {
      const txt = (status.innerText || '').toLowerCase();
      if (successToastTexts.some((p) => txt.includes(p.toLowerCase()))) {
        return { success: true, signal: 'toast' };
      }
      if (failureSignals.some((p) => txt.includes(p.toLowerCase()))) {
        return { success: false, signal: 'toast-failure: ' + txt.slice(0, 80) };
      }
    }

    // SIGNAL: pending-approval text — explicit success (just held by admin)
    const currentDialog = document.querySelector(SELECTORS.dialog);
    if (currentDialog) {
      const text = (currentDialog.innerText || '').toLowerCase();
      lastDialogText = text;
      if (pendingApprovalTexts.some((p) => text.includes(p.toLowerCase()))) {
        return { success: true, signal: 'pending-approval' };
      }
      if (failureSignals.some((p) => text.includes(p.toLowerCase()))) {
        return { success: false, signal: 'dialog-failure' };
      }
    }

    // SIGNAL: the SUBMITTED dialog is gone from DOM → success (even if a new
    // dialog opened — we'll check by reference, not by selector)
    if (submittedDialog && !document.body.contains(submittedDialog)) {
      return { success: true, signal: 'dialog-removed' };
    }

    // SIGNAL: composer trigger visible OUTSIDE any dialog → we're back on feed
    const opener = document.querySelector('[role="button"][aria-label*="כאן כותבים"]');
    if (opener && !opener.closest('[role="dialog"]')) {
      return { success: true, signal: 'composer-back' };
    }

    await sleep(350);
  }

  // Optimistic default: no failure marker seen during the watch window.
  // We log the last dialog text for future improvement of detection signals.
  const stillThere = submittedDialog && document.body.contains(submittedDialog);
  if (stillThere && lastDialogText.length > 20) {
    // Dialog still open but NO failure marker recognized. Treat as success
    // (probably a pending-approval state we don't yet recognize). Record the
    // text so we can extend our patterns later.
    reportStep('warn', `🔍 Dialog still open with text (probably pending approval): "${lastDialogText.slice(0, 200)}"`);
    return { success: true, signal: 'optimistic-dialog-open-but-no-failure' };
  }
  return { success: true, signal: 'optimistic-no-failure-seen' };
}

function randBetween(min, max) {
  return Math.floor(min + Math.random() * (max - min));
}

// ---------- Capture URL of OUR post after it lands in the feed ----------
// Returns the FB permalink URL for the matched post, or null.
function findPostUrlInFeed(snippet) {
  if (!snippet) return null;
  const needle = snippet.replace(/\s+/g, ' ').trim();
  if (needle.length < 15) return null;
  const articles = document.querySelectorAll('[role="article"]');
  for (const art of articles) {
    if (art.closest('[role="dialog"]')) continue;
    if ((art.innerText || '').replace(/\s+/g, ' ').includes(needle)) {
      // Look for a /posts/ or /permalink/ or /groups/<id>/posts/<post-id>/ link
      const links = art.querySelectorAll('a[href*="/groups/"][href*="/posts/"], a[href*="/permalink/"], a[href*="/share/"]');
      for (const a of links) {
        const href = a.getAttribute('href');
        if (href) {
          // Make absolute
          return href.startsWith('http') ? href : 'https://www.facebook.com' + href;
        }
      }
    }
  }
  return null;
}

// ---------- Scrape engagement for an already-published post ----------
// Called on the post permalink page. Returns engagement counts.
function scrapePostEngagement() {
  const out = {
    reactions: 0, likes: 0, loves: 0, hahas: 0, wows: 0, sads: 0, angrys: 0, cares: 0,
    comments: 0, shares: 0, views: null,
  };
  const pageText = document.body.innerText.slice(0, 5000);

  // Reactions — total count near the post header
  // "K reactions" / "K לייקים" / formatted numbers
  const reactionsRegex = /([\d,.]+\s*[KMאלףמיליון]*)\s*(?:reactions?|תגובות לפוסט|ריאקציות|לייקים)/i;
  const m = pageText.match(reactionsRegex);
  if (m) out.reactions = parseFbCount(m[1]) || 0;

  // Comments — "N comments" / "N תגובות"
  const commentsMatch = pageText.match(/([\d,.]+\s*[KMאלףמיליון]*)\s*(?:comments?|תגוב)/i);
  if (commentsMatch) out.comments = parseFbCount(commentsMatch[1]) || 0;

  // Shares — "N shares" / "N שיתופים"
  const sharesMatch = pageText.match(/([\d,.]+\s*[KMאלףמיליון]*)\s*(?:shares?|שיתופים|שיתופ)/i);
  if (sharesMatch) out.shares = parseFbCount(sharesMatch[1]) || 0;

  // Views (only sometimes visible) — "N views" / "N צפיות"
  const viewsMatch = pageText.match(/([\d,.]+\s*[KMאלףמיליון]*)\s*(?:views?|צפיות|צפו)/i);
  if (viewsMatch) out.views = parseFbCount(viewsMatch[1]);

  // Try to break down reactions by emoji via aria-labels
  const reactBtns = document.querySelectorAll('[aria-label*="ike" i], [aria-label*="ove" i], [aria-label*="aha" i], [aria-label*="לייק"], [aria-label*="אהבה"]');
  for (const btn of reactBtns) {
    const lbl = (btn.getAttribute('aria-label') || '').toLowerCase();
    const numMatch = lbl.match(/([\d,.]+\s*[km]?)/);
    if (!numMatch) continue;
    const n = parseFbCount(numMatch[1]) || 0;
    if (/like|לייק/.test(lbl) && !/dis/.test(lbl)) out.likes = Math.max(out.likes, n);
    else if (/love|אהב/.test(lbl)) out.loves = Math.max(out.loves, n);
    else if (/haha|מצחיק/.test(lbl)) out.hahas = Math.max(out.hahas, n);
    else if (/wow|וואו/.test(lbl)) out.wows = Math.max(out.wows, n);
    else if (/sad|עצוב/.test(lbl)) out.sads = Math.max(out.sads, n);
    else if (/angry|כעס/.test(lbl)) out.angrys = Math.max(out.angrys, n);
    else if (/care|חיבוק/.test(lbl)) out.cares = Math.max(out.cares, n);
  }

  return out;
}

// ---------- Find OUR post in the feed (success verification) ----------
// Returns:
//   null                                — post not found in feed
//   { pending: false }                  — post visible normally → published
//   { pending: true, marker: '...' }    — post visible with pending-approval marker
//
// When the snippet is found, we walk UP to the post card and look for any
// "pending approval" text nearby.
function findPostInFeed(snippet) {
  if (!snippet) return null;
  const needle = snippet.replace(/\s+/g, ' ').trim();
  if (needle.length < 15) return null;

  const pendingMarkers = [
    'ממתין לאישור', 'ממתין לסקירה', 'ממתין לבדיקה',
    'הופנה לאישור', 'הופנה לסקירה', 'מנהל יבחן',
    'pending approval', 'pending review', 'awaiting approval',
    'will be reviewed', 'sent for review',
  ];

  // Look in actual post articles or fall back to a broad text scan
  const articles = document.querySelectorAll('[role="article"], [data-ad-comet-preview]');
  const targets = articles.length ? articles : document.querySelectorAll('div, span');

  for (const el of targets) {
    if (el.closest('[role="dialog"]')) continue;
    const t = (el.innerText || '').replace(/\s+/g, ' ');
    if (!t || !t.includes(needle)) continue;

    // Found our text. Check the SAME container for a pending marker.
    // Walk up to find the post card (max 6 levels).
    let card = el;
    for (let d = 0; d < 6 && card.parentElement; d++) {
      const cardText = (card.innerText || '').toLowerCase();
      const marker = pendingMarkers.find((m) => cardText.includes(m.toLowerCase()));
      if (marker) return { pending: true, marker };
      card = card.parentElement;
    }
    // Found post but no pending marker → published normally
    return { pending: false };
  }
  return null;
}

// ---------- Group page metadata scraping ----------
// Best-effort RICH extraction from a /groups/<id>/ page.
// Returns: { memberCount, privacy, avatarUrl, coverUrl, description, rulesText,
//           adminCount, postsPerDay, language, location, restrictionLevel, topics, lastScrapedAt }
function scrapeGroupMetadata() {
  const meta = {
    memberCount: null, privacy: 'unknown', avatarUrl: null, coverUrl: null,
    description: null, rulesText: null, adminCount: null, postsPerDay: null,
    language: null, location: null, restrictionLevel: null, topics: [],
    lastScrapedAt: Date.now(),
  };
  const pageText = document.body.innerText.slice(0, 8000);
  const lower = pageText.toLowerCase();

  // === Member count ===
  const memberRegex = /([\d,.]+\s*(?:K|M|אלף|מיליון)?)\s*(?:members?|חברים)/i;
  for (const el of document.querySelectorAll('a, span, div')) {
    const txt = (el.innerText || '').trim();
    if (!txt || txt.length > 60) continue;
    const m = txt.match(memberRegex);
    if (m) { meta.memberCount = parseFbCount(m[1]); break; }
  }

  // === Privacy ===
  if (/public group|קבוצה ציבורית/i.test(pageText)) meta.privacy = 'public';
  else if (/private group|קבוצה פרטית/i.test(pageText)) meta.privacy = 'private';

  // === Avatar — best heuristic: largest <img> in the header area whose
  // src is an FB CDN URL, or the one linked to the group itself.
  // Strategy: find images inside the first H1/H2 area (group title)
  try {
    const heroImgs = document.querySelectorAll('header img, h1 img, [role="banner"] img, [data-pagelet*="GroupHeader"] img');
    let bestImg = null;
    let bestArea = 0;
    for (const img of heroImgs) {
      if (!img.src || !img.src.startsWith('http')) continue;
      const area = (img.naturalWidth || img.width || 0) * (img.naturalHeight || img.height || 0);
      if (area > bestArea && area > 100) {
        bestArea = area;
        bestImg = img;
      }
    }
    // Fallback: any image whose alt contains the group name
    if (!bestImg) {
      const titleEl = document.querySelector('h1, [role="banner"] h2');
      const groupName = (titleEl?.innerText || '').slice(0, 50);
      if (groupName) {
        const candidates = document.querySelectorAll('img');
        for (const img of candidates) {
          if ((img.alt || '').includes(groupName) && img.src?.startsWith('http')) {
            bestImg = img; break;
          }
        }
      }
    }
    if (bestImg) meta.avatarUrl = bestImg.src;
  } catch {}

  // === Cover photo — typically a large image, often the largest on page ===
  try {
    let bestCover = null;
    let bestArea = 0;
    for (const img of document.querySelectorAll('img')) {
      if (!img.src?.startsWith('http')) continue;
      const w = img.naturalWidth || img.width || 0;
      const h = img.naturalHeight || img.height || 0;
      const area = w * h;
      // Cover is wide and large
      if (area > bestArea && w > 600 && w / Math.max(h, 1) > 1.5) {
        bestArea = area;
        bestCover = img;
      }
    }
    if (bestCover) meta.coverUrl = bestCover.src;
  } catch {}

  // === Description / About — first ~300 chars of "About" section if visible ===
  // Heuristic: a paragraph with reasonable length near the top
  for (const el of document.querySelectorAll('p, div')) {
    const txt = (el.innerText || '').trim();
    if (txt.length > 50 && txt.length < 800 && /אודות|about|description/i.test(txt.slice(0, 50))) {
      meta.description = txt.slice(0, 500);
      break;
    }
  }

  // === Rules text — sometimes shown on group entry ===
  const rulesMatch = pageText.match(/(?:כללי הקבוצה|group rules|חוקי הקבוצה)[:\n]([^]{50,800})/i);
  if (rulesMatch) meta.rulesText = rulesMatch[1].trim().slice(0, 800);

  // === Admin count ===
  const adminMatch = pageText.match(/(\d+)\s*(?:מנהל|admin)/i);
  if (adminMatch) meta.adminCount = parseInt(adminMatch[1], 10);

  // === Posts per day ===
  const ppdMatch = pageText.match(/(\d+(?:\.\d+)?)\s*(?:פוסטים ביום|posts (?:per day|today))/i);
  if (ppdMatch) meta.postsPerDay = parseFloat(ppdMatch[1]);

  // === Language detection (basic) ===
  const hebrewChars = (pageText.match(/[֐-׿]/g) || []).length;
  const arabicChars = (pageText.match(/[؀-ۿ]/g) || []).length;
  if (hebrewChars > 100) meta.language = 'he';
  else if (arabicChars > 100) meta.language = 'ar';
  else meta.language = 'en';

  // === Location — city in Israel (common pattern) ===
  const cities = ['תל אביב','חיפה','ירושלים','באר שבע','אילת','נתניה','אשדוד','אשקלון','רעננה','הרצליה','פתח תקווה','רחובות','חולון','בת ים','כפר סבא','ראשון לציון','רמת גן','גבעתיים','מודיעין'];
  for (const city of cities) {
    if (pageText.includes(city)) { meta.location = city; break; }
  }

  // === Restriction level — computed locally ===
  // Will be refined by background after publish based on postPolicy/joinPolicy

  return meta;
}

// Convert FB-style counts like "8.7K", "1.2M", "אלף", "5,432" to a number.
function parseFbCount(s) {
  if (!s) return null;
  s = s.trim().replace(/,/g, '');
  if (/אלף/.test(s) || /K/i.test(s)) {
    const n = parseFloat(s);
    return isFinite(n) ? Math.round(n * 1000) : null;
  }
  if (/מיליון/.test(s) || /M/i.test(s)) {
    const n = parseFloat(s);
    return isFinite(n) ? Math.round(n * 1000000) : null;
  }
  const n = parseInt(s, 10);
  return isFinite(n) ? n : null;
}

function waitForDialogClose(timeout = 10000) {
  return new Promise((resolve, reject) => {
    const start = Date.now();
    const check = () => {
      if (!document.querySelector(SELECTORS.dialog)) return resolve();
      if (Date.now() - start > timeout) return reject(new Error('Dialog did not close'));
      setTimeout(check, 300);
    };
    check();
  });
}

// ---------- Group rules consent dialog ----------
// Some groups show a "Membership Check" / "בדיקת השתתפות" dialog after the
// user clicks Post. We need to: (a) tick the "I agree to the group rules"
// checkbox, (b) click the Send/שליחה button.
//
// Returns true if we successfully handled the dialog, false if it wasn't there.

const RULES_DIALOG_TITLES = [
  'בדיקת השתתפות',
  'membership check',
  'membership rules',
  'הצטרפות לקבוצה',
  'group rules',
  'כללי הקבוצה',
];

const RULES_AGREE_TEXTS = [
  'אני מסכים לכללי הקבוצה',
  'אני מסכים',
  'i agree to the group rules',
  'i agree',
  'אני מסכימה',
];

const RULES_SUBMIT_TEXTS = [
  'שליחה', 'שלח', 'submit', 'send', 'אישור', 'confirm', 'הסכמה', 'agree',
];

async function tryHandleGroupRulesDialog() {
  // Look for ALL dialogs (not just the original composer). The rules dialog
  // is usually a NEW [role="dialog"] that opens on top.
  const dialogs = document.querySelectorAll('[role="dialog"]');
  for (const dlg of dialogs) {
    const title = (dlg.innerText || '').slice(0, 200).toLowerCase();
    const looksLikeRules = RULES_DIALOG_TITLES.some((t) => title.includes(t.toLowerCase()));
    if (!looksLikeRules) continue;

    reportStep('info', '📜 Group rules dialog detected — handling consent...');

    // (a) Find and click the agreement checkbox.
    // Strategy 1: any input[type=checkbox] not yet checked
    // Strategy 2: element with role="checkbox" not checked
    // Strategy 3: clickable element near the agreement text
    let checkbox = null;
    const checks = dlg.querySelectorAll('input[type="checkbox"]:not(:checked), [role="checkbox"][aria-checked="false"]');
    if (checks.length) {
      checkbox = checks[0];
    } else {
      // Find text node containing "אני מסכים" and walk up to find a clickable element
      const all = dlg.querySelectorAll('div, span, label');
      for (const el of all) {
        const txt = (el.innerText || '').trim().toLowerCase();
        if (txt.length > 100 || txt.length < 5) continue;
        if (RULES_AGREE_TEXTS.some((p) => txt.includes(p.toLowerCase()))) {
          // Walk up to find a labelable element / clickable parent
          let target = el;
          for (let d = 0; d < 5 && target.parentElement; d++) {
            const inner = target.parentElement.querySelector('input[type="checkbox"], [role="checkbox"]');
            if (inner) { checkbox = inner; break; }
            target = target.parentElement;
          }
          if (checkbox) break;
          // Fallback: just click the text element itself (FB sometimes binds click to the label)
          checkbox = el;
          break;
        }
      }
    }

    if (checkbox) {
      // Real click for React
      checkbox.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
      checkbox.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
      checkbox.click();
      reportStep('info', '  ✓ Agreement checkbox clicked');
    } else {
      reportStep('warn', '  ⚠ Could not find agreement checkbox');
    }

    // (b) Wait briefly for "Send" button to become enabled, then click it.
    await sleep(600);
    let submit = null;
    const submitCandidates = dlg.querySelectorAll('[role="button"], button');
    for (const btn of submitCandidates) {
      const label = ((btn.getAttribute('aria-label') || '') + ' ' + (btn.innerText || '')).toLowerCase().trim();
      if (!label) continue;
      if (RULES_SUBMIT_TEXTS.some((s) => label.includes(s))) {
        submit = btn;
        break;
      }
    }
    if (!submit) {
      reportStep('warn', '  ⚠ Could not find submit button in rules dialog');
      return false;
    }

    // Poll for the submit button to become enabled (after checkbox)
    const submitStart = Date.now();
    while (submit.getAttribute('aria-disabled') === 'true' || submit.disabled) {
      if (Date.now() - submitStart > 5000) {
        reportStep('warn', '  ⚠ Submit button stayed disabled');
        return false;
      }
      await sleep(300);
    }

    submit.click();
    reportStep('info', '  ✓ Rules dialog Submit clicked');
    return true;
  }
  return false;
}

// ---------- Colored background ----------
// FB's composer has an "Aa" button (often a colored square) next to the
// editable. Clicking it expands a row of preset background colors/gradients.
// Click the Nth one to select it.
//
// Selectors are heuristic — FB changes them. If we can't find the toggle
// we throw, and publishPost falls back to plain text.

async function enableColoredBackground(colorIndex) {
  // Possible labels for the toggle button
  const toggleSelectors = [
    '[role="dialog"] [aria-label="Aa"]',
    '[role="dialog"] [aria-label="ערכת צבעים"]',
    '[role="dialog"] [aria-label="ערכות צבעים"]',
    '[role="dialog"] [aria-label*="background" i]',
    '[role="dialog"] [aria-label*="רקע"]',
  ];
  let toggle = querySelectorAny(toggleSelectors);
  if (!toggle) {
    // Fallback: look for a square button with the literal text "Aa"
    const candidates = document.querySelectorAll('[role="dialog"] [role="button"]');
    for (const el of candidates) {
      const t = (el.innerText || '').trim();
      if (t === 'Aa' || t === 'אא') { toggle = el; break; }
    }
  }
  if (!toggle) throw new Error('color-toggle-not-found');

  toggle.click();
  await sleep(600);

  // After clicking, a palette appears. We pick the (colorIndex)th option.
  // The palette items are usually `[role="button"]` with `aria-label` ending
  // in a color name OR the toggle's siblings.
  const palette = Array.from(document.querySelectorAll('[role="dialog"] [role="button"]'))
    .filter((el) => {
      const lbl = (el.getAttribute('aria-label') || '').toLowerCase();
      // Skip the toggle itself + common non-color buttons
      if (lbl.includes('aa') || lbl.includes('post') || lbl.includes('emoji') ||
          lbl.includes('cancel') || lbl.includes('פרסום') || lbl.includes('ביטול')) return false;
      // Color buttons usually have either color words or numeric aria-label
      return lbl.length < 30; // simple heuristic: short labels
    });

  if (!palette.length) throw new Error('palette-not-visible');
  const target = palette[Math.min(colorIndex, palette.length - 1)];
  target.click();
  await sleep(400);
}

// ---------- Poll ----------
// Click "Add to your post" expand button → click "Poll" → fill question + options.

async function fillPoll(question, options, throttle = {}) {
  // Open the "..." or "Add to your post" expand menu
  const expandSel = [
    '[role="dialog"] [aria-label="הוסיפו לפוסט שלכם"]',
    '[role="dialog"] [aria-label="Add to your post"]',
    '[role="dialog"] [aria-label*="Add to" i]',
    '[role="dialog"] [aria-label*="הוספ"]',
  ];
  const expand = querySelectorAny(expandSel);
  if (expand) {
    expand.click();
    await sleep(700);
  }

  // Click "Poll" menu item
  const pollMenuSel = [
    '[aria-label="סקר"]', '[aria-label="Poll"]',
    '[role="menuitem"][aria-label*="oll" i]',
    '[role="menuitem"][aria-label*="סקר"]',
  ];
  const pollMenu = await waitForOneOf(pollMenuSel, 4000);
  if (!pollMenu) throw new Error('poll-menu-not-found');
  pollMenu.click();
  await sleep(1000);

  // Find poll question input (typically a textbox with placeholder)
  const questionInput = await waitForOneOf([
    '[role="dialog"] [aria-label*="שאל" i]',
    '[role="dialog"] [aria-label*="question" i]',
    '[role="dialog"] [contenteditable="true"][aria-placeholder*="שאל"]',
    '[role="dialog"] textarea',
  ], 6000);
  if (!questionInput) throw new Error('poll-question-input-not-found');
  questionInput.focus();
  await typeIntoEditable(questionInput, question, throttle);
  await sleep(500);

  // Fill option fields — typically a list of textboxes/inputs
  const inputs = Array.from(document.querySelectorAll(
    '[role="dialog"] input[type="text"], [role="dialog"] [contenteditable="true"]'
  )).filter((el) => el !== questionInput);

  for (let i = 0; i < options.length; i++) {
    let target = inputs[i];
    if (!target) {
      // Need to click "Add option" to reveal more inputs
      const addBtn = querySelectorAny([
        '[aria-label*="dd option" i]', '[aria-label*="הוסף אפש"]', '[aria-label*="עוד אפש"]',
      ]);
      if (addBtn) { addBtn.click(); await sleep(400); }
      target = document.querySelectorAll('[role="dialog"] input[type="text"], [role="dialog"] [contenteditable="true"]')[i + 1];
    }
    if (!target) continue;
    target.focus();
    await typeIntoEditable(target, options[i], throttle);
    await sleep(300);
  }
}

async function waitForOneOf(selectors, timeoutMs) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const el = querySelectorAny(selectors);
    if (el) return el;
    await sleep(250);
  }
  return null;
}

// ---------- GIF ----------

async function pickGif(query) {
  const gifBtnSel = [
    '[role="dialog"] [aria-label="GIF"]',
    '[role="dialog"] [aria-label*="GIF" i]',
  ];
  const gifBtn = querySelectorAny(gifBtnSel);
  if (!gifBtn) throw new Error('gif-button-not-found');
  gifBtn.click();
  await sleep(1500);

  const searchInput = await waitForOneOf([
    '[role="dialog"] [aria-label*="earch" i]',
    '[role="dialog"] [aria-label*="חפש"]',
    '[role="dialog"] input[placeholder*="GIF" i]',
    '[role="dialog"] input[type="search"]',
  ], 5000);
  if (!searchInput) throw new Error('gif-search-input-not-found');
  searchInput.focus();
  searchInput.value = query;
  searchInput.dispatchEvent(new InputEvent('input', { bubbles: true, data: query }));
  await sleep(2000); // wait for results

  // Pick the first result — typically an image or button inside a grid
  const result = querySelectorAny([
    '[role="dialog"] [role="button"] img',
    '[role="dialog"] img[src*=".gif" i]',
    '[role="dialog"] [role="button"][aria-label*="GIF" i]',
  ]);
  if (!result) throw new Error('gif-results-not-found');
  // Click parent button if it's an image
  (result.closest('[role="button"]') || result).click();
  await sleep(1500);
}

// ---------- Feeling / Activity ----------

async function pickFeeling(feelingName) {
  const feelingBtnSel = [
    '[role="dialog"] [aria-label="רגש/פעילות"]',
    '[role="dialog"] [aria-label*="eeling" i]',
    '[role="dialog"] [aria-label*="ctivity" i]',
    '[role="dialog"] [aria-label*="רגש"]',
  ];
  const btn = querySelectorAny(feelingBtnSel);
  if (!btn) throw new Error('feeling-button-not-found');
  btn.click();
  await sleep(1000);

  // Click "Feeling" tab if Activity is selected
  const feelingTab = querySelectorAny([
    '[role="tab"][aria-label*="eeling" i]',
    '[role="tab"][aria-label*="רגש"]',
  ]);
  if (feelingTab) { feelingTab.click(); await sleep(600); }

  // Find the option matching the requested feeling. Match on aria-label or text.
  const candidates = document.querySelectorAll('[role="dialog"] [role="button"], [role="dialog"] [role="option"]');
  let chosen = null;
  for (const el of candidates) {
    const lbl = ((el.getAttribute('aria-label') || el.innerText) || '').toLowerCase();
    if (lbl.includes(feelingName.toLowerCase())) { chosen = el; break; }
  }
  if (!chosen) throw new Error(`feeling "${feelingName}" not found in picker`);
  chosen.click();
  await sleep(800);
}

// ---------- Media injection ----------

async function attachMediaToComposer(mediaList) {
  const files = mediaList.map((m) => {
    const bytes = new Uint8Array(m.buffer);
    return new File([bytes], m.name, { type: m.mimeType });
  });

  let input = document.querySelector(SELECTORS.fileInput);
  if (!input) {
    const photoBtn = querySelectorAny(SELECTORS.addPhotoBtn);
    if (photoBtn) {
      photoBtn.click();
      try { input = await waitFor(SELECTORS.fileInput, { timeout: 4000 }); }
      catch { /* fall through */ }
    }
  }
  if (!input) throw new Error('Composer file input not found');

  const dt = new DataTransfer();
  for (const f of files) dt.items.add(f);
  input.files = dt.files;
  input.dispatchEvent(new Event('change', { bubbles: true }));
  input.dispatchEvent(new Event('input', { bubbles: true }));

  await waitForUploadComplete(30000);
}

async function waitForUploadComplete(timeoutMs) {
  const start = Date.now();
  return new Promise((resolve) => {
    const check = () => {
      const submit = querySelectorAny(SELECTORS.submit);
      const ready = submit && submit.getAttribute('aria-disabled') !== 'true';
      if (ready) return resolve();
      if (Date.now() - start > timeoutMs) return resolve();
      setTimeout(check, 600);
    };
    check();
  });
}

// ---------- Comments ----------
// Posts a comment on the currently-loaded FB post page.
// Returns { ok, error? }.
async function postCommentOnCurrentPage(text, throttle) {
  if (!text || text.length < 1) return { ok: false, error: 'empty text' };

  reportStep('info', `💬 Adding comment (${text.length} chars)`);
  await sleep(1500);

  // Find the comment composer — typically a contenteditable with role=textbox
  // and aria-label containing "comment"/"תגובה"
  const composerSelectors = [
    '[role="textbox"][contenteditable="true"][aria-label*="omment" i]',
    '[role="textbox"][contenteditable="true"][aria-label*="תגוב"]',
    '[contenteditable="true"][aria-label*="omment" i]',
    '[contenteditable="true"][aria-label*="תגוב"]',
    'form [contenteditable="true"]',
  ];
  let composer = querySelectorAny(composerSelectors);
  if (!composer) {
    // Scroll to bottom — comment box might be off-screen
    window.scrollTo(0, document.body.scrollHeight);
    await sleep(1500);
    composer = querySelectorAny(composerSelectors);
  }
  if (!composer) return { ok: false, error: 'comment composer not found' };

  reportStep('info', '💬 Composer found, typing...');
  composer.focus();
  composer.click();
  await sleep(400 + Math.random() * 800);

  // Type char-by-char with humanlike delays
  for (const ch of text) {
    dispatchKeyChar(composer, ch);
    await sleep(40 + Math.random() * 100);
  }
  await sleep(800);

  // Press Enter to submit — FB submits comments on Enter by default
  composer.dispatchEvent(new KeyboardEvent('keydown', {
    key: 'Enter', code: 'Enter', keyCode: 13, which: 13,
    bubbles: true, cancelable: true, composed: true,
  }));
  composer.dispatchEvent(new KeyboardEvent('keyup', {
    key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true,
  }));

  await sleep(2500);

  // Check for FB warning
  const warn = detectFbWarning();
  if (warn) {
    reportStep('error', `⚠ FB warning after comment: ${warn.kind}`);
    return { ok: false, error: `fb-warning: ${warn.kind}`, warning: warn };
  }

  reportStep('info', '💬 ✅ Comment submitted');
  return { ok: true };
}

// Scrape recent replies/comments on the currently loaded post.
function scrapeRepliesOnCurrentPost() {
  const replies = [];
  // Common pattern: <ul> with <li role="article"> per comment
  const items = document.querySelectorAll('div[role="article"], li[role="article"]');
  for (const el of items) {
    const text = (el.innerText || '').trim();
    if (!text || text.length < 5) continue;
    // First strong text is usually the author name
    const authorEl = el.querySelector('strong, a strong, h3 a');
    const author = authorEl ? authorEl.innerText.trim() : null;
    if (!author) continue;
    // Strip author from text for the comment body
    const body = text.split('\n').slice(1).join(' ').trim().slice(0, 500);
    if (body) replies.push({ author, text: body });
    if (replies.length >= 50) break;
  }
  return replies;
}

// ---------- Wave 3: Admin tools ----------

// Approve all pending posts in a group (where user is admin).
// Expects SW to navigate to /groups/<id>/pending_posts
async function adminApprovePending({ max = 10 } = {}) {
  reportStep('info', `👮 Admin: approving up to ${max} pending posts`);
  let approved = 0;
  // Find Approve buttons — usually multiple in the pending queue
  const approveBtns = document.querySelectorAll(
    '[aria-label="Approve"], [aria-label="אישור"], button[aria-label*="pprove" i]'
  );
  for (const btn of approveBtns) {
    if (approved >= max) break;
    if (btn.disabled) continue;
    btn.click();
    await sleep(2000 + Math.random() * 2000);
    approved++;
  }
  return { ok: true, approved };
}

// Scrape Page Insights — visible numbers from the Insights page.
async function adminScrapeInsights() {
  reportStep('info', '📊 Scraping Page Insights');
  const data = {};
  const text = document.body.innerText.slice(0, 5000);
  // Common metric patterns
  const patterns = {
    reach: /reach[:\s]+([\d,.]+\s*[KMכמ]?)/i,
    followers: /followers[:\s]+([\d,.]+\s*[KMכמ]?)/i,
    engagement: /engagement[:\s]+([\d,.]+\s*[KMכמ]?)/i,
    impressions: /impressions[:\s]+([\d,.]+\s*[KMכמ]?)/i,
  };
  for (const [k, re] of Object.entries(patterns)) {
    const m = text.match(re);
    if (m) data[k] = m[1].trim();
  }
  return { ok: true, data };
}

// Cleanup: delete OUR own posts older than X days from a profile/page.
// SW must navigate to the user's posts page (/me/?sk=allactivity or similar).
async function cleanupOldPosts({ olderThanDays = 365, max = 5 } = {}) {
  reportStep('info', `🧹 Cleanup: removing up to ${max} posts older than ${olderThanDays}d`);
  let removed = 0;
  // Iterate through visible posts, click "..." menu, then "Move to trash" / "Delete"
  const posts = document.querySelectorAll('[role="article"]');
  for (const post of posts) {
    if (removed >= max) break;
    const moreBtn = post.querySelector('[aria-label*="More" i], [aria-label*="עוד"]');
    if (!moreBtn) continue;
    moreBtn.click();
    await sleep(1500);
    const items = document.querySelectorAll('[role="menuitem"]');
    let deleteBtn = null;
    for (const it of items) {
      if (/move to trash|delete|מחיקה|העברה לאשפה/i.test(it.innerText || '')) {
        deleteBtn = it; break;
      }
    }
    if (!deleteBtn) continue;
    deleteBtn.click();
    await sleep(2000);
    // Confirm dialog
    const confirm = querySelectorAny(['[role="dialog"] [aria-label*="confirm" i]', '[role="dialog"] [role="button"]:last-child']);
    if (confirm) confirm.click();
    await sleep(3000 + Math.random() * 2000);
    removed++;
  }
  return { ok: true, removed };
}

// Bulk-leave: leave selected groups (SW provides the list of URLs).
// We expect SW to navigate to each group URL one by one and call this.
async function bulkLeaveGroups({ max = 1 } = {}) {
  reportStep('info', '🚪 Leaving current group');
  const moreBtn = querySelectorAny(['[aria-label*="More" i]', '[aria-label*="עוד פעולות"]']);
  if (!moreBtn) return { ok: false, error: 'menu not found' };
  moreBtn.click();
  await sleep(1500);
  const items = document.querySelectorAll('[role="menuitem"]');
  for (const it of items) {
    if (/leave group|עזיבת קבוצה/i.test(it.innerText || '')) {
      it.click();
      await sleep(2000);
      const confirm = querySelectorAny([
        '[role="dialog"] [aria-label*="leave" i]', '[role="dialog"] [aria-label*="עזיבה"]',
      ]);
      if (confirm) confirm.click();
      await sleep(2500);
      return { ok: true };
    }
  }
  return { ok: false, error: 'leave option not in menu' };
}

// ---------- Wave 2: Humanize the account ----------
// These run on the SW's humanize alarm. Each is intentionally small-batch
// to look natural — no aggressive bulk actions.

// 🎂 Wish friends a happy birthday (max N per run)
async function wishHappyBirthdays({ max = 2, defaultMessage = 'יום הולדת שמח! 🎉' } = {}) {
  reportStep('info', `🎂 Birthdays — looking for up to ${max} to wish`);
  // Navigate-time: expect /events/birthdays/ to be loaded by SW
  // Find each birthday card with a "Write a message" composer
  const cards = document.querySelectorAll('[role="article"], [data-pagelet*="Birthday"]');
  let count = 0;
  for (const card of cards) {
    if (count >= max) break;
    const editable = card.querySelector('[contenteditable="true"]');
    if (!editable) continue;
    editable.focus();
    await sleep(800 + Math.random() * 1500);
    for (const ch of defaultMessage) {
      dispatchKeyChar(editable, ch);
      await sleep(60 + Math.random() * 120);
    }
    await sleep(500);
    // Submit via Enter
    editable.dispatchEvent(new KeyboardEvent('keydown', {
      key: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true,
    }));
    await sleep(2500 + Math.random() * 2500);
    count++;
    reportStep('info', `  ✓ Wished ${count}/${max}`);
  }
  return { ok: true, count };
}

// 👁 Watch a few Stories — pure passive engagement signal
async function watchFeedStories({ count = 3 } = {}) {
  reportStep('info', `👁 Watching up to ${count} Stories`);
  // Stories are rendered as a horizontal tray near top of feed
  const stories = document.querySelectorAll('[aria-label*="story" i] a, [data-pagelet*="Stories"] a, a[href*="/stories/"]');
  if (!stories.length) {
    reportStep('warn', 'No stories tray detected');
    return { ok: false, error: 'no stories' };
  }
  let watched = 0;
  for (let i = 0; i < Math.min(count, stories.length); i++) {
    stories[i].click();
    await sleep(4000 + Math.random() * 5000); // dwell on each story
    // Press Esc to close
    document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }));
    await sleep(1500);
    watched++;
  }
  return { ok: true, count: watched };
}

// 📅 RSVP "Interested" to relevant events
async function rsvpRecommendedEvents({ max = 2 } = {}) {
  reportStep('info', `📅 RSVP to up to ${max} events`);
  // On /events/ — look for "Interested" buttons
  const buttons = document.querySelectorAll('[aria-label*="nterested" i], [aria-label*="מעוניין"]');
  let rsvped = 0;
  for (const btn of buttons) {
    if (rsvped >= max) break;
    if (btn.getAttribute('aria-pressed') === 'true') continue; // already
    btn.click();
    await sleep(2000 + Math.random() * 2000);
    rsvped++;
  }
  return { ok: true, count: rsvped };
}

// ❤ Varied reactions (Love/Haha/Wow not just Like) on visible feed
async function variedReactionsOnFeed({ count = 3 } = {}) {
  reportStep('info', `❤ Variety reactions on ${count} posts`);
  const reactions = ['Like', 'Love', 'Care', 'Haha', 'Wow'];
  const posts = document.querySelectorAll('[role="article"]');
  let reacted = 0;
  for (const post of posts) {
    if (reacted >= count) break;
    const likeBtn = post.querySelector('[aria-label="Like"], [aria-label="לייק"]');
    if (!likeBtn) continue;
    if (likeBtn.getAttribute('aria-pressed') === 'true') continue;
    // Hover to reveal reaction tray
    likeBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
    await sleep(1500 + Math.random() * 1000);
    const trayBtn = post.querySelector(`[aria-label="${reactions[Math.floor(Math.random() * reactions.length)]}"]`);
    (trayBtn || likeBtn).click();
    await sleep(2000 + Math.random() * 2000);
    reacted++;
  }
  return { ok: true, count: reacted };
}

// ---------- Pin post (on profile/page) ----------
// Loads the post URL, clicks the "..." menu, then "Pin to profile" / "Pin post"
async function pinCurrentPost() {
  reportStep('info', `📌 Pin attempt on ${location.pathname}`);
  await sleep(2000);
  // The "..." menu button on the post
  const moreBtn = querySelectorAny([
    '[aria-label="More" i]', '[aria-label*="עוד אפשרויות"]',
    '[role="button"][aria-label*="ctions for this post" i]',
  ]);
  if (!moreBtn) return { ok: false, error: 'menu button not found' };
  moreBtn.click();
  await sleep(1200);
  // Look for "Pin to profile" / "Pin post" / "הצמדה לפרופיל"
  const items = document.querySelectorAll('[role="menuitem"], [role="button"]');
  let pinItem = null;
  for (const it of items) {
    const t = (it.innerText || '').trim().toLowerCase();
    if (/pin to|pin post|הצמד/.test(t)) { pinItem = it; break; }
  }
  if (!pinItem) return { ok: false, error: 'Pin option not found' };
  pinItem.click();
  await sleep(2000);
  reportStep('info', '📌 ✅ Pin clicked');
  return { ok: true };
}

// ---------- Generic channel publishing ----------
// Handles: profile / page / story / reel / event / share / album
// Each has slightly different UX but the broad pattern is:
//   1. Find/open composer for this surface
//   2. Fill the form (text/media/event-fields)
//   3. Click Publish
//   4. Detect success
async function publishChannel({ channel, title, text, media = [], eventData = null, throttle = {} }) {
  reportStep('info', `📡 Channel publish flow: ${channel}`);
  await sleep(2000);

  if (channel === 'event') {
    return await publishEvent(eventData, text, media);
  }
  if (channel === 'share') {
    return await publishShare();
  }
  if (channel === 'story' || channel === 'reel') {
    return await publishStoryOrReel(channel, text, media);
  }

  // profile / page / album → use the standard composer (looks like a group post)
  return await publishStandardComposer(text, media, throttle, channel);
}

// Publish via the normal composer (profile/page wall)
async function publishStandardComposer(text, media, throttle, channel) {
  // Reuse the existing publishPost flow's logic with adapted selectors.
  // The composer trigger on a profile/page wall is similar to a group:
  // "What's on your mind" / "כאן כותבים..."
  try {
    let opener = querySelectorAny(SELECTORS.composerOpen) || findComposerByText();
    if (!opener) {
      try { opener = await waitFor(SELECTORS.composerOpen, { timeout: 10000 }); } catch {}
    }
    if (!opener) return { ok: false, error: `${channel}: composer trigger not found` };
    await sleep(800 + Math.random() * 1500);
    opener.click();
    await waitFor(SELECTORS.dialog, { timeout: 10000 });
    const editable = await waitFor(SELECTORS.editable, { timeout: 8000 });
    await sleep(600);
    await typeIntoEditable(editable, text, throttle);

    if (media.length) {
      try { await attachMediaToComposer(media); }
      catch (e) { reportStep('warn', `📡 Media attach failed: ${e.message}`); }
    }
    await sleep(1500);
    const submit = querySelectorAny(SELECTORS.submit);
    if (!submit) return { ok: false, error: 'submit button not found' };
    const enableStart = Date.now();
    while (submit.getAttribute('aria-disabled') === 'true') {
      if (Date.now() - enableStart > 10000) return { ok: false, error: 'submit disabled' };
      await sleep(300);
    }
    submit.click();
    const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 40);
    const outcome = await detectPublishOutcome(document.querySelector(SELECTORS.dialog), 15000, snippet);
    if (outcome.success) return { ok: true, signal: outcome.signal, url: location.href };
    return { ok: false, error: outcome.signal };
  } catch (e) {
    return { ok: false, error: String(e.message || e) };
  }
}

// Story / Reel — needs media (photo/video). Different composer.
async function publishStoryOrReel(kind, text, media) {
  reportStep('info', `📡 ${kind} flow`);
  if (!media.length) return { ok: false, error: `${kind} requires at least one media file` };

  // Upload media (these surfaces have prominent file inputs)
  const fileInput = await waitFor('input[type="file"]', { timeout: 8000 }).catch(() => null);
  if (!fileInput) return { ok: false, error: 'file input not found' };
  const files = media.map((m) => new File([new Uint8Array(m.buffer)], m.name, { type: m.mimeType }));
  const dt = new DataTransfer();
  for (const f of files) dt.items.add(f);
  fileInput.files = dt.files;
  fileInput.dispatchEvent(new Event('change', { bubbles: true }));
  await sleep(4000);

  // Add caption if Story (Reels has its own UI)
  if (text && kind === 'story') {
    const textOverlay = document.querySelector('[aria-label*="ext" i]') ||
                        document.querySelector('[contenteditable="true"]');
    if (textOverlay) {
      textOverlay.focus();
      for (const ch of text.slice(0, 200)) {
        dispatchKeyChar(textOverlay, ch);
        await sleep(40);
      }
    }
  }
  await sleep(2000);

  // Click publish/share
  const publishBtn = querySelectorAny([
    '[aria-label="פרסום"]', '[aria-label="Publish"]',
    '[aria-label="שתפו ב-Story"]', '[aria-label="שיתוף"]', '[aria-label="Share"]',
    '[aria-label="פרסם"]',
  ]);
  if (!publishBtn) return { ok: false, error: 'publish button not found' };
  publishBtn.click();
  await sleep(5000);
  return { ok: true, signal: 'channel-published', url: location.href };
}

// Event — fills the create-event form
async function publishEvent(eventData, description, media) {
  if (!eventData?.name || !eventData?.when) {
    return { ok: false, error: 'event needs name + when' };
  }
  reportStep('info', `📡 Event: ${eventData.name}`);
  await sleep(2000);

  const fields = [
    { sel: 'input[aria-label*="vent name" i], input[aria-label*="שם האירוע"]', value: eventData.name },
    { sel: 'input[aria-label*="ocation" i], input[aria-label*="מיקום"]', value: eventData.location || '' },
    { sel: 'textarea[aria-label*="escription" i], textarea[aria-label*="תיאור"]', value: description || '' },
  ];

  for (const f of fields) {
    if (!f.value) continue;
    const el = document.querySelector(f.sel);
    if (!el) continue;
    el.focus(); el.click();
    await sleep(300);
    el.value = '';
    el.dispatchEvent(new Event('input', { bubbles: true }));
    for (const ch of f.value) {
      dispatchKeyChar(el, ch);
      await sleep(40);
    }
    el.dispatchEvent(new Event('change', { bubbles: true }));
    await sleep(500);
  }

  // Set date — FB uses datetime input or a custom picker
  const whenInput = document.querySelector('input[type="datetime-local"], input[aria-label*="ate" i]');
  if (whenInput) {
    const d = new Date(eventData.when);
    const formatted = d.toISOString().slice(0, 16);
    whenInput.value = formatted;
    whenInput.dispatchEvent(new Event('input', { bubbles: true }));
    whenInput.dispatchEvent(new Event('change', { bubbles: true }));
  }

  await sleep(1500);
  const createBtn = querySelectorAny([
    '[aria-label="Create event"]', '[aria-label="יצירת אירוע"]', '[aria-label="צרו אירוע"]',
    '[aria-label="Create"]', '[aria-label="צור"]',
  ]);
  if (!createBtn) return { ok: false, error: 'create button not found' };
  createBtn.click();
  await sleep(4000);
  return { ok: true, signal: 'event-created', url: location.href };
}

// Share existing post — assumes we navigated to the post URL
async function publishShare() {
  await sleep(2000);
  const shareBtn = querySelectorAny([
    '[aria-label*="hare" i]', '[aria-label*="שית"]', '[aria-label="שיתוף"]',
  ]);
  if (!shareBtn) return { ok: false, error: 'share button not found' };
  shareBtn.click();
  await sleep(1500);

  // Pick "Share now" / "Share to feed"
  const shareNow = querySelectorAny([
    '[aria-label*="hare now" i]', '[aria-label*="שתפו עכשיו"]', '[aria-label*="פיד"]',
  ]);
  if (shareNow) shareNow.click();
  await sleep(3000);
  return { ok: true, signal: 'shared', url: location.href };
}

// ---------- Marketplace publish ----------
// Fills the Marketplace creation form. FB's marketplace composer is structured
// (separate fields for title, price, description, etc.) — very different from
// the group composer.
//
// Selectors are heuristic and will need tuning per category and per FB version.
const MP_SELECTORS = {
  title:       'input[aria-label*="title" i], input[aria-label*="כותרת"], input[aria-label*="שם"]',
  price:       'input[aria-label*="price" i], input[aria-label*="מחיר"]',
  description: 'textarea[aria-label*="description" i], textarea[aria-label*="תיאור"]',
  location:    'input[aria-label*="location" i], input[aria-label*="מיקום"]',
  fileInput:   'input[type="file"][accept*="image"]',
  // The next/publish button — usually "Next" then "Publish" / "פרסם"
  nextBtn: [
    '[aria-label="Next"]', '[aria-label="הבא"]',
  ],
  publishBtn: [
    '[aria-label="Publish"]', '[aria-label="פרסם"]', '[aria-label="פרסום"]',
  ],
};

async function publishMarketplace({ category, title, text, media = [], data = {}, throttle = {} }) {
  reportStep('info', `🛒 Marketplace publish flow (${category})`);

  // Give the form a moment to mount its fields
  await sleep(2000);

  // 1) Upload media FIRST — FB sometimes won't reveal other fields until photos exist
  if (media.length) {
    reportStep('info', `🛒 Uploading ${media.length} photo(s)`);
    try {
      const fileInput = document.querySelector(MP_SELECTORS.fileInput);
      if (fileInput) {
        const files = media.map((m) => {
          const bytes = new Uint8Array(m.buffer);
          return new File([bytes], m.name, { type: m.mimeType });
        });
        const dt = new DataTransfer();
        for (const f of files) dt.items.add(f);
        fileInput.files = dt.files;
        fileInput.dispatchEvent(new Event('change', { bubbles: true }));
        await sleep(3000); // wait for uploads to start
      } else {
        reportStep('warn', '🛒 No file input found yet');
      }
    } catch (e) {
      reportStep('warn', `🛒 Photo upload failed: ${e.message}`);
    }
  }

  // 2) Fill text fields with humanlike typing
  const fields = [];
  if (title) fields.push({ sel: MP_SELECTORS.title, value: title });
  if (data.price) fields.push({ sel: MP_SELECTORS.price, value: String(data.price) });
  if (text) fields.push({ sel: MP_SELECTORS.description, value: text });
  if (data.location || data.address) fields.push({ sel: MP_SELECTORS.location, value: data.location || data.address });

  for (const f of fields) {
    const el = document.querySelector(f.sel);
    if (!el) {
      reportStep('warn', `🛒 Field not found: ${f.sel.slice(0, 50)}`);
      continue;
    }
    reportStep('info', `🛒 Typing into ${f.sel.slice(0, 30)}...`);
    el.focus();
    el.click();
    await sleep(300 + Math.random() * 500);
    // Clear existing
    el.value = '';
    el.dispatchEvent(new Event('input', { bubbles: true }));
    await sleep(200);
    // Type char-by-char
    for (const ch of f.value) {
      dispatchKeyChar(el, ch);
      await sleep(30 + Math.random() * 90);
    }
    el.dispatchEvent(new Event('change', { bubbles: true }));
    await sleep(500);
  }

  // 3) Handle category-specific dropdowns / fields (best-effort)
  await fillCategorySpecificFields(category, data);

  // 4) Click "Next" if exists (Marketplace often has a 2-step flow)
  await sleep(1500);
  const nextBtn = querySelectorAny(MP_SELECTORS.nextBtn);
  if (nextBtn && nextBtn.getAttribute('aria-disabled') !== 'true') {
    reportStep('info', '🛒 Clicking Next');
    nextBtn.click();
    await sleep(2500);
  }

  // 5) Click Publish
  const publishBtn = querySelectorAny(MP_SELECTORS.publishBtn);
  if (!publishBtn) {
    reportStep('error', '🛒 Publish button not found');
    return { ok: false, error: 'publish button not found' };
  }
  if (publishBtn.getAttribute('aria-disabled') === 'true') {
    reportStep('error', '🛒 Publish button disabled — required fields missing?');
    return { ok: false, error: 'publish button disabled' };
  }
  await sleep(800 + Math.random() * 1500);
  publishBtn.click();

  // 6) Wait for URL change (Marketplace navigates to the listing after success)
  const startUrl = location.href;
  for (let i = 0; i < 30; i++) {
    await sleep(500);
    if (location.href !== startUrl) {
      reportStep('info', `🛒 ✅ Published — URL: ${location.href}`);
      return { ok: true, url: location.href, signal: 'navigated-to-listing' };
    }
  }
  reportStep('warn', '🛒 No navigation detected, but no error either — treating as success');
  return { ok: true, url: null, signal: 'optimistic-no-failure' };
}

// Category-specific fields (year/make/model for vehicles, bedrooms/sqft for property)
async function fillCategorySpecificFields(category, data) {
  // Try to find combobox/select inputs by their adjacent label text.
  // This is fragile — we do best-effort, log when fields can't be found.
  const candidates = document.querySelectorAll('label, [role="combobox"], select');
  function findByLabel(text) {
    for (const c of candidates) {
      if ((c.innerText || '').toLowerCase().includes(text.toLowerCase())) {
        return c.querySelector('input, select') || c.nextElementSibling;
      }
    }
    return null;
  }

  if (category === 'vehicle') {
    if (data.year) await typeInto(findByLabel('year'), String(data.year));
    if (data.make) await typeInto(findByLabel('make'), data.make);
    if (data.model) await typeInto(findByLabel('model'), data.model);
    if (data.mileage) await typeInto(findByLabel('mileage'), String(data.mileage));
  } else if (category === 'property-sale' || category === 'property-rent') {
    if (data.bedrooms) await typeInto(findByLabel('bedroom'), String(data.bedrooms));
    if (data.sqft) await typeInto(findByLabel('square'), String(data.sqft));
  }
}

async function typeInto(el, value) {
  if (!el || !value) return;
  el.focus();
  el.click();
  await sleep(200);
  el.value = '';
  el.dispatchEvent(new Event('input', { bubbles: true }));
  for (const ch of value) {
    dispatchKeyChar(el, ch);
    await sleep(40);
  }
  el.dispatchEvent(new Event('change', { bubbles: true }));
}

// ---------- Auto-join group ----------
// Locates the Join button on the group page, clicks it, then watches the
// outcome to determine whether membership is instant or requires approval.

const JOIN_SELECTORS = {
  // The big "Join group" button on the group's cover area.
  joinBtn: [
    '[aria-label="הצטרפות לקבוצה"]',
    '[aria-label="הצטרף/י לקבוצה"]',
    '[aria-label*="Join group" i]',
    '[aria-label*="Join Group" i]',
    'div[role="button"][aria-label*="Join"]',
  ],
};

async function joinCurrentGroup() {
  reportStep('info', `📍 Join attempt on ${location.pathname}`);

  // First — check for any FB block/warning. If we see one, abort immediately
  // so the SW can back off.
  const warn = detectFbWarning();
  if (warn) {
    reportStep('error', `⚠ FB warning detected before join: ${warn.kind} — "${warn.message}"`);
    return { ok: false, warning: warn };
  }

  // Already a member? Look for "Joined" / "חבר/ה" labels.
  const existingMember = document.querySelector(
    '[aria-label="חבר/ה"], [aria-label="Joined"], [aria-label*="Member" i]'
  );
  if (existingMember) {
    reportStep('info', '✓ Already a member of this group');
    return { ok: true, alreadyMember: true, joinPolicy: 'instant' };
  }

  // === HUMAN-LIKE BEHAVIOR ===
  // 1. Scroll the page a bit — humans don't immediately click Join, they look
  // 2. Dwell — pause as if reading
  // 3. THEN click
  await humanlikeBrowse();

  // Find the Join button
  let joinBtn = querySelectorAny(JOIN_SELECTORS.joinBtn);
  if (!joinBtn) {
    try { joinBtn = await waitFor(JOIN_SELECTORS.joinBtn, { timeout: 6000 }); }
    catch { /* fall through */ }
  }
  if (!joinBtn) {
    reportStep('error', '❌ Join button not found');
    return { ok: false, error: 'Join button not found' };
  }
  reportStep('info', `✓ Found Join button (label="${joinBtn.getAttribute('aria-label')}")`);

  // Pre-click dwell + mouse-like events
  await sleep(800 + Math.random() * 2200);
  joinBtn.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
  await sleep(200 + Math.random() * 400);
  joinBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
  joinBtn.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
  joinBtn.click();

  // Check immediately after click for warning popups
  await sleep(1500);
  const postClickWarn = detectFbWarning();
  if (postClickWarn) {
    reportStep('error', `⚠ FB warning after join click: ${postClickWarn.kind}`);
    return { ok: false, warning: postClickWarn };
  }

  // Wait for outcome. Either:
  // (a) Button label changes to "חבר/ה" / "Joined" → instant
  // (b) Button label changes to "ביטול בקשה" / "Cancel request" → approval
  // (c) A dialog appears asking admin questions → approval
  const outcome = await detectJoinOutcome(15000);
  if (!outcome.ok) {
    reportStep('error', `❌ Could not determine join outcome: ${outcome.error}`);
    return { ok: false, error: outcome.error };
  }
  reportStep('info', `✅ Join result: ${outcome.joinPolicy}`);
  return { ok: true, joinPolicy: outcome.joinPolicy };
}

async function detectJoinOutcome(timeoutMs = 15000) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    // A dialog with admin questions = approval flow
    const dialog = document.querySelector('[role="dialog"]');
    if (dialog) {
      const txt = (dialog.innerText || '').toLowerCase();
      if (/שאל|answer|question|membership|אישור/i.test(txt)) {
        return { ok: true, joinPolicy: 'approval' };
      }
    }
    // Look at the current state of the button area
    const labels = Array.from(document.querySelectorAll('[aria-label]'))
      .map((el) => (el.getAttribute('aria-label') || '').toLowerCase());
    if (labels.some((l) => /חבר\/ה|joined|^member$/.test(l))) {
      return { ok: true, joinPolicy: 'instant' };
    }
    if (labels.some((l) => /cancel request|ביטול בקשה|בקשה ממתינה|pending/.test(l))) {
      return { ok: true, joinPolicy: 'approval' };
    }
    await sleep(400);
  }
  return { ok: false, error: 'timeout' };
}

// ---------- Human-like browsing + warning detection ----------

// Pretend to skim the page before taking action: scroll partially, dwell a few seconds.
async function humanlikeBrowse() {
  const startY = window.scrollY;
  const moves = 2 + Math.floor(Math.random() * 3); // 2-4 small scrolls
  for (let i = 0; i < moves; i++) {
    const delta = 200 + Math.random() * 600;
    window.scrollBy({ top: delta, behavior: 'smooth' });
    await sleep(1200 + Math.random() * 2500);
  }
  // Scroll back partway (humans rarely stay at the bottom)
  window.scrollTo({ top: startY + Math.random() * 200, behavior: 'smooth' });
  await sleep(500 + Math.random() * 1500);
}

// Detect FB's typical block/warning/captcha overlays. Returns null if clean.
function detectFbWarning() {
  const patterns = [
    { kind: 'slow-down', regex: /you're temporarily blocked|too quickly|slow down|please wait/i },
    { kind: 'slow-down', regex: /חסום זמנית|מהר מדי|האט|המתן/i },
    { kind: 'action-blocked', regex: /action blocked|this action was blocked|can't perform this action|cannot perform/i },
    { kind: 'action-blocked', regex: /פעולה נחסמה|לא ניתן לבצע פעולה זו|הפעולה נחסמה/i },
    { kind: 'captcha', regex: /security check|confirm it's you|prove you're not a bot|captcha/i },
    { kind: 'captcha', regex: /בדיקת אבטחה|אישור שזה אתה|הוכח שאינך בוט/i },
    { kind: 'limit', regex: /you've reached the limit|you have reached the limit|rate limit/i },
    { kind: 'limit', regex: /הגעת למגבלה/i },
  ];

  // Check role=dialog content (warnings often appear as modals)
  const dialogs = document.querySelectorAll('[role="dialog"]');
  for (const dlg of dialogs) {
    const text = (dlg.innerText || '').slice(0, 1500);
    for (const p of patterns) {
      if (p.regex.test(text)) {
        return { kind: p.kind, message: text.slice(0, 200).trim(), pageUrl: location.href };
      }
    }
  }
  // Also check any aria-live status region
  const statusEls = document.querySelectorAll('[role="status"], [role="alert"], [aria-live="assertive"]');
  for (const el of statusEls) {
    const text = (el.innerText || '').slice(0, 500);
    for (const p of patterns) {
      if (p.regex.test(text)) {
        return { kind: p.kind, message: text.slice(0, 200).trim(), pageUrl: location.href };
      }
    }
  }
  return null;
}

// ---------- Group discovery (search) ----------
// Scrapes search results on /search/groups/?q=...
// Returns: { fbGroupId, name, url, memberCount, privacy, joinPolicy }
async function scrapeGroupSearch({ maxScrolls = 10, scrollPauseMs = 1500 } = {}) {
  if (!location.pathname.startsWith('/search/groups')) {
    return { ok: false, error: 'Not on /search/groups/ page' };
  }

  // Scroll to lazy-load more results
  let prevHeight = 0;
  for (let i = 0; i < maxScrolls; i++) {
    window.scrollTo(0, document.body.scrollHeight);
    await sleep(scrollPauseMs);
    if (document.body.scrollHeight === prevHeight) break;
    prevHeight = document.body.scrollHeight;
  }

  const seen = new Map();
  // Each result card typically wraps a link to /groups/<id>/ and includes
  // member count + a Join button. We collect anchors, then walk up to a
  // common parent to extract neighbouring info.
  const anchors = document.querySelectorAll('a[href*="/groups/"]');
  for (const a of anchors) {
    const id = extractGroupIdFromUrl(a.getAttribute('href') || '');
    if (!id || seen.has(id)) continue;
    if (['feed', 'joins', 'discover', 'search', 'create'].includes(id)) continue;

    const name = (a.innerText || a.getAttribute('aria-label') || '').trim().split('\n')[0];
    if (!name) continue;

    // Walk up to find the result card's text content for member count + privacy + button text.
    let card = a;
    for (let depth = 0; depth < 8 && card.parentElement; depth++) {
      card = card.parentElement;
      if (card.querySelector('[role="button"], [aria-label*="oin" i], [aria-label*="הצטרף"]')) break;
    }
    const cardText = (card.innerText || '').toLowerCase();

    let memberCount = null;
    const m = cardText.match(/([\d,.]+\s*(?:k|m|אלף|מיליון)?)\s*(?:members?|חברים)/i);
    if (m) memberCount = parseFbCount(m[1]);

    let privacy = 'unknown';
    if (/public group|קבוצה ציבורית|ציבורית/.test(cardText)) privacy = 'public';
    else if (/private group|קבוצה פרטית|פרטית/.test(cardText)) privacy = 'private';

    // Join button — infer join policy from its label
    let joinPolicy = 'unknown';
    const joinBtn = card.querySelector('[aria-label*="oin" i], [aria-label*="הצטרף"], [aria-label*="הוסיפו"]');
    if (joinBtn) {
      const label = (joinBtn.getAttribute('aria-label') || joinBtn.innerText || '').toLowerCase();
      if (/request|בקשה|אישור/.test(label)) joinPolicy = 'approval';
      else if (/join|הצטרף/.test(label)) joinPolicy = 'instant';
    }
    // Already a member? Look for "Joined" / "חבר/ה"
    const isMember = /\bjoined\b|חבר\/ה|אתם חברים|אתה חבר|את חברה/i.test(cardText);

    seen.set(id, {
      fbGroupId: id,
      name,
      url: `https://www.facebook.com/groups/${id}/`,
      memberCount,
      privacy,
      joinPolicy,
      isMember,
    });
  }

  return { ok: true, results: Array.from(seen.values()) };
}

// ---------- Scrape joined groups ----------

async function scrapeMyGroups({ maxScrolls = 30, scrollPauseMs = 1500 } = {}) {
  if (!location.pathname.includes('/groups/joins')) {
    return { ok: false, error: 'Navigate to /groups/joins/ first' };
  }
  let prevHeight = 0;
  for (let i = 0; i < maxScrolls; i++) {
    window.scrollTo(0, document.body.scrollHeight);
    await sleep(scrollPauseMs);
    if (document.body.scrollHeight === prevHeight) break;
    prevHeight = document.body.scrollHeight;
  }
  const seen = new Map();
  document.querySelectorAll(SELECTORS.groupCardLinks).forEach((a) => {
    const id = extractGroupIdFromUrl(a.getAttribute('href') || '');
    if (!id || seen.has(id)) return;
    if (['feed', 'joins', 'discover', 'search', 'create', 'notifications'].includes(id)) return;
    const name = (a.innerText || a.getAttribute('aria-label') || '').trim().split('\n')[0];
    if (!name) return;
    seen.set(id, {
      fbGroupId: id,
      name,
      url: `https://www.facebook.com/groups/${id}/`,
      memberCount: null,
    });
  });
  return { ok: true, groups: Array.from(seen.values()) };
}

// ---------- Message handler ----------

// ============================================================
// LEADS HARVESTER — scrape visible comments from a post page
// ============================================================
// Robust against FB DOM drift via multiple fallback selectors.
async function harvestComments() {
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  // 1. Wait for comment section to be present (up to 6s)
  const t0 = Date.now();
  while (Date.now() - t0 < 6000) {
    if (document.querySelector('[role="article"], [aria-label="Comment" i], div[data-pagelet*="comment" i]')) break;
    await sleep(300);
  }

  // 2. Scroll + expand "view more comments" repeatedly
  for (let pass = 0; pass < 6; pass++) {
    window.scrollTo(0, document.body.scrollHeight);
    await sleep(900);
    // Click "view N more comments" / "View more comments" / "תגובות נוספות"
    const expandBtns = Array.from(document.querySelectorAll('div[role="button"], span[role="button"]'));
    let clicked = 0;
    for (const b of expandBtns) {
      const txt = (b.innerText || b.textContent || '').trim();
      if (!txt) continue;
      if (/(view|see)\s+(\d+|more)\s+(more\s+)?(comments|replies)/i.test(txt)
          || /תגובות (נוספות|קודמות|בנושא)/i.test(txt)
          || /more comments/i.test(txt)
          || /^\d+\s+(תגובות|replies)$/i.test(txt)) {
        try { b.click(); clicked++; } catch {}
        if (clicked >= 3) break; // cap per pass to avoid storms
      }
    }
    if (clicked === 0 && pass > 1) break; // nothing more to expand
    await sleep(700);
  }

  // 3. Collect comments. Look for FB's `[role="article"]` blocks that are children
  //    of the post (not the post itself).
  const comments = [];
  const seen = new Set();
  const candidates = document.querySelectorAll('[role="article"]');
  for (const el of candidates) {
    // Skip the original post article (large, contains the post body)
    const text = (el.innerText || '').trim();
    if (!text || text.length < 3) continue;
    if (text.length > 4000) continue; // probably the post itself
    // Find an author link inside this comment block
    const authorEl = el.querySelector('a[role="link"][href*="/user/"], a[role="link"][href*="profile.php"], a[role="link"][href*="facebook.com/"]:not([href*="/photo"])');
    const author = (authorEl?.innerText || '').trim().split('\n')[0].slice(0, 200);
    const authorUrl = authorEl?.href || '';
    if (!author) continue;
    // The comment body is typically the longest [dir="auto"] direct descendant
    let commentText = '';
    const dirs = el.querySelectorAll('[dir="auto"]');
    for (const d of dirs) {
      const t = (d.innerText || '').trim();
      if (t.length > commentText.length && t !== author) commentText = t;
    }
    if (!commentText || commentText.length < 2) continue;
    const key = `${authorUrl || author}|${commentText.slice(0, 80)}`;
    if (seen.has(key)) continue;
    seen.add(key);
    comments.push({
      author,
      authorUrl: authorUrl || null,
      text: commentText.slice(0, 1500),
      timestamp: Date.now(),
    });
    if (comments.length >= 200) break; // sanity cap
  }
  return comments;
}

// === SCRAPE_MY_FB_POSTS — AI Mode Sprint 2 ===
// Scrolls the current FB profile/page tab and extracts up to N posts with
// text + engagement counts. Designed to run on facebook.com/me, on a page
// you admin, or on a group where you've posted.
//
// Selectors are FB-volatile so we use multiple fallbacks. Returns:
//   [{ url?, text, posted_at?, likes, comments, shares, image_count }]
async function scrapeMyFbPosts(maxPosts = 15, maxScrolls = 6) {
  const out = [];
  const seen = new Set();
  let scrolls = 0;
  // Minimum-exposure default: a handful of gentle scrolls. Caller can raise it
  // explicitly ("scan deeper"), but the default keeps the FB session tiny to
  // avoid rate-limits / blocks.
  const MAX_SCROLLS = Math.max(2, Math.min(120, Number(maxScrolls) || 6));
  const SCROLL_PAUSE_MS = 1600;  // slightly slower = more human, fewer total scrolls
  while (out.length < maxPosts && scrolls < MAX_SCROLLS) {
    // Article-level container — FB uses [role="article"] for feed items
    const articles = document.querySelectorAll('[role="article"]');
    for (const a of articles) {
      if (out.length >= maxPosts) break;
      // Dedup by a cheap "first 80 chars of innerText" hash
      const fingerprint = (a.innerText || '').slice(0, 80);
      if (!fingerprint || seen.has(fingerprint)) continue;
      seen.add(fingerprint);
      try {
        const parsed = extractFbPost(a);
        if (parsed && parsed.text && parsed.text.length > 5) out.push(parsed);
      } catch { /* skip malformed */ }
    }
    if (out.length >= maxPosts) break;
    window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'instant' });
    scrolls++;
    // Tell the service worker we scrolled — it may grab a screenshot so the user
    // can watch the scan work (fire-and-forget).
    try { const _r = chrome.runtime.sendMessage({ type: 'SCAN_SCROLL_TICK', scrolls, found: out.length }); if (_r && _r.catch) _r.catch(() => {}); } catch {}
    await new Promise(r => setTimeout(r, SCROLL_PAUSE_MS));
  }
  return out;
}

function extractFbPost(article) {
  // Text — concat all paragraph-like divs inside the article body.
  // The composer button area we want to skip; FB tags real post content
  // with data-ad-preview="message" sometimes.
  const messageNode = article.querySelector('[data-ad-preview="message"]')
                   || article.querySelector('[data-ad-comet-preview="message"]')
                   || article.querySelector('div[dir="auto"][style*="text-align"]')
                   || article.querySelector('div[dir="auto"]');
  let text = messageNode ? messageNode.innerText.trim() : '';
  // Engagement — FB shows them in a strip below the post; look for spans with
  // patterns "1.2K" / "23" / "5 comments" / "2 shares".
  const allText = article.innerText || '';
  const numFrom = (re) => {
    const m = allText.match(re);
    if (!m) return 0;
    const n = m[1].replace(/[,.]/g, '');
    if (/k/i.test(m[1])) return Math.round(parseFloat(m[1]) * 1000);
    if (/m/i.test(m[1])) return Math.round(parseFloat(m[1]) * 1000000);
    return parseInt(n, 10) || 0;
  };
  // Hebrew + English patterns
  const likes    = numFrom(/(\d+(?:[.,]\d+)?[KMkm]?)\s*(לייקים|לייק|reactions|like|Likes)/i);
  const comments = numFrom(/(\d+(?:[.,]\d+)?[KMkm]?)\s*(תגובות|תגובה|comments|comment)/i);
  const shares   = numFrom(/(\d+(?:[.,]\d+)?[KMkm]?)\s*(שיתופים|שיתוף|shares|share)/i);
  // Image count
  const image_count = article.querySelectorAll('img[src*="scontent"], img[src*="fbcdn"]').length;
  // Permalink — every post has a <a> with a date like "3h" or "Yesterday"
  const link = article.querySelector('a[href*="/posts/"]')
            || article.querySelector('a[href*="/permalink/"]')
            || article.querySelector('a[href*="/photo/"]');
  const url = link?.href || null;
  // Source group — a timeline post made TO a group renders as
  // "<name> posted in <Group>" with an <a href="/groups/<id>/">. Capturing this
  // is how we learn WHERE the user actually publishes (their real footprint),
  // instead of guessing from their joined-groups list + brand description.
  let group_id = null, group_name = null;
  const gLink = article.querySelector('a[href*="/groups/"]');
  if (gLink) {
    const gm = (gLink.getAttribute('href') || '').match(/\/groups\/([^\/?#]+)/);
    // Exclude non-group segments that share the /groups/ path.
    if (gm && !['feed', 'joins', 'discover', 'search', 'create', 'notifications'].includes(gm[1])) {
      group_id = gm[1];
      const gName = (gLink.innerText || gLink.getAttribute('aria-label') || '').trim().split('\n')[0];
      group_name = gName || null;
    }
  }
  // Timestamp — best-effort, FB renders these as relative ("3h", "Yesterday")
  // and the exact epoch is on the tooltip. Skip parsing for now; webhook can fill in.
  return { url, text, likes, comments, shares, image_count, group_id, group_name };
}

// Detect the currently logged-in Facebook user (id + display name).
// The numeric id lives in the non-httpOnly `c_user` cookie (reliable); the
// display name + avatar are best-effort from the DOM. Used to show
// "logged in as X" and to warn if the open FB account isn't the expected one.
function detectFbIdentity() {
  let fbUserId = null;
  try {
    const m = document.cookie.match(/(?:^|;\s*)c_user=(\d+)/);
    if (m) fbUserId = m[1];
  } catch {}
  let name = null, avatarUrl = null;
  try {
    // FB embeds the viewer in a JSON blob: "USER_ID":"<id>" ... "NAME":"<name>"
    if (fbUserId) {
      for (const sc of document.scripts) {
        const txt = sc.textContent || '';
        if (!txt.includes(fbUserId)) continue;
        const nm = txt.match(/"NAME":"([^"]{1,60})"/) || txt.match(/"name":"([^"]{1,60})"/);
        if (nm) { try { name = JSON.parse('"' + nm[1] + '"'); } catch { name = nm[1]; } break; }
      }
    }
    // Avatar: any current-user profile image on the page
    const img = document.querySelector('image[xlink\\:href*="scontent"], img[src*="scontent"]');
    if (img) avatarUrl = img.getAttribute('xlink:href') || img.getAttribute('href') || img.src || null;
  } catch {}
  return {
    ok: true,
    loggedIn: !!fbUserId,
    fbUserId,
    name: name || null,
    avatarUrl: avatarUrl || null,
    profileUrl: fbUserId ? `https://www.facebook.com/profile.php?id=${fbUserId}` : null,
    detectedAt: Date.now(),
  };
}

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  (async () => {
    try {
      if (msg.type === 'PUBLISH_POST') {
        const result = await publishPost(msg.payload || {});
        return sendResponse(result);
      }
      if (msg.type === 'SCRAPE_MY_GROUPS') {
        const result = await scrapeMyGroups(msg.payload || {});
        if (result.ok) {
          await chrome.runtime.sendMessage({
            type: 'SCRAPE_MY_GROUPS_RESULT',
            payload: result.groups,
          });
        }
        return sendResponse(result);
      }
      if (msg.type === 'SCRAPE_POST_ENGAGEMENT') {
        const data = scrapePostEngagement();
        return sendResponse({ ok: true, data });
      }
      if (msg.type === 'HARVEST_COMMENTS') {
        const comments = await harvestComments();
        return sendResponse({ ok: true, comments });
      }
      if (msg.type === 'SCRAPE_MY_FB_POSTS') {
        const posts = await scrapeMyFbPosts(msg.payload?.maxPosts || 30);
        return sendResponse({ ok: true, posts });
      }
      if (msg.type === 'FIND_POST_URL') {
        const url = findPostUrlInFeed(msg.payload?.snippet || '');
        return sendResponse({ ok: true, url });
      }
      if (msg.type === 'JOIN_GROUP') {
        const result = await joinCurrentGroup();
        return sendResponse(result);
      }
      if (msg.type === 'HUMAN_BROWSE_PAGE') {
        // Simulate a real user: scroll, pause, move mouse a bit, read.
        const dur = Math.max(3000, msg.durationMs || 10000);
        const end = Date.now() + dur;
        try {
          while (Date.now() < end) {
            const step = 200 + Math.random() * 600;
            window.scrollBy({ top: step, behavior: 'smooth' });
            // Occasionally scroll back up a bit (re-reading)
            if (Math.random() < 0.2) {
              await new Promise((r) => setTimeout(r, 500 + Math.random() * 1500));
              window.scrollBy({ top: -(80 + Math.random() * 200), behavior: 'smooth' });
            }
            // Random mouse-move (anti-bot signal)
            try {
              const evt = new MouseEvent('mousemove', {
                bubbles: true,
                clientX: Math.random() * window.innerWidth,
                clientY: Math.random() * window.innerHeight,
              });
              document.dispatchEvent(evt);
            } catch {}
            // Variable pause between actions — humans don't scroll continuously
            await new Promise((r) => setTimeout(r, 800 + Math.random() * 2400));
          }
          // Scroll back to top before joining
          window.scrollTo({ top: 0, behavior: 'smooth' });
          await new Promise((r) => setTimeout(r, 1500));
        } catch (e) {
          console.warn('[OrganicAI] human-browse error', e);
        }
        return sendResponse({ ok: true });
      }
      if (msg.type === 'PIN_POST') {
        const r = await pinCurrentPost();
        return sendResponse(r);
      }
      if (msg.type === 'HUMANIZE_BIRTHDAYS') {
        return sendResponse(await wishHappyBirthdays(msg.payload || {}));
      }
      if (msg.type === 'HUMANIZE_STORIES') {
        return sendResponse(await watchFeedStories(msg.payload || {}));
      }
      if (msg.type === 'HUMANIZE_RSVP') {
        return sendResponse(await rsvpRecommendedEvents(msg.payload || {}));
      }
      if (msg.type === 'HUMANIZE_REACTIONS') {
        return sendResponse(await variedReactionsOnFeed(msg.payload || {}));
      }
      if (msg.type === 'ADMIN_APPROVE_PENDING') {
        return sendResponse(await adminApprovePending(msg.payload || {}));
      }
      if (msg.type === 'ADMIN_SCRAPE_INSIGHTS') {
        return sendResponse(await adminScrapeInsights());
      }
      if (msg.type === 'CLEANUP_OLD_POSTS') {
        return sendResponse(await cleanupOldPosts(msg.payload || {}));
      }
      if (msg.type === 'BULK_LEAVE_GROUPS') {
        return sendResponse(await bulkLeaveGroups(msg.payload || {}));
      }
      if (msg.type === 'PUBLISH_MARKETPLACE') {
        const result = await publishMarketplace(msg.payload || {});
        return sendResponse(result);
      }
      if (msg.type === 'PUBLISH_CHANNEL') {
        const result = await publishChannel(msg.payload || {});
        return sendResponse(result);
      }
      if (msg.type === 'POST_COMMENT') {
        const result = await postCommentOnCurrentPage(msg.payload?.text || '', msg.payload?.throttle || {});
        return sendResponse(result);
      }
      if (msg.type === 'SCRAPE_REPLIES') {
        const result = scrapeRepliesOnCurrentPost();
        return sendResponse({ ok: true, replies: result });
      }
      if (msg.type === 'SCRAPE_GROUP_SEARCH') {
        const result = await scrapeGroupSearch(msg.payload || {});
        return sendResponse(result);
      }
      if (msg.type === 'DEBUG_PROBE') {
        // Returns a snapshot of what we see on the page right now.
        const opener = querySelectorAny(SELECTORS.composerOpen) || findComposerByText();
        return sendResponse({
          ok: true,
          url: location.href,
          pathname: location.pathname,
          composerFound: !!opener,
          composerText: opener?.innerText?.slice(0, 80) || null,
          dialogOpen: !!document.querySelector(SELECTORS.dialog),
        });
      }
      if (msg.type === 'DETECT_FB_IDENTITY') {
        return sendResponse(detectFbIdentity());
      }
      if (msg.type === 'SCRAPE_GROUP_METADATA') {
        return sendResponse({ ok: true, ...scrapeGroupMetadata() });
      }
      sendResponse({ ok: false, error: 'unknown message type' });
    } catch (e) {
      sendResponse({ ok: false, error: String(e?.message || e) });
    }
  })();
  return true;
});

console.log('[fb-poster] content script loaded on', location.pathname);
