/**
 * Nexus Connect — LinkedIn Content Script
 * Runs inside the user's real browser on linkedin.com pages.
 * Executes search/connect commands from the bot via background.js relay.
 * Zero detection risk — it's just JavaScript in the user's own tab.
 *
 * IMPORTANT: This script does NOT navigate. Navigation kills the content script.
 * The background.js handles navigation via chrome.tabs.update(), then sends
 * extraction/action commands to the freshly loaded content script instance.
 */

// ── Selector fallback chains (from selectors.js) ─────────────────────
// Selectors updated 2026-05-26 (r12) based on Puppeteer DOM diagnostics —
// see scripts/test-li-everything.js and snapshots #6-13.
//
// IMPORTANT: LinkedIn migrated to HASHED CSS class names in 2026 (e.g.
// "_7b1c3de2", "ba6b4ddb"). Any selector using `class*="something"` is
// now dead. We rely on:
//   - aria-label* (semantic, stable across deploys)
//   - role= (semantic)
//   - href* (URL patterns — stable)
//   - data-* attributes when present
//   - text content match for unique buttons
//
// If you add a new selector here, NEVER use class*= unless you've verified
// the class is in the stable set (e.g., "msg-form__contenteditable" still
// works as of r11; many others don't).
const SEL = {
  searchResultItem: [
    // The hashed wrapper classes change per deploy; navigate from
    // the profile link upward instead. This pseudo-selector returns
    // the link itself — callers should use .closest() to find the
    // surrounding result container.
    'a[href*="/in/"][componentkey]',
    'a[href*="/in/"]',
  ],
  connectButton: [
    // Aria-label always includes both "Invite" and "to connect" for the
    // direct-connect button (e.g., "Invite Jane Doe to connect").
    'button[aria-label*="Invite"][aria-label*="connect" i]',
    // Generic fallback — matches both standalone "Connect" buttons and
    // "Connect with X" variants in cards. Filter out unintended matches
    // (Connect-the-app, etc.) in the caller if needed.
    'button[aria-label*="Connect"]',
  ],
  moreActionsButton: [
    // 2026: aria-label is now "More actions" or "More actions for <name>"
    // (e.g., "More actions for Delvin Moore-Crane"). Exact-match fails.
    'button[aria-label*="More actions"]',
    'button[aria-label="More"]',
  ],
  addNoteButton: [
    'button[aria-label="Add a note"]',
    'button[aria-label*="Add a note"]',
  ],
  noteTextarea: [
    "textarea#custom-message",
    'textarea[name="message"]',
    // Newer LinkedIn variants may render the note as a contenteditable
    // div rather than a textarea — try both.
    'div[role="textbox"][aria-label*="note" i]',
  ],
  sendInvitationButton: [
    'button[aria-label="Send invitation"]',
    'button[aria-label="Send now"]',
    'button[aria-label*="Send invitation"]',
    'button[aria-label*="Send now"]',
  ],
};

// ── Helpers ───────────────────────────────────────────────────────────

function findEl(selectorKey) {
  const chain = SEL[selectorKey] || [];
  for (const sel of chain) {
    const el = document.querySelector(sel);
    if (el) return el;
  }
  return null;
}

function findAllEl(selectorKey) {
  const chain = SEL[selectorKey] || [];
  for (const sel of chain) {
    const els = document.querySelectorAll(sel);
    if (els.length > 0) return Array.from(els);
  }
  return [];
}

function clampCount(val, fallback = 10, min = 1, max = 100) {
  const n = parseInt(val) || fallback;
  return Math.max(min, Math.min(n, max));
}

function delay(ms) {
  const jitter = ms * 0.3 * (Math.random() - 0.5);
  return new Promise(r => setTimeout(r, ms + jitter));
}

// ── CRM sync (fire-and-forget) ───────────────────────────────────────
function crmSync(data) {
  try { chrome.runtime.sendMessage({ action: "crm-sync", ...data }); } catch {}
}

// ── Humanization helpers ─────────────────────────────────────────────
// humanDelay / sessionWarmup / maybeFatiguePause live in content-common.js,
// prepended to this content_scripts entry in manifest.json (shared MV3 world).

// ── Cancellable batch helper ─────────────────────────────────────────
//
// Wraps a "do something for each item in items" loop with two cross-cutting
// concerns: cancel-check between iterations (so the Stop button works) and
// counter tracking (ok/fail/skip totals).
//
// processOne(item, index) returns:
//   true     — counts as ok
//   false    — counts as fail
//   "skip"   — counts as skip, no further log
//   anything else thrown / rejected — counts as fail (caught here)
//
// Used by every batch write action. Why a helper instead of inline loops:
// keeps the cancel-check + counter logic in one place so future tweaks
// (e.g. add a max-failures abort threshold) land everywhere at once.
async function cancellableBatch(items, opts, processOne) {
  const { cancellable = true } = opts || {};
  let ok = 0, fail = 0, skipped = 0;
  try {
    for (let i = 0; i < items.length; i++) {
      if (cancellable && isCancelled()) {
        addLog("-", `Cancelled at ${i}/${items.length}`);
        break;
      }
      try {
        const r = await processOne(items[i], i);
        if (r === "skip") skipped++;
        else if (r === false) fail++;
        else ok++;
      } catch (err) {
        fail++;
        addLog("X", `Item ${i + 1}: ${err.message}`);
      }
    }
    return { ok, fail, skipped, total: items.length };
  } finally {
    // Release any CDP attachment the batch may have triggered. We attach
    // lazily on the first cdpInsertText call from a typing function and
    // KEEP the attachment for the rest of the batch (so the yellow
    // "started debugging this browser" banner doesn't flicker per item).
    // The detach here dismisses the banner once the campaign is done.
    // Idempotent — safe to call when no attachment exists.
    try { await _cdpDetachViaBg(); } catch { /* best-effort */ }
  }
}

// ── Local error classifier (mirrors bot's classifyError) ─────────────
//
// When an action throws or returns an error, we tag it with a canonical
// code so the LinkedIn Activity dashboard can group failures by cause.
// Codes match nexus-bot/src/utils/linkedin-logger.js#classifyError so
// rows from extension and Voyager paths sort into the same buckets.
function liClassifyErrorLocal(err) {
  if (!err) return "unknown";
  const msg = String(err.message || err).toLowerCase();
  if (msg.includes("timeout") || msg.includes("timed out")) return "selector_timeout";
  if (msg.includes("no tab") || msg.includes("no_tab_open")) return "no_tab_open";
  if (msg.includes("selector") && msg.includes("missing")) {
    const m = msg.match(/selector[_\s]+missing[:\s]+(\S+)/);
    return m ? `selector_missing:${m[1]}` : "selector_missing";
  }
  if (msg.includes("not authenticated") || (msg.includes("session") && msg.includes("expired"))) return "session_expired";
  if (msg.includes("rate limit") || msg.includes("429")) return "rate_limited";
  if (msg.includes("weekly") && msg.includes("limit")) return "weekly_cap";
  if (msg.includes("daily") && msg.includes("limit")) return "daily_cap";
  if (msg.includes("cooldown")) return "cooldown";
  if (msg.includes("network") || msg.includes("failed to fetch")) return "network";
  return "unknown";
}

function waitForSelector(selectorKey, timeoutMs = 10000) {
  return new Promise((resolve) => {
    const chain = SEL[selectorKey] || [];
    const start = Date.now();
    const check = () => {
      for (const sel of chain) {
        const el = document.querySelector(sel);
        if (el) return resolve(el);
      }
      if (Date.now() - start > timeoutMs) return resolve(null);
      setTimeout(check, 300);
    };
    check();
  });
}

/** Type text into a contenteditable element at human speed */
async function humanType(el, text) {
  el.focus();
  el.textContent = "";

  // Autonomous Mode fast path — works in unfocused tabs because the
  // events are flagged isTrusted=true at the browser-process level.
  // Used by handleCommentOnPost / handleSendMessage / handleReplyMessage
  // — which is why those used to silently fail when the LinkedIn tab
  // wasn't focused.
  if (await _isAutonomousModeActive()) {
    const cdpRes = await _cdpTypeViaBg(text);
    if (cdpRes?.success) return;
    console.warn("[Nexus] humanType CDP failed, falling back:", cdpRes?.error);
  }

  // Use execCommand for proper contenteditable input
  for (const char of text) {
    document.execCommand("insertText", false, char);
    await delay(80 + Math.random() * 120); // 80-200ms per char (human speed)
  }
}

/** Find a button by its visible text content */
function findButtonByText(container, text) {
  const buttons = container.querySelectorAll("button");
  for (const btn of buttons) {
    if (btn.textContent?.trim().toLowerCase() === text.toLowerCase()) return btn;
  }
  return null;
}

// ── Command Handlers ──────────────────────────────────────────────────

/** Extract search results from the current page (already navigated by background.js) */
async function handleExtractSearch({ limit = 10 }) {
  // Wait for profile links to appear (LinkedIn uses obfuscated classes, so we find by structure)
  await waitForProfileLinks(12000);
  await delay(1000); // let remaining results render

  const links = document.querySelectorAll('a[href*="/in/"]');
  const seen = new Set();
  const results = [];

  links.forEach(a => {
    const url = a.href.split("?")[0];
    if (seen.has(url) || !url.includes("/in/")) return;
    seen.add(url);

    // Extract name: prefer span[aria-hidden="true"] inside the link, fallback to link text
    const name = a.querySelector('span[aria-hidden="true"]')?.textContent?.trim()
      || a.textContent?.trim()?.split("\n")[0]?.trim() || "";

    // Walk up to find the card container (parent with >3 siblings = list container)
    let card = a;
    for (let i = 0; i < 6; i++) {
      if (!card.parentElement) break;
      if (card.parentElement.children.length > 3) break;
      card = card.parentElement;
    }

    // Extract title and company from text content in the card
    let title = "", company = "";
    const textNodes = card.querySelectorAll("div, span");
    const texts = [];
    // Only texts[0] and texts[1] are ever read, so stop at two. A people-search
    // card carries 50-200 div/span descendants and the old full walk ran a
    // linear dedupe scan against a growing list for every one of them.
    for (const el of textNodes) {
      const t = el.textContent?.trim();
      if (t && t.length > 3 && t.length < 120 && !t.includes(name) && !texts.includes(t)) {
        texts.push(t);
        if (texts.length >= 2) break;
      }
    }
    // Heuristic: first unique text after name is title, second is company
    if (texts.length > 0) title = texts[0] || "";
    if (texts.length > 1) company = texts[1] || "";

    // Check connection degree — skip 1st degree (already connected)
    const cardText = card.textContent || "";
    const is1st = /\b1st\b/.test(cardText);

    if (name && url) results.push({ name: name.slice(0, 60), linkedinUrl: url, title: title.slice(0, 100), company: company.slice(0, 100), connected: is1st });
  });

  return { success: true, results: results.slice(0, limit) };
}

function waitForProfileLinks(timeoutMs = 12000) {
  return new Promise((resolve) => {
    const start = Date.now();
    const check = () => {
      const links = document.querySelectorAll('a[href*="/in/"]');
      if (links.length >= 3) return resolve(true);
      if (Date.now() - start > timeoutMs) return resolve(false);
      setTimeout(check, 500);
    };
    check();
  });
}

/** Click Connect on the current profile page (already navigated by background.js) */
async function handleClickConnect({ note }) {
  const sentinel = liActionStart("click-connect", { hasNote: !!note });

  // Track which DOM step failed so we know whether Voyager fallback is worth
  // trying. Some failures (weekly cap, already-connected) are NOT recoverable
  // by Voyager — those short-circuit with no fallback attempt.
  let domError = null;
  let domFatal = false; // true if Voyager fallback would also fail (cap, etc.)

  try {
    await delay(1500); // wait for profile to settle

    let connectBtn = findEl("connectButton");

    if (!connectBtn) {
      const msgBtn = document.querySelector('button[aria-label*="Message"]');
      if (msgBtn) {
        addLog("-", "Already connected (Message button visible)");
        await liActionEnd(sentinel, "skip", { errorCode: "already_connected" });
        return { success: false, reason: "already_connected" };
      }

      const moreBtn = findEl("moreActionsButton");
      if (moreBtn) {
        moreBtn.click();
        await delay(1000);
        const dropdownItems = document.querySelectorAll('div[class*="artdeco-dropdown"] span');
        let found = false;
        for (const span of dropdownItems) {
          if (span.textContent.trim().toLowerCase() === "connect") {
            const clickTarget = span.closest("button, li, div[role='button']");
            if (clickTarget) { clickTarget.click(); found = true; }
            break;
          }
        }
        if (!found) {
          domError = "selector_missing:connect_in_dropdown — More menu opened but no Connect option";
          throw new Error(domError);
        }
      } else {
        domError = "selector_missing:connectButton — Connect button + More menu both missing";
        throw new Error(domError);
      }
    } else {
      connectBtn.click();
    }

    await delay(1500);

    if (note) {
      const addNoteBtn = findEl("addNoteButton");
      if (addNoteBtn) {
        addNoteBtn.click();
        await delay(800);
        const textarea = findEl("noteTextarea");
        if (textarea) {
          textarea.focus();
          textarea.value = note.slice(0, 300);
          textarea.dispatchEvent(new Event("input", { bubbles: true }));
          await delay(500);
        }
      }
    }

    await delay(500);
    const sendBtn = findEl("sendInvitationButton");
    if (sendBtn) {
      sendBtn.click();
    } else {
      const fallback = document.querySelector('button[aria-label*="Send"]');
      if (fallback) {
        fallback.click();
      } else {
        domError = "selector_missing:sendInvitationButton — Send button missing after Connect click";
        throw new Error(domError);
      }
    }

    await delay(2000);

    // Weekly limit — fatal (Voyager will fail the same way).
    const limitModal = document.querySelector('div[role="dialog"], div[class*="artdeco-modal"]');
    if (limitModal) {
      const modalText = (limitModal.textContent || "").toLowerCase();
      if (modalText.includes("invitation limit") || modalText.includes("weekly limit") || modalText.includes("can't send") || modalText.includes("limit reached")) {
        const closeBtn = limitModal.querySelector('button[aria-label="Dismiss"], button[aria-label="Got it"], button[aria-label="Close"]')
          || findButtonByText(limitModal, "Got it")
          || findButtonByText(limitModal, "OK");
        if (closeBtn) closeBtn.click();
        addLog("!", "Weekly invitation limit reached — LinkedIn blocked the request");
        domFatal = true;
        await liActionEnd(sentinel, "fail", { errorCode: "weekly_cap", errorMessage: "Weekly invitation limit modal" });
        return { success: false, reason: "weekly_limit_reached" };
      }
    }

    addLog("V", "Connect request sent (DOM)");
    await liActionEnd(sentinel, "ok", { result: { path: "dom", withNote: !!note } });
    return { success: true, path: "dom" };
  } catch (err) {
    if (!domError) domError = err.message;
    if (domFatal) throw err; // already logged + returned
    addLog("-", `DOM connect failed: ${domError} — trying Voyager fallback`);
  }

  // ── Voyager fallback ────────────────────────────────────────────
  // The bot's linkedin:http-send-connection endpoint sends the connection
  // request server-side via the Voyager API. Requires the profile slug,
  // which we extract from the current URL.
  const slugMatch = window.location.pathname.match(/\/in\/([^\/?#]+)/);
  if (!slugMatch) {
    await liActionEnd(sentinel, "fail", {
      errorCode: "no_slug",
      errorMessage: `DOM failed (${domError}) and current URL is not a profile — can't fall back`,
    });
    return { success: false, reason: "no_slug_for_fallback", domError };
  }

  try {
    const voyResult = await linkedinCmd("linkedin:http-send-connection", {
      publicIdentifier: decodeURIComponent(slugMatch[1]),
      message: note ? note.slice(0, 300) : "",
    });
    if (voyResult?.success !== false && !voyResult?.error) {
      addLog("V", "Connect request sent (Voyager fallback)");
      await liActionEnd(sentinel, "ok", { result: { path: "voyager", domError } });
      return { success: true, path: "voyager" };
    }
    const voyErr = voyResult?.error || "voyager returned success:false";
    await liActionEnd(sentinel, "fail", {
      errorCode: voyResult?.statusCode === 429 ? "rate_limited" : "fallback_failed",
      errorMessage: `DOM: ${domError} | Voyager: ${voyErr}`,
    });
    return { success: false, reason: "both_paths_failed", domError, voyagerError: voyErr };
  } catch (err) {
    await liActionEnd(sentinel, "fail", {
      errorCode: liClassifyErrorLocal(err),
      errorMessage: `DOM: ${domError} | Voyager threw: ${err.message}`,
    });
    return { success: false, reason: "fallback_threw", domError };
  }
}

/** Like posts on the current feed page */
async function handleLikePosts({ count = 5, keywordFilter = "" }) {
  const sentinel = liActionStart("like-posts", { count, keywordFilter });
  let liked = 0, viaDom = 0, viaVoyager = 0;
  const keywords = keywordFilter ? keywordFilter.toLowerCase().split(",").map(k => k.trim()).filter(Boolean) : [];

  try {
    // Scroll to load more posts
    for (let scroll = 0; scroll < 5 && liked < count; scroll++) {
      if (isCancelled()) { addLog("-", `Cancelled at ${liked}/${count}`); break; }

      // Find all post containers. We include posts where the like button is
      // missing too — those are candidates for Voyager-only path (DOM rotted).
      const allPosts = Array.from(document.querySelectorAll('div[data-urn*="activity"], div[data-urn*="ugcPost"]'));
      const candidates = allPosts.filter(post => {
        if (keywords.length > 0) {
          const postText = (post.textContent || "").toLowerCase();
          if (!keywords.some(kw => postText.includes(kw))) return false;
        }
        // Skip already-liked posts.
        const pressed = post.querySelector('button[aria-pressed="true"][aria-label*="ike"]');
        return !pressed;
      });

      if (candidates.length === 0 && scroll > 0) break;

      const remaining = count - liked;
      const batch = candidates.slice(0, remaining);

      await cancellableBatch(batch, {}, async (post, i) => {
        // Path 1: DOM click on the Like button.
        const likeBtn = post.querySelector('button[aria-label*="Like"][aria-pressed="false"]')
          || post.querySelector('button[aria-label="React Like"]')
          || post.querySelector('button[aria-label*="like"]:not([aria-pressed="true"])');
        if (likeBtn) {
          try {
            likeBtn.click();
            liked++; viaDom++;
            addLog("V", `Liked post (${liked}/${count}) [DOM]`);
            await delay(1500 + Math.random() * 2000);
            return true;
          } catch (err) {
            addLog("-", `Post ${i + 1} DOM like failed: ${err.message} — trying Voyager`);
          }
        }

        // Path 2: Voyager fallback. Extract the post URN from data-urn
        // (urn:li:activity:123 or urn:li:ugcPost:456) and call the bot.
        const postUrn = post.getAttribute("data-urn");
        if (!postUrn) {
          addLog("!", `Post ${i + 1}: no data-urn, can't Voyager-fallback`);
          return false;
        }
        try {
          const r = await linkedinCmd("linkedin:http-like-post", { postUrn });
          if (r?.success !== false && !r?.error) {
            liked++; viaVoyager++;
            addLog("V", `Liked post (${liked}/${count}) [Voyager]`);
            await delay(1500 + Math.random() * 2000);
            return true;
          }
          addLog("!", `Post ${i + 1} Voyager like failed: ${r?.error || "success:false"}`);
          return false;
        } catch (err) {
          addLog("X", `Post ${i + 1} Voyager like threw: ${err.message}`);
          return false;
        }
      });

      // Scroll down to load more if we still need likes.
      if (liked < count && !isCancelled()) {
        window.scrollBy(0, 800);
        await delay(2000);
      }
    }

    await liActionEnd(sentinel, "ok", { result: { liked, viaDom, viaVoyager, requested: count } });
    return { success: true, liked, viaDom, viaVoyager };
  } catch (err) {
    await liActionEnd(sentinel, "fail", {
      errorCode: liClassifyErrorLocal(err),
      errorMessage: err.message,
      result: { likedBeforeError: liked, viaDom, viaVoyager },
    });
    throw err;
  }
}

/** Comment on a post — either hardcoded text or AI-generated */
async function handleCommentOnPost({ postIndex = 0, text = "", useAI = false }) {
  const sentinel = liActionStart("comment-on-post", { postIndex, useAI, hasText: !!text });

  try {
    const posts = document.querySelectorAll('div[data-urn*="activity"], div[data-urn*="ugcPost"]');
    if (postIndex >= posts.length) {
      await liActionEnd(sentinel, "fail", { errorCode: "selector_missing:post", errorMessage: `postIndex ${postIndex} not in DOM (${posts.length} posts found)` });
      return { success: false, reason: "post_not_found" };
    }

    const post = posts[postIndex];

    // If useAI, extract post text and request AI comment from background
    let commentText = text;
    if (useAI) {
      const postTextEl = post.querySelectorAll('span[dir="ltr"]');
      const postContent = Array.from(postTextEl).map(s => s.textContent?.trim()).join(" ").slice(0, 500);
      // `actor__name` class was hashed in LinkedIn's 2026 rebuild (0 matches
      // in audit). `a[href*="/in/"] span` works (27 matches) — the first
      // <span> inside a profile link is reliably the actor's name.
      const authorEl = post.querySelector('a[href*="/in/"] span');
      const authorName = authorEl?.textContent?.trim() || "someone";
      // Extract author LinkedIn URL for CRM-enriched AI comments
      const authorLink = post.querySelector('a[href*="/in/"]');
      const authorLinkedInUrl = authorLink?.href?.split("?")[0] || null;

      // Ask background.js to generate AI comment (15s timeout)
      try {
        const aiResult = await Promise.race([
          new Promise((resolve, reject) => {
            chrome.runtime.sendMessage({ action: "generate-ai-comment", postText: postContent, authorName, authorLinkedInUrl }, (res) => {
              if (res?.comment) resolve(res);
              else reject(new Error(res?.error || "AI comment failed"));
            });
          }),
          new Promise((_, reject) => setTimeout(() => reject(new Error("AI comment timed out")), 15000)),
        ]);
        commentText = aiResult.comment;
      } catch (err) {
        await liActionEnd(sentinel, "fail", { errorCode: "ai_failed", errorMessage: err.message });
        return { success: false, reason: `AI failed: ${err.message}` };
      }
    }

    if (!commentText) {
      await liActionEnd(sentinel, "fail", { errorCode: "missing_params", errorMessage: "no comment text" });
      return { success: false, reason: "no_comment_text" };
    }

    // Click the Comment button on this post (not the comment count)
    const commentBtns = post.querySelectorAll('button[aria-label*="Comment"]');
    let commentBtn = null;
    for (const btn of commentBtns) {
      const label = btn.getAttribute("aria-label") || "";
      // The action button says "Comment" or "Comment on X's post", not "N comments"
      if (!label.match(/^\d+/)) { commentBtn = btn; break; }
    }
    if (!commentBtn) {
      await liActionEnd(sentinel, "fail", { errorCode: "selector_missing:comment_button", errorMessage: "no Comment action button on post" });
      return { success: false, reason: "comment_button_not_found" };
    }

    commentBtn.click();
    await delay(1500);

    // Find the comment input
    const input = post.querySelector('div[contenteditable="true"][role="textbox"]')
      || document.querySelector('div[contenteditable="true"][role="textbox"]');
    if (!input) {
      await liActionEnd(sentinel, "fail", { errorCode: "selector_missing:comment_input", errorMessage: "contenteditable input not found" });
      return { success: false, reason: "comment_input_not_found" };
    }

    // Type comment with humanized speed
    input.focus();
    await humanType(input, commentText);
    await delay(500);

    // Find and click submit — use multiple strategies
    const submitBtn = post.querySelector('button.comments-comment-box__submit-button')
      || post.querySelector('button[class*="comments-comment-box__submit"]')
      || document.querySelector('button.comments-comment-box__submit-button')
      || findButtonByText(post, "Post")
      || findButtonByText(document, "Post");
    if (submitBtn) {
      submitBtn.click();
      await delay(2000);
      addLog("V", `Commented (DOM): "${commentText.slice(0, 40)}${commentText.length > 40 ? "…" : ""}"`);
      await liActionEnd(sentinel, "ok", { result: { path: "dom", commentLength: commentText.length } });
      return { success: true, path: "dom", comment: commentText.slice(0, 50) };
    }

    // ── Voyager fallback ───────────────────────────────────────
    // DOM submit button missing — try the bot's linkedin:http-comment
    // endpoint with the post URN. Same pattern as like-posts fallback.
    const postUrn = post.getAttribute("data-urn");
    if (!postUrn) {
      await liActionEnd(sentinel, "fail", {
        errorCode: "selector_missing:submit_button",
        errorMessage: "Post button not found and post has no data-urn for Voyager fallback",
      });
      return { success: false, reason: "submit_button_not_found" };
    }
    addLog("-", `DOM submit button missing — trying Voyager comment fallback`);
    try {
      const r = await linkedinCmd("linkedin:http-comment", { postUrn, text: commentText });
      if (r?.success !== false && !r?.error) {
        addLog("V", `Commented (Voyager): "${commentText.slice(0, 40)}${commentText.length > 40 ? "…" : ""}"`);
        await liActionEnd(sentinel, "ok", { result: { path: "voyager", commentLength: commentText.length } });
        return { success: true, path: "voyager", comment: commentText.slice(0, 50) };
      }
      const voyErr = r?.error || "voyager returned success:false";
      await liActionEnd(sentinel, "fail", {
        errorCode: "fallback_failed",
        errorMessage: `DOM submit missing | Voyager: ${voyErr}`,
      });
      return { success: false, reason: "both_paths_failed", voyagerError: voyErr };
    } catch (err) {
      await liActionEnd(sentinel, "fail", {
        errorCode: liClassifyErrorLocal(err),
        errorMessage: `DOM submit missing | Voyager threw: ${err.message}`,
      });
      return { success: false, reason: "fallback_threw" };
    }
  } catch (err) {
    await liActionEnd(sentinel, "fail", { errorCode: liClassifyErrorLocal(err), errorMessage: err.message });
    throw err;
  }
}

/** Comment on a specific post DOM element */
async function commentOnPostElement(post, text, useAI) {
  let commentText = text;
  if (useAI) {
    const postTextEl = post.querySelectorAll('span[dir="ltr"]');
    const postContent = Array.from(postTextEl).map(s => s.textContent?.trim()).join(" ").slice(0, 500);
    const authorEl = post.querySelector('span[class*="actor__name"] span, a[href*="/in/"] span');
    const authorName = authorEl?.textContent?.trim() || "someone";
    const authorLink2 = post.querySelector('a[href*="/in/"]');
    const authorLinkedInUrl2 = authorLink2?.href?.split("?")[0] || null;
    try {
      const aiResult = await Promise.race([
        new Promise((resolve, reject) => {
          chrome.runtime.sendMessage({ action: "generate-ai-comment", postText: postContent, authorName, authorLinkedInUrl: authorLinkedInUrl2 }, (res) => {
            if (res?.comment) resolve(res); else reject(new Error(res?.error || "AI failed"));
          });
        }),
        new Promise((_, reject) => setTimeout(() => reject(new Error("AI timed out")), 15000)),
      ]);
      commentText = aiResult.comment;
    } catch { return { success: false, reason: "ai_failed" }; }
  }
  if (!commentText) return { success: false, reason: "no_text" };

  // Click comment button on this post
  const commentBtns = post.querySelectorAll('button[aria-label*="Comment"]');
  let commentBtn = null;
  for (const btn of commentBtns) {
    if (!(btn.getAttribute("aria-label") || "").match(/^\d+/)) { commentBtn = btn; break; }
  }
  if (!commentBtn) return { success: false, reason: "comment_button_not_found" };
  commentBtn.click();
  await delay(1500);

  const input = post.querySelector('div[contenteditable="true"][role="textbox"]')
    || document.querySelector('div[contenteditable="true"][role="textbox"]');
  if (!input) return { success: false, reason: "input_not_found" };

  await humanType(input, commentText);
  await delay(500);

  const submitBtn = post.querySelector('button.comments-comment-box__submit-button')
    || findButtonByText(post, "Post") || findButtonByText(document, "Post");
  if (submitBtn) { submitBtn.click(); await delay(2000); return { success: true }; }
  return { success: false, reason: "submit_not_found" };
}

/** Like posts and comment on every Nth one */
async function handleLikeAndComment({ count = 5, commentText = "", useAI = false, commentEveryN = 3, keywordFilter = "" }) {
  const sentinel = liActionStart("like-and-comment", { count, useAI, commentEveryN, keywordFilter });
  let liked = 0, commented = 0;
  const keywords = keywordFilter ? keywordFilter.toLowerCase().split(",").map(k => k.trim()).filter(Boolean) : [];

  try {
    for (let scroll = 0; scroll < 5 && liked < count; scroll++) {
      if (isCancelled()) { addLog("-", `Cancelled at like ${liked}/${count}`); break; }

      const allPosts = Array.from(document.querySelectorAll('div[data-urn*="activity"], div[data-urn*="ugcPost"]'));
      const candidates = allPosts.filter(post => {
        if (keywords.length > 0) {
          const postText = (post.textContent || "").toLowerCase();
          if (!keywords.some(kw => postText.includes(kw))) return false;
        }
        const likeBtn = post.querySelector('button[aria-label*="Like"][aria-pressed="false"]')
          || post.querySelector('button[aria-label="React Like"]')
          || post.querySelector('button[aria-label*="like"]:not([aria-pressed="true"])');
        return !!likeBtn;
      });

      if (candidates.length === 0 && scroll > 0) break;

      const remaining = count - liked;
      const batch = candidates.slice(0, remaining);

      await cancellableBatch(batch, {}, async (post, i) => {
        const likeBtn = post.querySelector('button[aria-label*="Like"][aria-pressed="false"]')
          || post.querySelector('button[aria-label="React Like"]')
          || post.querySelector('button[aria-label*="like"]:not([aria-pressed="true"])');
        if (!likeBtn) return "skip";

        likeBtn.click();
        liked++;
        addLog("V", `Liked post (${liked}/${count})`);
        await delay(1500 + Math.random() * 2000);

        // Comment on every Nth liked post — pass actual post element.
        if (commentEveryN > 0 && liked % commentEveryN === 0) {
          const result = await commentOnPostElement(post, commentText, useAI);
          if (result.success) {
            commented++;
            addLog("V", `Commented on post #${liked}`);
          } else {
            addLog("!", `Comment skipped on post #${liked}: ${result.reason || "unknown"}`);
          }
          await delay(2000);
        }
        return true;
      });

      if (liked < count && !isCancelled()) {
        window.scrollBy(0, 800);
        await delay(2000);
      }
    }

    await liActionEnd(sentinel, "ok", { result: { liked, commented, requested: count } });
    return { success: true, liked, commented };
  } catch (err) {
    await liActionEnd(sentinel, "fail", {
      errorCode: liClassifyErrorLocal(err),
      errorMessage: err.message,
      result: { likedBeforeError: liked, commentedBeforeError: commented },
    });
    throw err;
  }
}

/**
 * Like the first available post on the current page — used as the DOM
 * fallback for the like-latest-post warm-up step in accept-and-DM.
 * Assumes the page is already loaded to the person's /recent-activity/
 * surface (background navigates there before calling us).
 *
 * Reuses the same post-container + like-button selector chain that
 * handleLikePosts and handleLikeAndComment use on the feed — those
 * selectors survived LinkedIn's 2026 rebuild because they hang off
 * data-urn (stable) and aria-label (stable). Class-based selectors
 * are NOT used here — the hashed cosmetic classes break weekly.
 *
 * `skipReposts`: a heuristic. Reshares typically have empty or very
 * short commentary text (just an actor handoff). When the flag is on,
 * we walk through posts looking for one with > 30 chars of post text
 * and fall back to posts[0] if everything looks reshared. Matches the
 * heuristic in background.js _likeLatestPost.
 */
async function handleLikeLatestPostOnPage({ skipReposts = true, targetSlug = null } = {}) {
  const sentinel = liActionStart("like-latest-post-on-page", { skipReposts, targetSlug });

  try {
    // Scroll once to make sure the lazy-loaded feed component has mounted.
    // The activity page only renders the first 1-2 posts above the fold
    // before paint; without a scroll the post list can be empty when we
    // query.
    window.scrollBy(0, 600);
    await delay(1500);

    const posts = Array.from(document.querySelectorAll(
      'div[data-urn*="activity"], div[data-urn*="ugcPost"]'
    ));
    if (posts.length === 0) {
      await liActionEnd(sentinel, "fail", {
        errorCode: "no_posts_in_dom",
        errorMessage: "No data-urn posts visible on /recent-activity/ page",
      });
      return { success: false, error: "no posts on page", errorCode: "no_posts_in_dom" };
    }

    const findLikeBtn = (post) =>
      post.querySelector('button[aria-label*="Like"][aria-pressed="false"]')
        || post.querySelector('button[aria-label="React Like"]')
        || post.querySelector('button[aria-label*="like"]:not([aria-pressed="true"])');

    // ── Authorship check: does THIS person own this post? ──────────
    // On /recent-activity/shares/ the post container shows TWO actor
    // links when the entry is a reshare: the outer "X reshared this"
    // header (the target person) and the inner original-post actor
    // (someone else). We want posts where the FIRST actor link in DOM
    // order is the target — that's their original post. If it's a
    // reshare, the first actor link is also the target but there's a
    // visible "reshared" marker we can sniff.
    //
    // Strategy:
    //   1. The first <a href="/in/<slug>"> in the post container must
    //      be the target — confirms they're at least involved.
    //   2. Skip the post if it has a reshare indicator (a header element
    //      with class "*reshared*" or text matching /reposted|reshared/i
    //      AT THE TOP of the post, before the main content).
    //   3. As fallback, accept any post whose first actor link is the
    //      target — if we can't tell reshare vs original we'd rather
    //      like SOMETHING the target touched than nothing.
    const slugLower = (targetSlug || "").toLowerCase();
    const isAuthoredByTarget = (post) => {
      if (!slugLower) return true; // no filter requested — accept any
      const actorLink = post.querySelector('a[href*="/in/"]');
      if (!actorLink) return false;
      const href = (actorLink.getAttribute("href") || "").toLowerCase();
      // href shape: /in/<slug>/ or /in/<slug>?... — match both
      return href.includes(`/in/${slugLower}/`) || href.includes(`/in/${slugLower}?`);
    };
    const looksLikeReshare = (post) => {
      // LinkedIn's reshare header is usually the first child element with
      // text "X reposted this" / "X reshared this". Class names are hashed
      // but the text + position is stable.
      const firstHeader = post.querySelector('div[class*="header"], div[class*="reshar"], h2, h3, span');
      if (!firstHeader) return false;
      const t = (firstHeader.textContent || "").trim().slice(0, 80).toLowerCase();
      if (/repost(ed)? this|reshared this|originally posted/i.test(t)) return true;
      return false;
    };

    // ── Read the actual post body (not the actor name) ─────────────
    // Post body in 2026 LinkedIn lives in a description div that contains
    // either a [dir="ltr"] span or plain text. The actor name span is
    // ALSO [dir="ltr"], so just querying the first [dir="ltr"] gives the
    // author name. Strategy: skip the first [dir="ltr"] (header/actor)
    // and concatenate the rest. Falls back to post.textContent minus the
    // actor link text.
    const readPostBody = (post) => {
      const ltrSpans = post.querySelectorAll('span[dir="ltr"]');
      // Skip the first (actor name); join the rest.
      if (ltrSpans.length >= 2) {
        const body = Array.from(ltrSpans).slice(1).map(s => s.textContent || "").join(" ").trim();
        if (body.length > 0) return body;
      }
      // Fallback: total textContent minus the first 60 chars (rough header strip).
      const all = (post.textContent || "").trim();
      return all.length > 60 ? all.slice(60) : all;
    };

    // ── Pick the right post ────────────────────────────────────────
    // Priority:
    //   1. Target-authored, not a reshare, has unpressed Like button.
    //   2. Target-authored, has unpressed Like button (might be reshare —
    //      still touches their feed which is what they'd notice).
    //   3. Any post with an unpressed Like button (last resort; only
    //      fires when target filter is off OR every post failed the
    //      target check).
    let chosen = null;
    let chosenReason = null;
    for (const p of posts) {
      if (!isAuthoredByTarget(p)) continue;
      if (skipReposts && looksLikeReshare(p)) continue;
      if (!findLikeBtn(p)) continue;
      chosen = { post: p, text: readPostBody(p) };
      chosenReason = "target_original";
      break;
    }
    if (!chosen) {
      // Drop the reshare filter — accept any post they at least touched.
      for (const p of posts) {
        if (!isAuthoredByTarget(p)) continue;
        if (!findLikeBtn(p)) continue;
        chosen = { post: p, text: readPostBody(p) };
        chosenReason = "target_reshare";
        break;
      }
    }
    if (!chosen && !slugLower) {
      // Last resort, only when we have no slug to filter against.
      for (const p of posts) {
        if (!findLikeBtn(p)) continue;
        chosen = { post: p, text: readPostBody(p) };
        chosenReason = "any_post";
        break;
      }
    }

    if (!chosen) {
      // Useful diagnostic: how many posts on the page, how many were
      // by the target, how many had like buttons.
      const totalLikeable = posts.filter(findLikeBtn).length;
      const totalByTarget = slugLower ? posts.filter(isAuthoredByTarget).length : posts.length;
      const errMsg = `${posts.length} posts on page, ${totalByTarget} by target ${targetSlug || "(no slug)"}, ${totalLikeable} have unpressed Like — no overlap`;
      await liActionEnd(sentinel, "fail", {
        errorCode: "no_target_authored_post",
        errorMessage: errMsg,
      });
      return { success: false, error: errMsg, errorCode: "no_target_authored_post" };
    }

    const likeBtn = findLikeBtn(chosen.post);
    const postUrn = chosen.post.getAttribute("data-urn") || null;

    // Scroll the post into view before clicking — React's intersection
    // observer doesn't initialize event handlers on posts that have
    // never been visible. Without this the click registers but the
    // backend doesn't record the like.
    try { chosen.post.scrollIntoView({ behavior: "instant", block: "center" }); } catch { /* ignore */ }
    await delay(400 + Math.random() * 300);

    likeBtn.click();
    await delay(1200 + Math.random() * 800);

    // Confirm via aria-pressed flip. If LinkedIn didn't register the like,
    // the button stays pressed=false. Not all variants update aria-pressed
    // immediately — we accept either an aria flip OR a visible counter
    // change as success.
    const pressedAfter = likeBtn.getAttribute("aria-pressed") === "true";
    if (!pressedAfter) {
      addLog("!", `Like click landed but aria-pressed didn't flip (post: ${(chosen.text || "").slice(0, 40)}…)`);
    }

    await liActionEnd(sentinel, "ok", {
      result: {
        path: "dom",
        postUrn,
        chosenReason,
        textLen: chosen.text?.length || 0,
        ariaConfirmed: pressedAfter,
      },
    });
    return {
      success: true,
      postUrn,
      postText: chosen.text || "",
      chosenReason,
    };
  } catch (err) {
    await liActionEnd(sentinel, "fail", {
      errorCode: liClassifyErrorLocal(err),
      errorMessage: err.message,
    });
    return { success: false, error: err.message, errorCode: liClassifyErrorLocal(err) };
  }
}

/** Accept pending connection invitations on the invitation manager page */
async function handleAcceptInvites({ count = 20 }) {
  const sentinel = liActionStart("accept-invites", { count });
  let accepted = 0;

  try {
    for (let attempt = 0; attempt < 3 && accepted < count; attempt++) {
      if (isCancelled()) { addLog("-", `Cancelled at ${accepted}/${count}`); break; }

      const acceptBtns = Array.from(document.querySelectorAll(
        'button[aria-label*="Accept"], button[aria-label*="accept"]'
      )).filter(btn => (btn.getAttribute("aria-label") || "").toLowerCase().includes("accept"));

      if (acceptBtns.length === 0 && attempt > 0) {
        // No more buttons after scrolling — likely done.
        break;
      }

      const remaining = count - accepted;
      const batch = acceptBtns.slice(0, remaining);

      const batchResult = await cancellableBatch(batch, {}, async (btn, i) => {
        if (!btn.isConnected) return "skip";
        const label = btn.getAttribute("aria-label") || "";
        btn.click();
        accepted++;
        addLog("V", `Accepted (${accepted}/${count}): ${label.slice(0, 60)}`);
        await delay(800 + Math.random() * 1200);
        return true;
      });

      // Scroll to load more if we still need invites.
      if (accepted < count && !isCancelled()) {
        window.scrollBy(0, 600);
        await delay(1500);
      }
    }

    await liActionEnd(sentinel, "ok", { result: { accepted, requested: count } });
    return { success: true, accepted };
  } catch (err) {
    await liActionEnd(sentinel, "fail", {
      errorCode: liClassifyErrorLocal(err),
      errorMessage: err.message,
      result: { acceptedBeforeError: accepted },
    });
    throw err;
  }
}

/**
 * Snapshot every visible pending invitation on /received/, then click Accept on
 * each. Returns per-invitation info (name, publicIdentifier, customMessage,
 * accepted) so callers can DM them via the HTTP messaging endpoint.
 *
 * Snapshot-then-click is mandatory: clicking Accept removes the card from the
 * DOM, so a live `querySelectorAll` loop breaks after the first click.
 *
 * We avoid the Voyager `http-accept-invite` endpoint entirely — LinkedIn
 * migrated that surface to a Server-Driven UI endpoint and the legacy call
 * silently 400s. Clicking the real UI button is the most reliable path.
 */
/**
 * Snapshot every visible pending invitation on /received/ WITHOUT accepting
 * any of them. Same scan logic that handleAcceptAndCollect uses, factored out
 * so the interleaved accept-and-dm flow can re-scan between iterations.
 *
 * Pass `scroll: false` to skip the scroll-to-load dance on follow-up scans
 * after the first — the cards we want are already in the DOM by then and
 * scrolling around mid-batch creates a janky UI.
 */
/**
 * Walk up from an element to find the nearest ancestor with vertical scroll.
 * LinkedIn's invitation manager wraps the list in a scrollable container —
 * `window.scrollTo` doesn't trigger the container's infinite-scroll handler.
 * Returns null if no scrollable ancestor exists (fall back to window scroll).
 */
function _findScrollableAncestor(el) {
  let node = el?.parentElement;
  while (node && node !== document.body && node !== document.documentElement) {
    try {
      const style = window.getComputedStyle(node);
      const overflowY = style.overflowY;
      if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight + 10) {
        return node;
      }
    } catch { /* ignore */ }
    node = node.parentElement;
  }
  return null;
}

/**
 * Aggressive scroll-to-load. Triggers LinkedIn's infinite-scroll lazy-load
 * by scrolling BOTH the page AND the scrollable list container. Some
 * LinkedIn surfaces watch one or the other; doing both is belt-and-suspenders.
 *
 * Returns the new total count of accept buttons after the scroll, so
 * callers can detect "loaded more" vs "hit the bottom".
 */
async function _scrollToLoadMore({ waitMs = 1500, returnToTop = false } = {}) {
  // Find an anchor element inside the list (any accept button works).
  const anchor = document.querySelector('button[aria-label*="Accept"], button[aria-label*="accept"]');
  const container = anchor ? _findScrollableAncestor(anchor) : null;

  // Scroll both the container (if any) and the window.
  if (container) {
    container.scrollTop = container.scrollHeight;
  }
  window.scrollTo(0, document.body.scrollHeight);
  // Also trigger a wheel event near the bottom — some infinite-scroll
  // implementations listen to wheel rather than scroll position.
  try {
    document.documentElement.dispatchEvent(new WheelEvent("wheel", { deltaY: 1000, bubbles: true }));
  } catch { /* ignore */ }

  await delay(waitMs);

  if (returnToTop) {
    if (container) container.scrollTop = 0;
    window.scrollTo(0, 0);
    await delay(300);
  }

  return document.querySelectorAll(
    'button[aria-label*="Accept"], button[aria-label*="accept"]'
  ).length;
}

async function scanPendingInvitations({ scroll = true } = {}) {
  if (scroll) {
    // Aggressive scroll-to-load (r20): LinkedIn's invitation-manager uses
    // infinite scroll inside a SCROLLABLE LIST CONTAINER, not the page body.
    // Pre-r20 we scrolled `window` only — that worked sometimes but missed
    // the container-scoped infinite-scroll trigger most of the time, leaving
    // us with just the initial ~10 invitations loaded regardless of scroll.
    //
    // _scrollToLoadMore scrolls BOTH the page and the list container (and
    // fires a wheel event to trigger any wheel-based listeners). Loop until
    // the accept-button count stops growing.
    await delay(600);
    let lastBtnCount = 0;
    for (let pass = 0; pass < 15; pass++) {
      const currentBtnCount = await _scrollToLoadMore({ waitMs: 1500, returnToTop: false });
      if (pass > 0 && currentBtnCount === lastBtnCount) {
        // No growth on this pass — we've hit the end of the list.
        break;
      }
      lastBtnCount = currentBtnCount;
    }
    // Some LinkedIn variants show a "Show more" button instead of pure
    // infinite scroll. Click it if present. (NOTE: `:text-is(...)` is a
    // Playwright selector, NOT valid CSS — querySelector throws on it.
    // We use the aria-label selector plus a text-content fallback below.)
    const showMoreBtn = document.querySelector('button[aria-label*="Show more" i]')
      || (() => {
        const all = document.querySelectorAll('main button');
        for (const b of all) {
          const txt = (b.innerText || b.textContent || "").trim();
          if (/^Show more$/i.test(txt) || /^See more$/i.test(txt)) return b;
        }
        return null;
      })();
    if (showMoreBtn) {
      try {
        showMoreBtn.click();
        await delay(1500);
      } catch { /* ignore */ }
    }
    // Scroll back to top so click ordering matches visible top-down order.
    window.scrollTo(0, 0);
    await delay(500);
  }

  const buttons = Array.from(document.querySelectorAll(
    'button[aria-label*="Accept"], button[aria-label*="accept"]'
  )).filter(btn => {
    const label = (btn.getAttribute("aria-label") || "").toLowerCase();
    return label.includes("accept") && label.includes("invitation");
  });

  const snapshot = [];
  for (const btn of buttons) {
    const label = btn.getAttribute("aria-label") || "";
    // The "'s" possessive uses straight or curly apostrophes; names ending in
    // "s" use apostrophe-only ("Andrew Rollins' invitation"). Match either.
    const nameMatch = label.match(/Accept\s+(.+?)(?:[''']s?)?\s+invitation/i);
    let name = nameMatch ? nameMatch[1].trim().replace(/['']$/, "") : null;

    // 2026 LinkedIn hashes the card wrapper classes (no more "invitation-card"),
    // but the <li> wrapper is still semantic. data-view-name is sometimes
    // present on legacy variants. Fall back to walking up if neither works.
    const card = btn.closest('li, article, [data-view-name*="invitation"], [componentkey]') || btn.parentElement;

    if (!name && card) {
      const nameLink = card.querySelector('a[href*="/in/"] span[aria-hidden="true"], a[href*="/in/"]');
      if (nameLink) name = (nameLink.textContent || "").trim().split("\n")[0].trim();
    }
    if (!name) name = "Unknown";

    // ── Extract publicIdentifier (slug) and/or URN ─────────────────
    // LinkedIn restructured invitation cards in mid-2026; the immediate `card`
    // element no longer always contains the /in/<slug> profile link (it's
    // hoisted to a wrapping ancestor or nested deeper inside a sibling).
    // Strategy: walk up the DOM up to 5 levels searching for a profile link,
    // AND in parallel look for a member URN in data attributes (often present
    // even when the link is missing). Either path is sufficient to send a DM.
    let publicIdentifier = null;
    let urn = null;

    let scope = card;
    for (let i = 0; i < 5 && scope && scope !== document.body; i++) {
      if (!publicIdentifier) {
        const profileLink = scope.querySelector('a[href*="/in/"]');
        const href = profileLink?.getAttribute("href") || "";
        const m = href.match(/\/in\/([^\/?#]+)/);
        if (m) publicIdentifier = decodeURIComponent(m[1]);
      }
      if (!urn) {
        const urnEl = scope.querySelector(
          '[data-entity-urn*="member"], [data-entity-urn*="fsd_profile"], ' +
          '[data-urn*="member"], [data-urn*="fsd_profile"], ' +
          '[data-test-invitation*="urn"]'
        );
        if (urnEl) {
          const dataUrn = urnEl.getAttribute("data-entity-urn") ||
                          urnEl.getAttribute("data-urn") ||
                          urnEl.getAttribute("data-test-invitation") || "";
          const fsdMatch = dataUrn.match(/fsd_profile:([A-Za-z0-9_-]+)/);
          const memMatch = dataUrn.match(/member:(\d+)/);
          if (fsdMatch) urn = `urn:li:fsd_profile:${fsdMatch[1]}`;
          else if (memMatch) urn = `urn:li:fsd_profile:${memMatch[1]}`;
        }
      }
      if (publicIdentifier && urn) break;
      scope = scope.parentElement;
    }

    // If we still have nothing, dump a sample of the card's HTML to the
    // console so we can see what LinkedIn is currently rendering. The user
    // can paste this to debug a selector miss.
    if (!publicIdentifier && !urn && card) {
      const sample = (card.outerHTML || "").slice(0, 600);
      console.warn(
        `[Nexus] No slug/URN found in invitation card for "${name}". ` +
        `LinkedIn may have rotated the card structure. Card sample:`,
        sample,
      );
    }

    let customMessage = null;
    if (card) {
      // Class-based selectors (.invitation-card__custom-message,
      // class*="custom-message") are dead in 2026 — LinkedIn hashed the
      // wrapper classes. <blockquote> still works for visible note text
      // since LinkedIn semantically marks the inviter's message as a quote.
      const msgEl =
        card.querySelector('blockquote') ||
        card.querySelector('p[class*="message"]') ||
        card.querySelector('q');
      if (msgEl) {
        const t = (msgEl.textContent || "").trim();
        if (t) customMessage = t;
      }
    }

    snapshot.push({ name, publicIdentifier, urn, customMessage, btn });
  }

  return snapshot;
}

async function handleAcceptAndCollect({ count = 15, skipNoMsg = false, clickDelayMs = 700 } = {}) {
  // Let lazy-loaded cards render and trigger a couple of scrolls to surface more.
  await delay(800);
  window.scrollBy(0, 800);
  await delay(600);
  window.scrollBy(0, 800);
  await delay(600);
  window.scrollTo(0, 0);
  await delay(400);

  // Snapshot. The Accept button's aria-label has been stable for years; use it
  // as the anchor. Walk up to the enclosing card for sender info.
  const buttons = Array.from(document.querySelectorAll(
    'button[aria-label*="Accept"], button[aria-label*="accept"]'
  )).filter(btn => {
    const label = (btn.getAttribute("aria-label") || "").toLowerCase();
    return label.includes("accept") && label.includes("invitation");
  });

  const snapshot = [];
  for (const btn of buttons) {
    const label = btn.getAttribute("aria-label") || "";
    // "Accept John Doe's invitation" — capture the name. Handles straight and
    // curly apostrophes, and an optional possessive "s".
    const nameMatch = label.match(/Accept\s+(.+?)(?:[''']s)?\s+invitation/i);
    let name = nameMatch ? nameMatch[1].trim() : null;

    // 2026 LinkedIn hashes the card wrapper classes (no more "invitation-card"),
    // but the <li> wrapper is still semantic. data-view-name is sometimes
    // present on legacy variants. Fall back to walking up if neither works.
    const card = btn.closest('li, article, [data-view-name*="invitation"], [componentkey]') || btn.parentElement;

    // Fallback: pick the name from the card's primary profile link text.
    if (!name && card) {
      const nameLink = card.querySelector('a[href*="/in/"] span[aria-hidden="true"], a[href*="/in/"]');
      if (nameLink) name = (nameLink.textContent || "").trim().split("\n")[0].trim();
    }
    if (!name) name = "Unknown";

    let publicIdentifier = null;
    if (card) {
      const profileLink = card.querySelector('a[href*="/in/"]');
      const href = profileLink?.getAttribute("href") || "";
      const m = href.match(/\/in\/([^\/?#]+)/);
      if (m) publicIdentifier = decodeURIComponent(m[1]);
    }

    // 2026: class*="custom-message" / "message-text" no longer work
    // (LinkedIn hashed the classes). blockquote/q/p[class*="message"]
    // are the remaining semantic anchors that survived the rewrite.
    let customMessage = null;
    if (card) {
      const msgEl =
        card.querySelector('blockquote') ||
        card.querySelector('q') ||
        card.querySelector('p[class*="message"]');
      if (msgEl) {
        const t = (msgEl.textContent || "").trim();
        if (t) customMessage = t;
      }
    }

    snapshot.push({ name, publicIdentifier, customMessage, btn });
  }

  // Apply skip-no-note filter, then cap.
  const results = [];
  const queue = [];
  for (const entry of snapshot) {
    if (skipNoMsg && !entry.customMessage) {
      results.push({ name: entry.name, publicIdentifier: entry.publicIdentifier, customMessage: null, accepted: false, reason: "skipped (no note)" });
      continue;
    }
    if (queue.length >= count) {
      results.push({ name: entry.name, publicIdentifier: entry.publicIdentifier, customMessage: entry.customMessage, accepted: false, reason: "skipped (over max)" });
      continue;
    }
    queue.push(entry);
  }

  // Click sequentially. Each Accept removes its card from the DOM.
  for (const entry of queue) {
    try {
      if (!entry.btn || !entry.btn.isConnected) {
        results.push({ name: entry.name, publicIdentifier: entry.publicIdentifier, customMessage: entry.customMessage, accepted: false, reason: "button detached before click" });
        continue;
      }
      entry.btn.click();
      results.push({ name: entry.name, publicIdentifier: entry.publicIdentifier, customMessage: entry.customMessage, accepted: true });
      await delay(clickDelayMs + Math.random() * 400);
    } catch (err) {
      results.push({ name: entry.name, publicIdentifier: entry.publicIdentifier, customMessage: entry.customMessage, accepted: false, reason: `click failed: ${err.message}` });
    }
  }

  return { success: true, found: snapshot.length, results };
}

/** Withdraw sent invitations on the sent invitations page */
async function handleWithdrawInvites({ count = 20 }) {
  const sentinel = liActionStart("withdraw-invites", { count });
  let withdrawn = 0;

  try {
    for (let attempt = 0; attempt < 3 && withdrawn < count; attempt++) {
      if (isCancelled()) { addLog("-", `Cancelled at ${withdrawn}/${count}`); break; }

      const withdrawBtns = Array.from(document.querySelectorAll(
        'button[aria-label*="Withdraw"], button[aria-label*="withdraw"]'
      ));

      if (withdrawBtns.length === 0 && attempt > 0) break;

      const remaining = count - withdrawn;
      const batch = withdrawBtns.slice(0, remaining);

      await cancellableBatch(batch, {}, async (btn, i) => {
        if (!btn.isConnected) return "skip";
        btn.click();
        await delay(500);

        // LinkedIn shows a confirmation dialog — click confirm.
        await delay(500);
        const modalBtns = document.querySelectorAll('button.artdeco-button--primary');
        let confirmed = false;
        for (const mb of modalBtns) {
          if (mb.textContent?.trim().toLowerCase() === "withdraw") {
            mb.click();
            confirmed = true;
            break;
          }
        }
        if (!confirmed) {
          // Modal didn't appear or button label changed; still count as
          // attempted but log a warn so we can see selector drift in the dash.
          addLog("!", `Withdraw confirm modal missing — invite may not be fully withdrawn`);
        }

        withdrawn++;
        addLog("V", `Withdrew (${withdrawn}/${count})`);
        await delay(800 + Math.random() * 1000);
        return true;
      });

      if (withdrawn < count && !isCancelled()) {
        window.scrollBy(0, 600);
        await delay(1500);
      }
    }

    await liActionEnd(sentinel, "ok", { result: { withdrawn, requested: count } });
    return { success: true, withdrawn };
  } catch (err) {
    await liActionEnd(sentinel, "fail", {
      errorCode: liClassifyErrorLocal(err),
      errorMessage: err.message,
      result: { withdrawnBeforeError: withdrawn },
    });
    throw err;
  }
}

/** Follow connections from the "My Network" or connections list page */
async function handleFollowConnections({ count = 20 }) {
  let followed = 0;

  for (let attempt = 0; attempt < 5 && followed < count; attempt++) {
    const followBtns = document.querySelectorAll(
      'button[aria-label*="Follow"], button[aria-label*="follow"]'
    );

    for (const btn of followBtns) {
      if (followed >= count) break;
      const label = btn.getAttribute("aria-label") || "";
      // Skip "Unfollow" buttons
      if (label.toLowerCase().includes("unfollow")) continue;
      btn.click();
      followed++;
      await delay(1000 + Math.random() * 1500);
    }

    if (followed < count) {
      window.scrollBy(0, 600);
      await delay(2000);
    }
  }

  return { success: true, followed };
}

/** Endorse skills on the current profile page */
async function handleEndorseSkills({ count = 3 }) {
  let endorsed = 0;

  // Scroll to skills section
  const skillsSection = document.querySelector('section#skills, section[class*="skills"]');
  if (skillsSection) {
    skillsSection.scrollIntoView({ behavior: "smooth" });
    await delay(1000);
  }

  // Find endorse buttons (the + button next to skills)
  const endorseBtns = document.querySelectorAll(
    'button[aria-label*="Endorse"], button[aria-label*="endorse"]'
  );

  for (const btn of endorseBtns) {
    if (endorsed >= count) break;
    const label = btn.getAttribute("aria-label") || "";
    if (label.toLowerCase().includes("endorse")) {
      btn.click();
      endorsed++;
      await delay(800 + Math.random() * 1000);
    }
  }

  return { success: true, endorsed };
}

/** View profiles (just scrolls through search results clicking each one) */
async function handleViewProfiles({ count = 10 }) {
  // Already on search results — extract profile links and return them
  // The background.js will navigate to each one with a delay
  const links = document.querySelectorAll('a[href*="/in/"]');
  const seen = new Set();
  const profiles = [];

  links.forEach(a => {
    const url = a.href.split("?")[0];
    if (seen.has(url) || !url.includes("/in/")) return;
    seen.add(url);
    const name = a.querySelector('span[aria-hidden="true"]')?.textContent?.trim()
      || a.textContent?.trim()?.split("\n")[0]?.trim() || "";
    if (name && url) profiles.push({ name, url });
  });

  return { success: true, profiles: profiles.slice(0, count) };
}

/** Message connections from the messaging page */
async function handleMessageConnections({ message, count = 10 }) {
  let sent = 0;

  // We should be on the connections list — extract connection links
  const connectionLinks = document.querySelectorAll('a[href*="/in/"]');
  const seen = new Set();
  const connections = [];
  connectionLinks.forEach(a => {
    const url = a.href.split("?")[0];
    if (seen.has(url) || !url.includes("/in/")) return;
    seen.add(url);
    const name = a.querySelector('span[aria-hidden="true"]')?.textContent?.trim()
      || a.textContent?.trim()?.split("\n")[0]?.trim() || "";
    if (name) connections.push({ name, url });
  });

  return { success: true, connections: connections.slice(0, count), message };
}

/** Send a message to a specific connection on their profile page */
async function handleSendMessage({ message }) {
  const sentinel = liActionStart("send-message", { messageLength: message?.length || 0 });
  if (!message) {
    await liActionEnd(sentinel, "fail", { errorCode: "missing_params", errorMessage: "no message provided" });
    return { success: false, reason: "no_message" };
  }

  // ── Path 1: in-place DOM send (no new tab) ─────────────────────
  // Click the Message button on the current profile, type into the
  // slide-out compose drawer, click Send. If LinkedIn served this user
  // the in-page messaging widget, this is the fastest path.
  let domError = null;
  try {
    const msgBtn = document.querySelector('button[aria-label*="Message"]');
    if (!msgBtn) throw new Error("selector_missing:message_button — no Message button on profile");

    msgBtn.click();
    await delay(2000);

    const msgInput = document.querySelector(
      'div.msg-form__contenteditable p, div.msg-form__contenteditable, div[class*="msg-form"] div[contenteditable="true"]'
    );
    if (!msgInput) throw new Error("selector_missing:msg_input — compose drawer didn't open");

    msgInput.focus();
    msgInput.textContent = "";
    await humanType(msgInput, message);
    await delay(500);

    const sendBtn = document.querySelector(
      'button.msg-form__send-button, button[class*="msg-form__send"], button[type="submit"][class*="msg-form"]'
    ) || findButtonByText(document, "Send");
    if (!sendBtn) throw new Error("selector_missing:send_button — drawer Send button missing");

    sendBtn.click();
    await delay(1500);
    addLog("V", "Message sent (DOM)");
    await liActionEnd(sentinel, "ok", { result: { path: "dom" } });
    return { success: true, path: "dom" };
  } catch (err) {
    domError = err.message;
    addLog("-", `DOM send failed: ${domError} — trying hidden-tab fallback`);
  }

  // ── Path 2: hidden-tab fallback via background coordinator ─────
  // Reuses the same li-send-dm-via-ui machinery as accept-and-dm. Opens
  // a background tab to /messaging/thread/new/?recipient=URN, types + sends
  // there, closes the tab. Requires the slug from the current URL.
  const slugMatch = window.location.pathname.match(/\/in\/([^\/?#]+)/);
  if (!slugMatch) {
    await liActionEnd(sentinel, "fail", {
      errorCode: "no_slug",
      errorMessage: `DOM failed (${domError}) and current URL is not a profile — can't fall back`,
    });
    return { success: false, reason: "no_slug_for_fallback", domError };
  }

  try {
    const fallbackResult = await new Promise((resolve) => {
      chrome.runtime.sendMessage({
        action: "li-send-dm-via-ui",
        slug: decodeURIComponent(slugMatch[1]),
        message,
      }, (response) => {
        const err = chrome.runtime.lastError;
        if (err) resolve({ success: false, error: err.message, errorCode: "runtime_error" });
        else resolve(response || { success: false, error: "no response from background" });
      });
    });

    if (fallbackResult?.success) {
      addLog("V", "Message sent (hidden-tab fallback)");
      await liActionEnd(sentinel, "ok", { result: { path: "hidden_tab", domError } });
      return { success: true, path: "hidden_tab" };
    }

    await liActionEnd(sentinel, "fail", {
      errorCode: fallbackResult?.errorCode || "fallback_failed",
      errorMessage: `DOM: ${domError} | hidden-tab: ${fallbackResult?.error || "unknown"}`,
    });
    return { success: false, reason: "both_paths_failed", domError, fallbackError: fallbackResult?.error };
  } catch (err) {
    await liActionEnd(sentinel, "fail", {
      errorCode: liClassifyErrorLocal(err),
      errorMessage: `DOM: ${domError} | hidden-tab threw: ${err.message}`,
    });
    return { success: false, reason: "fallback_threw", domError };
  }
}

/** Check for unread messages and extract them */
async function handleCheckMessages() {
  // Find unread message threads in the messaging sidebar.
  // Verified 2026-05-26 via Puppeteer diagnostic: `msg-conversation-listitem`
  // is in LinkedIn's stable class set (122 matches on a normal inbox).
  const threads = document.querySelectorAll(
    'li.msg-conversation-listitem, div[class*="msg-conversation-listitem"]'
  );

  const unread = [];
  for (const thread of threads) {
    // Unread heuristics — `[class*="unread"]` still works (11 matches in audit).
    const isUnread = thread.querySelector('[class*="unread"], [class*="notification-badge"]')
      || thread.classList.toString().includes("unread");
    if (!isUnread) continue;

    // Name: `participant-name` is dead in 2026; h3 still works as the
    // semantic anchor for the sender name in the conversation card.
    const nameEl = thread.querySelector('h3');
    // Preview: `message-snippet` survived (10 matches); a generic <p>
    // fallback as a last resort.
    const previewEl = thread.querySelector('p[class*="message-snippet"]') || thread.querySelector('p');
    const name = nameEl?.textContent?.trim() || "Unknown";
    const preview = previewEl?.textContent?.trim() || "";

    unread.push({ name, preview: preview.slice(0, 100) });
  }

  return { success: true, unread, count: unread.length };
}

/** Reply to the currently open message thread */
async function handleReplyMessage({ message }) {
  const sentinel = liActionStart("reply-message", { messageLength: message?.length || 0 });
  if (!message) {
    await liActionEnd(sentinel, "fail", { errorCode: "missing_params", errorMessage: "no message provided" });
    return { success: false, reason: "no_message" };
  }

  // ── Path 1: in-place DOM reply in the open thread ──────────────
  let domError = null;
  try {
    const msgInput = document.querySelector(
      'div.msg-form__contenteditable p, div.msg-form__contenteditable, div[class*="msg-form"] div[contenteditable="true"]'
    );
    if (!msgInput) throw new Error("selector_missing:msg_input — thread compose box missing");

    msgInput.focus();
    msgInput.textContent = "";
    await humanType(msgInput, message);
    await delay(500);

    const sendBtn = document.querySelector(
      'button.msg-form__send-button, button[class*="msg-form__send"]'
    ) || findButtonByText(document, "Send");
    if (!sendBtn) throw new Error("selector_missing:send_button — thread Send button missing");

    sendBtn.click();
    await delay(1500);
    addLog("V", "Reply sent (DOM)");
    await liActionEnd(sentinel, "ok", { result: { path: "dom" } });
    return { success: true, path: "dom" };
  } catch (err) {
    domError = err.message;
    addLog("-", `DOM reply failed: ${domError} — trying hidden-tab fallback`);
  }

  // ── Path 2: hidden-tab fallback ────────────────────────────────
  // We need the recipient URN to re-open the thread in a new tab. LinkedIn
  // thread pages put the recipient URN in various places — try a few:
  //   - URL pattern /messaging/thread/<URN>
  //   - <link rel="canonical"> with the URN encoded
  //   - data-urn attributes on conversation header elements
  let recipientUrn = null;

  // Try URL path segment.
  const urlMatch = window.location.pathname.match(/\/messaging\/thread\/([^\/?#]+)/);
  if (urlMatch && urlMatch[1].includes("fsd_profile")) {
    recipientUrn = decodeURIComponent(urlMatch[1]);
  }

  // Try data-urn on the conversation header / participant card.
  if (!recipientUrn) {
    const urnEl = document.querySelector('[data-urn*="fsd_profile"], [data-entity-urn*="fsd_profile"], [data-urn*="member"]');
    if (urnEl) {
      const raw = urnEl.getAttribute("data-urn") || urnEl.getAttribute("data-entity-urn") || "";
      const m = raw.match(/fsd_profile:([A-Za-z0-9_-]+)/) || raw.match(/member:(\d+)/);
      if (m) recipientUrn = `urn:li:fsd_profile:${m[1]}`;
    }
  }

  // Last resort: an /in/<slug> link in the thread header.
  let slugFromHeader = null;
  if (!recipientUrn) {
    const headerLink = document.querySelector('div[class*="msg-thread"] a[href*="/in/"], section[class*="messaging"] a[href*="/in/"]');
    const href = headerLink?.getAttribute("href") || "";
    const m = href.match(/\/in\/([^\/?#]+)/);
    if (m) slugFromHeader = decodeURIComponent(m[1]);
  }

  if (!recipientUrn && !slugFromHeader) {
    await liActionEnd(sentinel, "fail", {
      errorCode: "no_recipient",
      errorMessage: `DOM failed (${domError}) and could not extract recipient URN/slug from thread page`,
    });
    return { success: false, reason: "no_recipient_for_fallback", domError };
  }

  try {
    const fallbackResult = await new Promise((resolve) => {
      chrome.runtime.sendMessage({
        action: "li-send-dm-via-ui",
        slug: slugFromHeader || null,
        urn: recipientUrn || null,
        message,
      }, (response) => {
        const err = chrome.runtime.lastError;
        if (err) resolve({ success: false, error: err.message, errorCode: "runtime_error" });
        else resolve(response || { success: false, error: "no response from background" });
      });
    });

    if (fallbackResult?.success) {
      addLog("V", "Reply sent (hidden-tab fallback)");
      await liActionEnd(sentinel, "ok", { result: { path: "hidden_tab", domError } });
      return { success: true, path: "hidden_tab" };
    }

    await liActionEnd(sentinel, "fail", {
      errorCode: fallbackResult?.errorCode || "fallback_failed",
      errorMessage: `DOM: ${domError} | hidden-tab: ${fallbackResult?.error || "unknown"}`,
    });
    return { success: false, reason: "both_paths_failed", domError, fallbackError: fallbackResult?.error };
  } catch (err) {
    await liActionEnd(sentinel, "fail", {
      errorCode: liClassifyErrorLocal(err),
      errorMessage: `DOM: ${domError} | hidden-tab threw: ${err.message}`,
    });
    return { success: false, reason: "fallback_threw", domError };
  }
}

/** Extract the last message from the current open thread (for AI auto-reply) */
async function handleExtractLastMessage() {
  const messages = document.querySelectorAll(
    'div.msg-s-event-listitem, li[class*="msg-s-message-list__event"]'
  );
  if (messages.length === 0) return { success: false, reason: "no_messages" };

  const last = messages[messages.length - 1];
  const senderEl = last.querySelector('span[class*="msg-s-message-group__name"], span[class*="actor-name"]');
  const textEl = last.querySelector('p[class*="msg-s-event-listitem__body"], div[class*="msg-s-event-listitem__body"]');
  const sender = senderEl?.textContent?.trim() || "Unknown";
  const text = textEl?.textContent?.trim() || "";

  return { success: true, sender, text, isOwnMessage: false }; // TODO: detect if it's own message
}

async function handlePing() {
  return {
    ready: true,
    url: window.location.href,
    isLinkedIn: window.location.hostname.includes("linkedin.com"),
  };
}

// ── DOM-driven inbox scrape (replaces deprecated Voyager inbox endpoint) ──
//
// LinkedIn killed /voyager/api/messaging/conversations in early 2026 — the
// server-side linkedin-inbox-scanner.js has been 1h-cooldowning on 400s ever
// since. This handler runs in a hidden tab on /messaging/ and scrapes the
// conversation list directly, mirroring the data the old scanner produced
// so the bot's upsert logic doesn't need to change.
//
// Selectors prioritize semantic (role="listitem", data-test-*) over class-
// based names because LinkedIn rotates class hashes regularly.

const _INBOX_LIST_ITEM_SELECTORS = [
  'li.msg-conversation-listitem',
  'div[class*="msg-conversation-listitem"]',
  'li[data-view-name*="messaging-conversation"]',
  'div[class*="conversation-card"]',
];

// Verified 2026-05-26 via Puppeteer diagnostic (scripts/test-li-everything.js
// + snapshot #15). The `msg-conversation-card__*` family survives LinkedIn's
// class-name hashing because they're operational (JS-attached) classes;
// `participant-name` / `conversation-card__title` were cosmetic and DID get
// hashed. h3 is the reliable semantic anchor for sender name (11 matches).
const _INBOX_NAME_SELECTORS = [
  'h3.msg-conversation-card__participant-names span',
  'h3 span',
  'h3',
];

const _INBOX_PREVIEW_SELECTORS = [
  'p.msg-conversation-card__message-snippet',
  'p[class*="message-snippet"]', // verified: 10 matches in audit
];

const _INBOX_TIME_SELECTORS = [
  'time.msg-conversation-card__time-stamp',
  'time[class*="time-stamp"]', // verified: 10 matches in audit
  'time',
];

function _firstMatchIn(scope, selectors) {
  for (const s of selectors) {
    try {
      const el = scope.querySelector(s);
      if (el) return el;
    } catch { /* invalid selector — skip */ }
  }
  return null;
}

/**
 * Scrape visible conversations from /messaging/. Returns the same shape the
 * old Voyager scanner produced so the bot-side ingest code stays unchanged:
 *   { conversations: [{ conversationId, senderName, senderUrn,
 *                       senderProfileUrl, lastMessage, lastActivityAt,
 *                       unreadCount, senderHeadline?, senderCompany? }] }
 */
async function handleScrapeInbox({ scrollPasses = 2 } = {}) {
  // We may not be on the messaging page yet — the background coordinator
  // navigates the hidden tab then sends us this message. Sanity-check.
  if (!window.location.pathname.includes("/messaging/")) {
    return { success: false, error: `not_on_messaging_page (${window.location.pathname})`, conversations: [] };
  }

  // Scroll the conversation rail to load more items (LinkedIn lazy-loads).
  // The rail container varies — pick the most likely candidates and scroll
  // whichever exists.
  const railSelectors = [
    'ul.msg-conversations-container__conversations-list',
    'ul[class*="conversations-list"]',
    'aside section ul',
  ];
  await delay(1500);
  for (let pass = 0; pass < scrollPasses; pass++) {
    const rail = _firstMatchIn(document, railSelectors);
    if (rail) {
      rail.scrollTop = rail.scrollHeight;
    } else {
      window.scrollBy(0, 400);
    }
    await delay(900);
  }

  // Collect list items.
  let items = [];
  for (const sel of _INBOX_LIST_ITEM_SELECTORS) {
    try {
      const found = Array.from(document.querySelectorAll(sel));
      if (found.length > 0) { items = found; break; }
    } catch { /* skip */ }
  }
  if (items.length === 0) {
    console.warn("[Nexus] handleScrapeInbox: no conversation list items found. /messaging/ DOM may have changed.");
    return { success: false, error: "no_list_items", conversations: [] };
  }

  const conversations = [];
  for (const item of items) {
    try {
      // Conversation ID — used by bot for dedup. Try a few sources.
      let conversationId = item.getAttribute("data-conversation-urn")
        || item.getAttribute("data-test-conversation-thread-id")
        || item.getAttribute("data-urn")
        || null;
      // Some variants use an anchor href like /messaging/thread/<id>
      if (!conversationId) {
        const a = item.querySelector('a[href*="/messaging/thread/"]');
        const href = a?.getAttribute("href") || "";
        const m = href.match(/\/messaging\/thread\/([^\/?#]+)/);
        if (m) conversationId = decodeURIComponent(m[1]);
      }
      // Last-resort fallback: synthesize from sender name + preview snippet
      // (stable enough for dedup against the same listing render).
      // We'll suffix with a timestamp below once we extract it.

      const nameEl = _firstMatchIn(item, _INBOX_NAME_SELECTORS);
      const senderName = (nameEl?.textContent || "").trim().split("\n")[0].trim() || null;
      if (!senderName) continue; // can't process anonymous rows

      const previewEl = _firstMatchIn(item, _INBOX_PREVIEW_SELECTORS);
      const lastMessage = (previewEl?.textContent || "").trim();

      const timeEl = _firstMatchIn(item, _INBOX_TIME_SELECTORS);
      const lastActivityAt = timeEl?.getAttribute("datetime") || timeEl?.textContent?.trim() || null;

      // Unread heuristic: badge element OR class contains "unread".
      const isUnread = !!item.querySelector('[class*="unread"], [class*="notification-badge"]')
        || (item.className || "").includes("unread");
      const unreadCount = isUnread ? 1 : 0;

      // Sender profile URL from any /in/<slug> link in the row.
      const profileLink = item.querySelector('a[href*="/in/"]');
      const profileHref = profileLink?.getAttribute("href") || "";
      const profileMatch = profileHref.match(/\/in\/([^\/?#]+)/);
      const senderProfileUrl = profileMatch
        ? `https://www.linkedin.com/in/${profileMatch[1]}/`
        : null;
      const slug = profileMatch ? decodeURIComponent(profileMatch[1]) : null;

      // Sender URN from data attributes (often present on the profile pic or row).
      let senderUrn = null;
      const urnEl = item.querySelector(
        '[data-urn*="member"], [data-urn*="fsd_profile"], ' +
        '[data-entity-urn*="member"], [data-entity-urn*="fsd_profile"]'
      );
      if (urnEl) {
        const raw = urnEl.getAttribute("data-urn") || urnEl.getAttribute("data-entity-urn") || "";
        const m = raw.match(/fsd_profile:([A-Za-z0-9_-]+)/) || raw.match(/member:(\d+)/);
        if (m) senderUrn = `urn:li:fsd_profile:${m[1]}`;
      }

      // Headline / subtitle (when LinkedIn shows it).
      const subtitleEl = item.querySelector('p[class*="participant-detail"], span[class*="subtitle"]');
      const senderHeadline = (subtitleEl?.textContent || "").trim() || null;

      if (!conversationId) {
        // Synthesize a stable-ish id from slug + preview hash + timestamp.
        const hashBase = `${slug || senderName}|${lastMessage}|${lastActivityAt}`;
        let h = 0;
        for (let i = 0; i < hashBase.length; i++) h = ((h << 5) - h + hashBase.charCodeAt(i)) | 0;
        conversationId = `synthetic_${Math.abs(h)}`;
      }

      conversations.push({
        conversationId,
        senderName,
        senderUrn,
        senderProfileUrl,
        senderHeadline,
        senderCompany: null, // not visible in inbox list; bot's headline parser extracts
        lastMessage,
        lastActivityAt, // ISO string or human-readable — bot normalizes
        unreadCount,
      });
    } catch (err) {
      console.warn("[Nexus] handleScrapeInbox: row parse failed:", err.message);
    }
  }

  return { success: true, conversations };
}

// ── DOM-driven message send (OpenOutreach pattern) ───────────────────
//
// LinkedIn's Voyager messaging endpoint (POST /voyager/api/messaging/conversations)
// returns HTTP 400 reliably as of 2026 — same endpoint family that broke the
// inbox scanner. OpenOutreach learned this and uses the DOM as their primary
// message-send path with Voyager as fallback. We mirror that here.
//
// This handler runs in the content script on the LinkedIn messaging thread
// page (/messaging/thread/new/?recipient=URN). The background.js coordinator
// opens that page in a background tab, then sends us a li:type-and-send-message
// runtime message with the text to send. We:
//   1. Wait for the compose textbox to render (4-selector fallback chain)
//   2. Focus and type the message with humanized inter-key delays
//   3. Find the send button (5-selector fallback chain)
//   4. Click send and verify the thread updated
//
// Selectors come directly from OpenOutreach's linkedin/actions/message.py —
// they're battle-tested across LinkedIn's frequent class-name rotations.

const _COMPOSE_INPUT_SELECTORS = [
  'div[role="textbox"][aria-label*="Write a message"]',
  'div[role="textbox"][aria-label*="message" i]',
  'div[class*="msg-form__contenteditable"]',
  'div[contenteditable="true"][role="textbox"]',
  'div[contenteditable="true"]',
];

const _COMPOSE_SEND_SELECTORS = [
  'button[type="submit"][class*="msg-form"]',
  'button[class*="msg-form__send-button"]',
  'button[class*="send-btn"]',
  'button[class*="send-button"]',
  'form button[type="submit"]',
  'button[type="submit"]:not([disabled])',
];

async function _waitForFirstMatch(selectors, timeoutMs = 10000) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    for (const sel of selectors) {
      try {
        const el = document.querySelector(sel);
        if (el && el.offsetParent !== null) return el;
      } catch { /* invalid selector — skip */ }
    }
    await delay(200);
  }
  return null;
}

/**
 * Type `text` into a contenteditable element. Two-path implementation:
 *
 *   Path A (foreground / focused document): execCommand("insertText") with
 *     per-char humanized delays. This fires beforeinput + input + select
 *     events natively, which is the most React-compatible.
 *
 *   Path B (background tab — document.hasFocus() returns false): we cannot
 *     use execCommand because it silently no-ops in unfocused documents
 *     (this is what caused the "accept-and-message only works when I'm
 *     on the page" bug). Instead we directly manipulate DOM via Selection +
 *     Range and dispatch synthetic InputEvent("input") so React picks up
 *     the change. Loses per-char humanization but works in hidden tabs.
 *
 * The fallback is automatic — we detect focus state once and pick a path.
 */
/**
 * Wait until `document.hasFocus()` becomes true, or until `maxWaitMs`
 * elapses. While waiting, sets the extension badge to "PAUSE" so the
 * user can see — from any Chrome window — that an action is parked
 * waiting for focus. Returns true on resume, false on timeout.
 *
 * This is the heart of Option A (build r6). When you switch to another
 * app or Chrome window mid-DM, the typing function calls this between
 * characters and waits politely instead of silently failing.
 */
// ── Autonomous Mode (CDP) typing — works in unfocused / hidden tabs ──
//
// When the user has granted the debugger permission, the background SW
// can call Input.insertText into THIS tab through chrome.debugger. The
// resulting events are flagged isTrusted=true at the browser-process
// level, so React's onChange + Slate's beforeinput handler accept them
// without needing document.hasFocus(). This is the only typing path
// that works while the user is in another window or another Chrome
// tab — every execCommand / synthetic-event path silently no-ops in
// the background.
//
// Caller contract: focus the target element first (compose.focus() +
// caret placement). cdp inserts at the current focused element on the
// tab. Returns { success, ... } — caller treats failure as "fall back
// to focus-required typing".
//
// Cached so we don't burn a storage round-trip per character. The
// cache is per-typing-call (callers read it once at function entry).
async function _isAutonomousModeActive() {
  try {
    const { _liAutonomousMode = false } = await chrome.storage.local.get("_liAutonomousMode");
    return !!_liAutonomousMode;
  } catch { return false; }
}

// r49: active-hours check. Real humans concentrate LinkedIn activity
// in a working window (commonly 9:00-19:00 local). 24/7 batch runs
// are a hard tell. We default to a wide window but let the user
// override via chrome.storage.local._liActiveHours = {startHour, endHour}.
//
// Returns { allowed, hour, startHour, endHour } so the panel can log
// a clear "outside active hours" message.
async function _isWithinActiveHours() {
  let startHour = 8;
  let endHour = 22;
  try {
    const { _liActiveHours } = await chrome.storage.local.get("_liActiveHours");
    if (_liActiveHours && typeof _liActiveHours === "object") {
      if (typeof _liActiveHours.startHour === "number") startHour = _liActiveHours.startHour;
      if (typeof _liActiveHours.endHour === "number") endHour = _liActiveHours.endHour;
    }
  } catch { /* defaults */ }
  const hour = new Date().getHours();
  // Wraparound case (e.g. start=22 end=4) — supported but uncommon.
  const allowed = startHour <= endHour
    ? (hour >= startHour && hour < endHour)
    : (hour >= startHour || hour < endHour);
  return { allowed, hour, startHour, endHour };
}

// r48: detect LinkedIn account-wall surfaces. Fires at the top of
// each batch iteration AND whenever a navigation completes. Returns
// { type, url, details } if a wall is up; null otherwise.
//
// Surfaces detected:
//   checkpoint     — /checkpoint/* page (LinkedIn's "verify it's you")
//   captcha        — captcha image or iframe present
//   restricted     — "We've restricted your account" modal/banner
//   phone_verify   — "Add your phone number" or SMS-verify prompt
//   authwall       — /uas/login or /authwall (session got booted mid-batch)
//
// False positives are cheaper than misses — if anything looks even
// vaguely like a wall, fire the alert. The server-side dedup window
// prevents spam from a flickering selector.
function _detectAccountWall() {
  try {
    const url = window.location.href;
    const pathname = window.location.pathname.toLowerCase();
    // URL-based — fastest, runs first
    if (pathname.includes("/checkpoint/")) {
      return { type: "checkpoint", url, details: { path: pathname } };
    }
    if (pathname.includes("/uas/login") || pathname.includes("/authwall")) {
      return { type: "authwall", url, details: { path: pathname } };
    }
    // Modal / banner text-based detection. We scope to dialog/modal
    // surfaces so we don't false-positive on stray "restricted" text
    // elsewhere on the page.
    const dialogs = document.querySelectorAll(
      'div[role="dialog"]:not([aria-hidden="true"]), .artdeco-modal:not([aria-hidden="true"]), [data-test-modal]'
    );
    for (const d of dialogs) {
      const text = (d.textContent || "").toLowerCase().slice(0, 800);
      if (/(restrict(ed|ion)?|temporarily disabled|account suspended|unusual activity)/i.test(text)) {
        return { type: "restricted", url, details: { snippet: text.slice(0, 200) } };
      }
      if (/(verify (it'?s you|your identity)|prove you'?re|i'?m not a robot)/i.test(text)) {
        return { type: "captcha", url, details: { snippet: text.slice(0, 200) } };
      }
      if (/(add (your )?phone|verify your phone|phone number to keep)/i.test(text)) {
        return { type: "phone_verify", url, details: { snippet: text.slice(0, 200) } };
      }
    }
    // Captcha image / iframe outside a dialog (LinkedIn occasionally
    // inlines reCAPTCHA on the auth page itself).
    const captchaEl = document.querySelector(
      'iframe[src*="recaptcha"], iframe[src*="hcaptcha"], div[class*="captcha"]'
    );
    if (captchaEl) {
      return { type: "captcha", url, details: { selector: "iframe[src*=recaptcha]" } };
    }
    return null;
  } catch {
    return null;
  }
}

// Reports the wall to the bot. Idempotent on the server side (dedup
// within 24h cooldown window per type) so we can call freely.
async function _reportAccountWall(wall) {
  try {
    chrome.runtime.sendMessage({
      action: "li-report-incident",
      incidentType: wall.type,
      url: wall.url,
      details: wall.details,
    });
  } catch { /* best-effort */ }
}

// ── SOFT walls ──────────────────────────────────────────────────────────
// A soft wall is LinkedIn serving 200 OK with a "you've reached the weekly
// invitation limit" banner. No HTTP status carries it, so the 429 path and
// _detectAccountWall (which only looks for checkpoint/captcha surfaces) both
// miss it entirely — we keep firing into a wall and escalate the flag.
//
// Unlike a hard wall, a soft wall is scoped to ONE action category: connect
// requests being capped says nothing about whether messages are still legal.
// So these freeze a single category via /api/linkedin/soft-wall rather than
// pausing the whole account for 24h.
const _SOFT_WALL_PATTERNS = [
  // LinkedIn writes these with a curly apostrophe; _normalizeQuotes handles both.
  { re: /you've reached the weekly invitation limit/i, actionType: "connect_request", hours: 168 },
  { re: /reached the weekly invitation limit/i, actionType: "connect_request", hours: 168 },
  { re: /you're out of invitations for now/i, actionType: "connect_request", hours: 168 },
  { re: /out of invitations for now/i, actionType: "connect_request", hours: 168 },
  { re: /you've reached the (?:monthly|weekly) limit for/i, actionType: null, hours: 168 },
  { re: /no longer send invitations/i, actionType: "connect_request", hours: 168 },
  // Generic throttles — scoped to whatever action tripped them.
  { re: /too many requests/i, actionType: null, hours: 6 },
  { re: /you've reached the maximum number of/i, actionType: null, hours: 24 },
  { re: /try again later/i, actionType: null, hours: 3 },
];

/** LinkedIn mixes ' and U+2019; fold both so one pattern matches either. */
function _normalizeQuotes(s) {
  return String(s || "").replace(/[‘’ʼ]/g, "'");
}

/**
 * Scan the page for a soft wall.
 * @param {string|null} currentActionType category of the action being run, used
 *   for generic throttle phrases that don't name a specific limit.
 * @returns {{actionType: string, hours: number, phrase: string}|null}
 */
function _detectSoftWall(currentActionType = null) {
  try {
    // Body text, not just dialogs: LinkedIn renders invitation limits as an
    // inline banner on /mynetwork as often as it does in a modal.
    const text = _normalizeQuotes(document.body?.innerText || "").slice(0, 20000);
    if (!text) return null;
    for (const p of _SOFT_WALL_PATTERNS) {
      const m = text.match(p.re);
      if (!m) continue;
      const actionType = p.actionType || currentActionType;
      // A generic throttle with no known action context isn't actionable —
      // freezing an arbitrary category would be a guess. Skip rather than guess.
      if (!actionType) continue;
      return { actionType, hours: p.hours, phrase: m[0].slice(0, 120) };
    }
    return null;
  } catch {
    // Detection is advisory; a DOM read failure must not abort the batch.
    return null;
  }
}

/** Freeze one action category server-side. Idempotent — never shortens a freeze. */
async function _reportSoftWall(soft) {
  try {
    chrome.runtime.sendMessage({
      action: "li-report-soft-wall",
      actionType: soft.actionType,
      hours: soft.hours,
      phrase: soft.phrase,
      url: window.location.href,
    });
  } catch { /* best-effort */ }
}

// r47: ask the bot whether the next LinkedIn action is allowed under
// today's daily + weekly caps. r48: also accepts a recipient (slug or
// urn) so the multi-week per-recipient cooldown can block re-DMing
// someone we already contacted recently. Returns `{allowed,
// blockedReason, remainingToday, ageDays, accountAgeMultiplier,
// recipientHit?, cooldownDays?, ...}`. Fails open (allowed:true) when
// the bot is unreachable so a server outage doesn't strand the batch.
async function _checkLiLimit(liAction, opts = {}) {
  const { recipientSlug = null, recipientUrn = null } = opts;
  const useV2 = recipientSlug || recipientUrn;
  return new Promise((resolve) => {
    try {
      chrome.runtime.sendMessage({
        action: useV2 ? "li-limits-check-v2" : "li-limits-check",
        liAction,
        recipientSlug,
        recipientUrn,
      }, (r) => {
        const lastErr = chrome.runtime.lastError;
        if (lastErr) return resolve({ allowed: true, blockedReason: null, _bypass: lastErr.message });
        resolve(r || { allowed: true, blockedReason: null, _bypass: "no_response" });
      });
    } catch (err) {
      resolve({ allowed: true, blockedReason: null, _bypass: err.message });
    }
  });
}

async function _cdpTypeViaBg(text) {
  return new Promise((resolve) => {
    try {
      chrome.runtime.sendMessage({ action: "li-cdp-type", text }, (res) => {
        const lastErr = chrome.runtime.lastError;
        if (lastErr) return resolve({ success: false, error: lastErr.message, errorCode: "runtime_error" });
        resolve(res || { success: false, error: "no response from background", errorCode: "no_response" });
      });
    } catch (err) {
      resolve({ success: false, error: err.message, errorCode: "send_threw" });
    }
  });
}

// Tell background to detach the debugger from this tab. Idempotent —
// safe to call even when no attachment exists. Used by cancellableBatch
// to close out the batch-scoped attachment cleanly, which also
// dismisses the yellow "started debugging this browser" banner.
async function _cdpDetachViaBg() {
  return new Promise((resolve) => {
    try {
      chrome.runtime.sendMessage({ action: "li-cdp-detach" }, (res) => {
        const lastErr = chrome.runtime.lastError;
        if (lastErr) return resolve({ success: false, error: lastErr.message });
        resolve(res || { success: true });
      });
    } catch { resolve({ success: true }); }
  });
}

async function _waitForFocus(maxWaitMs = 300_000) {
  if (document.hasFocus()) return true;
  const start = Date.now();

  // Tell background to set the "PAUSE" badge — visible from any window.
  try {
    chrome.runtime.sendMessage({ type: "li-typing-paused" }).catch(() => {});
  } catch { /* ignore */ }
  addLog("!", "Paused — return to this LinkedIn tab to resume DM typing");

  while (!document.hasFocus()) {
    if (Date.now() - start > maxWaitMs) {
      try { chrome.runtime.sendMessage({ type: "li-typing-resumed" }).catch(() => {}); } catch { /* ignore */ }
      addLog("X", `Pause timeout (${Math.round(maxWaitMs / 60_000)}min) — DM aborted`);
      return false;
    }
    await delay(1000);
  }

  // Focus restored — clear the badge.
  try { chrome.runtime.sendMessage({ type: "li-typing-resumed" }).catch(() => {}); } catch { /* ignore */ }
  addLog("V", `Focus restored after ${Math.round((Date.now() - start) / 1000)}s — resuming typing`);
  return true;
}

/**
 * Detect whether the page is using a Slate.js editor. LinkedIn's 2026
 * messaging compose box IS Slate (confirmed via Puppeteer diagnostic —
 * see snapshot id 3 in linkedin_dom_snapshots). Slate intercepts
 * `beforeinput` events and manages its own internal model. Synthesized
 * execCommand calls write into the DOM but Slate's `value` state stays
 * empty, which is why Send stayed disabled in earlier builds.
 *
 * Detection: look for [data-slate-editor] / [data-slate-node] / class
 * markers. LinkedIn doesn't put the class directly on the compose box
 * but somewhere in the ancestor tree.
 */
function _detectSlateEditor(composeEl) {
  // Check the compose element itself first.
  if (composeEl?.hasAttribute?.("data-slate-editor")) return true;
  if (composeEl?.hasAttribute?.("data-slate-node")) return true;
  if ((composeEl?.className || "").toString().includes("slate")) return true;

  // Walk up the ancestor chain — Slate's marker is often on a wrapper.
  let walk = composeEl?.parentElement;
  for (let i = 0; i < 10 && walk && walk !== document.body; i++) {
    if (walk.hasAttribute("data-slate-editor")) return true;
    if (walk.hasAttribute("data-slate-node")) return true;
    if ((walk.className || "").toString().includes("slate")) return true;
    walk = walk.parentElement;
  }

  // Fall back to a document-wide check.
  try {
    return !!document.querySelector('[data-slate-editor], [data-slate-node]');
  } catch { return false; }
}

/**
 * Slate-specific typing — hardened version (r15).
 *
 * Pure Slate.js intercepts beforeinput and updates its own internal model;
 * a synthetic dispatchEvent would be enough. BUT LinkedIn ships a CUSTOMIZED
 * Slate that may not respond to synthetic-event beforeinput (events without
 * `isTrusted=true` are sometimes ignored).
 *
 * Strategy: dispatch beforeinput FIRST. If Slate's handler accepted it
 * (event.defaultPrevented === true), Slate is handling the insert — we
 * skip execCommand to avoid double-insertion. If Slate did NOT accept it
 * (defaultPrevented === false), we run execCommand as a backup so the
 * character at least lands in the DOM. Then fire input event regardless
 * so React's onChange picks it up.
 *
 * After every 5 chars, verify the text actually landed in the compose.
 * If it didn't, the editor isn't responding to either path — bail early
 * so we can fall back to the profile-page Message approach in background.
 */
async function _typeSlateEditor(el, text) {
  el.focus();
  // Don't pre-clear with execCommand("selectAll")+("delete") — Slate's
  // initial <p><br></p> is the canonical empty state, and execCommand
  // delete corrupts the paragraph structure.

  let lastVerifiedLen = 0;
  for (let i = 0; i < text.length; i++) {
    if (!document.hasFocus()) {
      const got = await _waitForFocus(300_000);
      if (!got) throw new Error("focus_timeout in slate typing");
      el.focus();
    }

    const ch = text[i];

    // Step 1: beforeinput. Slate's handler (if active) will call preventDefault.
    const beforeInputEvent = new InputEvent("beforeinput", {
      inputType: "insertText",
      data: ch,
      bubbles: true,
      cancelable: true,
    });
    const slateAccepted = !el.dispatchEvent(beforeInputEvent); // returns false if preventDefault

    // Step 2: if Slate did NOT accept (defaultPrevented is false), it means
    // either there's no Slate, OR LinkedIn's Slate ignored our synthetic
    // event. In both cases, execCommand("insertText") is the right
    // fallback — it inserts at the current selection and fires its own
    // input event, which Slate's data-binding may pick up via a different
    // listener.
    if (!slateAccepted) {
      try { document.execCommand("insertText", false, ch); } catch { /* ignore */ }
    }

    // Step 3: always fire input event with the data, regardless of which
    // path inserted. React's onChange listeners read from input events.
    el.dispatchEvent(new InputEvent("input", {
      inputType: "insertText",
      data: ch,
      bubbles: true,
    }));

    // Humanized rhythm (same as Path A).
    let d = 30 + Math.random() * 50;
    if (i < 5) d += 50;
    if (/[,.!?;:]/.test(ch)) d += 100 + Math.random() * 200;
    else if (ch === " ") d += 50 + Math.random() * 150;
    else if (/[A-Z]/.test(ch)) d += 20 + Math.random() * 40;
    if (Math.random() < 0.05) d += 400 + Math.random() * 500;
    await delay(d);

    // Step 4: every 5 chars, verify text actually landed. Slate's data
    // model is what enables the Send button — if we typed 5 chars but
    // compose.innerText is still empty, the editor isn't accepting either
    // path. Bail so caller can fall back to profile-page Message.
    if ((i + 1) % 5 === 0) {
      const currentLen = (el.innerText || "").trim().length;
      if (currentLen <= lastVerifiedLen) {
        throw new Error(`slate_typing_unresponsive: ${i + 1} chars typed, compose still has ${currentLen} chars`);
      }
      lastVerifiedLen = currentLen;
    }
  }
}

/**
 * Strong typing fallback for React editors (Lexical, Slate, ProseMirror)
 * that intercept beforeinput and manage their own state. Used when the
 * default execCommand path leaves the Send button disabled.
 *
 * Strategy: dispatch the COMPLETE native event sequence per character —
 * keydown + beforeinput + (manual DOM insert) + input + keyup. Lexical
 * listens to beforeinput and updates its internal state from `data` +
 * `inputType`, which is what we need for React to enable the Send button.
 *
 * We also dispatch a synthetic "selectionchange" since some editors
 * re-validate on selection events.
 */
async function _typeStrongFallback(el, text) {
  el.focus();
  // Place caret at the end so subsequent insertions go in order.
  try {
    const range = document.createRange();
    range.selectNodeContents(el);
    range.collapse(false);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  } catch { /* ignore */ }

  // Autonomous Mode fast path — CDP trusted events bypass focus AND
  // Slate's untrusted-event filter. If the debugger is granted, one
  // call lands the whole message; if not, fall through to per-char.
  if (await _isAutonomousModeActive()) {
    const cdpRes = await _cdpTypeViaBg(text);
    if (cdpRes?.success) {
      await delay(300 + Math.random() * 200);
      return;
    }
    console.warn("[Nexus] Strong-fallback CDP failed, falling back per-char:", cdpRes?.error);
  }

  for (let i = 0; i < text.length; i++) {
    if (!document.hasFocus()) {
      const got = await _waitForFocus(300_000);
      if (!got) throw new Error("focus_timeout in strong fallback");
      el.focus();
    }

    const ch = text[i];
    // 1. keydown — some editors listen to this to track Enter, IME, etc.
    el.dispatchEvent(new KeyboardEvent("keydown", {
      key: ch, code: `Key${ch.toUpperCase()}`, bubbles: true, cancelable: true,
    }));

    // 2. beforeinput — Lexical's primary state-update trigger.
    const beforeInput = new InputEvent("beforeinput", {
      inputType: "insertText", data: ch,
      bubbles: true, cancelable: true,
    });
    const beforeInputAccepted = el.dispatchEvent(beforeInput);

    // 3. If the editor didn't cancel beforeinput (i.e., it expects US to
    // do the DOM insert), manually insert the character. If it WAS cancelled
    // (i.e., the editor will handle the insert), don't double-insert.
    if (beforeInputAccepted && !beforeInput.defaultPrevented) {
      try {
        const sel = window.getSelection();
        if (sel.rangeCount > 0) {
          const range = sel.getRangeAt(0);
          range.deleteContents();
          range.insertNode(document.createTextNode(ch));
          range.collapse(false);
        } else {
          el.appendChild(document.createTextNode(ch));
        }
      } catch { /* ignore */ }
    }

    // 4. input — React's onChange/onInput handler reads from this.
    el.dispatchEvent(new InputEvent("input", {
      inputType: "insertText", data: ch, bubbles: true,
    }));

    // 5. keyup — completes the native sequence.
    el.dispatchEvent(new KeyboardEvent("keyup", {
      key: ch, code: `Key${ch.toUpperCase()}`, bubbles: true, cancelable: true,
    }));

    // Same humanized timing as Path A.
    let d = 30 + Math.random() * 50;
    if (i < 5) d += 50;
    if (/[,.!?;:]/.test(ch)) d += 100 + Math.random() * 200;
    else if (ch === " ") d += 50 + Math.random() * 150;
    else if (/[A-Z]/.test(ch)) d += 20 + Math.random() * 40;
    if (Math.random() < 0.05) d += 400 + Math.random() * 500;
    await delay(d);
  }
}

async function _typeIntoContentEditable(el, text) {
  el.focus();
  const isFocused = document.hasFocus();

  // ── Autonomous Mode fast path ──
  // When the user has granted debugger permission, route the whole
  // string through CDP's Input.insertText. Trusted events bypass both
  // the focus requirement AND Slate's isTrusted filter, so this works
  // identically whether the tab is foreground, background, or hidden.
  // On failure, fall through to the legacy paths so the typing still
  // lands when the debugger isn't available.
  if (await _isAutonomousModeActive()) {
    // Place caret at end so the inserted text appends cleanly.
    try {
      const range = document.createRange();
      range.selectNodeContents(el);
      range.collapse(false);
      const sel = window.getSelection();
      sel.removeAllRanges();
      sel.addRange(range);
    } catch { /* ignore */ }
    const cdpRes = await _cdpTypeViaBg(text);
    if (cdpRes?.success) {
      // Brief settle so React re-renders the Send-button state before
      // the caller queries it.
      await delay(300 + Math.random() * 200);
      return;
    }
    console.warn("[Nexus] CDP typing failed, falling back:", cdpRes?.error || cdpRes?.errorCode || "unknown");
    // Fall through to the legacy paths below.
  }

  // Detect Slate FIRST — LinkedIn's 2026 messaging compose is Slate.js.
  // The execCommand path below silently fails for Slate (DOM mutates but
  // Slate's internal value stays empty → Send button stays disabled).
  // Confirmed via Puppeteer diagnostic + snapshot inspection.
  if (_detectSlateEditor(el)) {
    console.log("[Nexus] Slate editor detected — using beforeinput typing path");
    // Slate's typing still requires focus to behave correctly (the editor's
    // selection/range model needs to be in sync with document focus). If
    // we're in a background tab, wait for focus.
    if (!isFocused) {
      const got = await _waitForFocus(300_000);
      if (!got) throw new Error("focus_timeout before slate typing");
      el.focus();
    }
    await _typeSlateEditor(el, text);
    return;
  }

  if (isFocused) {
    // Path A: focused — use execCommand with humanized per-char delays.
    // The typing pattern matters as much as the delays. Real humans:
    //   - Type fast on common letters, slower on punctuation/uppercase
    //   - Pause briefly between words (~150-400ms at spaces)
    //   - Occasionally pause longer (~500-1200ms) mid-sentence — "thinking"
    //   - Start slightly slower (first 3-5 chars) — "warmup"
    // LinkedIn's anti-bot heuristics watch for uniform-rhythm typing.
    try {
      const range = document.createRange();
      range.selectNodeContents(el);
      const sel = window.getSelection();
      sel.removeAllRanges();
      sel.addRange(range);
      document.execCommand("selectAll", false);
      document.execCommand("delete", false);
    } catch { /* selection APIs flaky — proceed anyway */ }

    for (let i = 0; i < text.length; i++) {
      // Pause-resume guard: if the user switched windows mid-DM, document
      // lost focus and execCommand will silently no-op. Wait politely for
      // focus to return before each character. 5-minute hard timeout.
      if (!document.hasFocus()) {
        const got = await _waitForFocus(300_000);
        if (!got) throw new Error("focus_timeout — waited 5 minutes for tab focus");
        // After resuming, re-focus the contenteditable since focus may have
        // landed elsewhere on the page.
        el.focus();
      }

      const ch = text[i];
      document.execCommand("insertText", false, ch);
      try { el.dispatchEvent(new InputEvent("input", { bubbles: true, data: ch, inputType: "insertText" })); } catch { /* ignore */ }

      // Per-character delay model:
      //   base: 30-80ms (typical sustained typing speed ~150-200 wpm)
      //   warmup: first 5 chars get +50ms each (settling into rhythm)
      //   punct: ,.!? get +100-300ms (real humans pause at punctuation)
      //   space: words boundaries get +50-200ms (less pause than punct)
      //   uppercase: +20-60ms (shift key adds time)
      //   thinking: ~5% chance of a 400-900ms pause anywhere
      let d = 30 + Math.random() * 50;
      if (i < 5) d += 50;
      if (/[,.!?;:]/.test(ch)) d += 100 + Math.random() * 200;
      else if (ch === " ") d += 50 + Math.random() * 150;
      else if (/[A-Z]/.test(ch)) d += 20 + Math.random() * 40;
      if (Math.random() < 0.05) d += 400 + Math.random() * 500;
      await delay(d);
    }
    return;
  }

  // Path B: background tab — execCommand doesn't fire without focus.
  // Manipulate the DOM directly and dispatch React-compatible events.
  //
  // 1. Clear existing content.
  el.innerHTML = "";
  // 2. Insert the full text as a single text node. React's contenteditable
  //    bridge reads the node tree on the input event, so a one-shot insert
  //    is fine even though it skips per-char humanization. (Humanization in
  //    a hidden tab provides no stealth value anyway — LinkedIn can't see
  //    keystroke timing on a hidden tab.)
  const textNode = document.createTextNode(text);
  el.appendChild(textNode);

  // 3. Move the caret to the end of the inserted text so subsequent
  //    selection operations (and any focus-restoring logic in LinkedIn's
  //    compose form) find a sensible cursor position.
  try {
    const range = document.createRange();
    range.selectNodeContents(el);
    range.collapse(false);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  } catch { /* ignore */ }

  // 4. Fire the React-compatible event chain. Order matters:
  //    beforeinput → input → change. React's synthetic-event system listens
  //    primarily to 'input' for contenteditable onChange, but firing
  //    beforeinput first matches the native sequence and reduces the chance
  //    of LinkedIn's anti-spam heuristics rejecting the message.
  try {
    el.dispatchEvent(new InputEvent("beforeinput", {
      bubbles: true, cancelable: true,
      inputType: "insertText", data: text,
    }));
    el.dispatchEvent(new InputEvent("input", {
      bubbles: true,
      inputType: "insertText", data: text,
    }));
    el.dispatchEvent(new Event("change", { bubbles: true }));
  } catch { /* event constructors flaky on very old Chromes */ }

  // 5. Brief settle so React re-renders before the caller clicks Send.
  //    Without this the Send button often remains in its "empty input"
  //    disabled state for ~100ms after we dispatch the input event.
  await delay(300 + Math.random() * 200);
}

/**
 * Build a comprehensive snapshot of the LinkedIn page state for remote
 * diagnostics. Captures EVERYTHING we'd ever want to know to debug a
 * compose/send failure without having to ask the user to paste console
 * output. Uploaded to the bot via _uploadDomSnapshot.
 *
 * What we capture:
 *   - URL, focus state, visibility, viewport
 *   - The matched compose box + send button (HTML + computed info)
 *   - ALL candidate contenteditables on the page (so we can spot wrong-match bugs)
 *   - ALL Send-like buttons on the page
 *   - The current compose contents (what our typing actually produced)
 *   - LinkedIn-specific markers: presence of msg-form / msg-overlay / messaging
 *     class names that hint at which UI variant we're dealing with
 */
function _buildLinkedInDomSnapshot({ triggerEvent, matchedCompose, matchedSendBtn } = {}) {
  const safe = (el, htmlMax = 1500) => {
    if (!el) return null;
    return {
      info: {
        tag: el.tagName,
        classes: el.className?.toString()?.slice(0, 300),
        id: el.id || null,
        ariaLabel: el.getAttribute("aria-label"),
        ariaDisabled: el.getAttribute("aria-disabled"),
        role: el.getAttribute("role"),
        contentEditable: el.getAttribute("contenteditable"),
        disabled: el.disabled,
        innerText: (el.innerText || "").slice(0, 200),
        textContent: (el.textContent || "").slice(0, 200),
        parentTag: el.parentElement?.tagName,
        parentClass: el.parentElement?.className?.toString()?.slice(0, 200),
      },
      html: (el.outerHTML || "").slice(0, htmlMax),
    };
  };

  const allCandidates = (selector, htmlMax = 400) => {
    let nodes;
    try { nodes = Array.from(document.querySelectorAll(selector)); }
    catch { return []; }
    return nodes.slice(0, 8).map(n => safe(n, htmlMax));
  };

  return {
    triggerEvent: triggerEvent || "unknown",
    capturedAt: new Date().toISOString(),
    url: window.location.href,
    pathname: window.location.pathname,
    pageTitle: document.title,
    documentHasFocus: document.hasFocus(),
    documentVisibility: document.visibilityState,
    viewport: { w: window.innerWidth, h: window.innerHeight },
    userAgent: navigator.userAgent,
    bodyClasses: document.body?.className?.toString()?.slice(0, 200),

    // What we matched as the compose/send.
    matchedCompose: safe(matchedCompose, 2000),
    matchedSendBtn: safe(matchedSendBtn, 800),

    // What text actually ended up in the compose after our typing attempts.
    // If this is empty, our typing didn't register at all. If it has text
    // but the Send button is still disabled, React's state didn't update.
    composeContents: matchedCompose ? {
      innerText: (matchedCompose.innerText || "").slice(0, 500),
      innerHTML: (matchedCompose.innerHTML || "").slice(0, 800),
      textNodeCount: matchedCompose.childNodes.length,
    } : null,

    // All candidates — we might have matched the wrong element.
    allContentEditables: allCandidates('[contenteditable="true"], [role="textbox"]', 300),
    allSendLikeButtons: allCandidates('button[type="submit"], button[aria-label*="end" i], button[class*="send"], button[class*="submit"]', 250),

    // LinkedIn UI variant hints — presence of these classes tells us which
    // messaging surface (legacy overlay vs. new full-page) is rendered.
    uiHints: {
      hasMsgForm: !!document.querySelector('[class*="msg-form"]'),
      hasMsgOverlay: !!document.querySelector('[class*="msg-overlay"]'),
      hasMessagingPage: !!document.querySelector('[class*="messaging"]'),
      hasLexicalEditor: !!document.querySelector('[data-lexical-editor], [class*="lexical"]'),
      hasSlateEditor: !!document.querySelector('[data-slate-editor], [class*="slate"]'),
      hasArtdeco: !!document.querySelector('[class*="artdeco"]'),
    },
  };
}

/**
 * Upload a snapshot to the bot's /api/linkedin/dom-snapshot endpoint.
 * Best-effort: if the bot is offline / token missing, fails silently —
 * the snapshot is still saved locally in chrome.storage._nxDmDebug as
 * a backup the user can paste manually.
 */
async function _uploadDomSnapshot(snapshot, triggerEvent, notes = null) {
  // Relay through the background service worker rather than fetching the bot
  // server directly. A content-script fetch from https://www.linkedin.com to
  // http://localhost:9091 is cross-origin and — under Manifest V3 — subject to
  // the *page's* CORS policy (content scripts do NOT inherit the extension's
  // host-permission CORS bypass). The preflight got no Access-Control-Allow-Origin
  // back, so every upload died as "Failed to fetch". The worker holds the
  // localhost host_permission, so its fetch bypasses CORS — exactly how liLog()
  // and every other content→server call already work. The worker also stamps
  // the build version (it owns NEXUS_BUILD), so we no longer round-trip for it.
  try {
    const resp = await new Promise(resolve => {
      try {
        chrome.runtime.sendMessage(
          {
            type: "li-dom-snapshot",
            payload: {
              triggerEvent,
              url: window.location.href,
              userAgent: navigator.userAgent,
              snapshot,
              notes,
            },
          },
          r => resolve(chrome.runtime.lastError ? null : r),
        );
      } catch { resolve(null); }
      setTimeout(() => resolve(null), 12_000);
    });
    if (resp?.ok) {
      console.log(`[Nexus] DOM snapshot uploaded — id ${resp.id}. Run /lidump in Telegram to view, or query the DB.`);
      return true;
    }
    return false;
  } catch (err) {
    console.warn("[Nexus] DOM snapshot upload failed:", err.message);
    return false;
  }
}

/**
 * Profile-page Message fallback. Called by background.js's
 * _sendDmViaProfileMessage when Voyager's URN resolution is down.
 *
 * Flow: we're on /in/<slug>/. Find the "Message" button, click it (this
 * opens a slide-out compose drawer at the bottom-right), wait for the
 * drawer's contenteditable to render, type into it via the Slate-aware
 * _typeIntoContentEditable, then click Send.
 *
 * Same compose-box / send-button selectors as handleTypeAndSendMessage —
 * the drawer renders the same DOM as /messaging/thread/new/.
 */
async function handleProfileMessage({ message, prepOnlyForCdp = false } = {}) {
  // r31: when `prepOnlyForCdp` is true, this function stops after the
  // drawer opens and the compose box is focused — the background then
  // CDP-types into the (already focused) element and calls a separate
  // "click drawer Send" handler. That keeps the focus-required typing
  // path out of the profile flow entirely when Autonomous Mode is on.
  if (!message && !prepOnlyForCdp) return { success: false, error: "missing_message" };

  // 0. Wait for the profile page to actually be done loading. background.js
  // waits on tab.status==="complete" but that fires at DOMContentLoaded,
  // BEFORE React hydrates and the action bar renders. Wait for:
  //   - document.readyState === "complete" (all subresources loaded)
  //   - a <main> element exists (LinkedIn's profile container)
  //   - at least one button is rendered in <main> (action bar present)
  // Up to 15s for slow connections.
  const readyStart = Date.now();
  while (Date.now() - readyStart < 15_000) {
    const complete = document.readyState === "complete";
    const main = document.querySelector("main");
    const hasActionButtons = main && main.querySelectorAll("button").length > 0;
    if (complete && main && hasActionButtons) break;
    await delay(500);
  }

  // Make sure the action bar is in view. Some LinkedIn profiles auto-scroll
  // to a section anchor (e.g., #experience) which leaves the top action bar
  // off-screen and React lazy-mounts buttons that aren't in the viewport.
  try { window.scrollTo({ top: 0, behavior: "instant" }); } catch { window.scrollTo(0, 0); }
  await delay(400);

  // Helper: detect the "Pending" badge that LinkedIn shows on a profile
  // when an invitation we just accepted hasn't propagated through their
  // backend yet. While "Pending" is visible, the Message button is NOT
  // rendered — so we have to wait it out instead of giving up.
  const _isPendingState = () => {
    try {
      const main = document.querySelector("main");
      if (!main) return false;
      // 2026 LinkedIn: "Pending" appears as button text or aria-label on
      // a disabled-looking action button in the top action bar.
      const candidates = main.querySelectorAll('button, span, div');
      for (const el of candidates) {
        const t = (el.innerText || el.textContent || "").trim();
        if (/^Pending$/i.test(t)) return true;
      }
      return false;
    } catch { return false; }
  };

  // 1. Find the Message button. LinkedIn 2026 renders this several ways
  // depending on the profile state (1st-degree vs 2nd-degree, recent
  // connection, etc.). Try multiple selectors then a text-content fallback.
  let msgBtn = null;
  let foundInOverflow = false;
  let sawPending = false;

  // r37: scope the primary search to the profile's top action bar.
  // The previous version ran `aria-label*="Message"` against the whole
  // document — which matched FIRST against a "Message <name>" button in
  // the right-rail "People also viewed" cards (or another profile-section
  // element) before the real action-bar button rendered. Clicking that
  // wrong element did nothing visible and the compose-drawer wait timed
  // out 15s later as "compose drawer didn't render". The overflow fallback
  // below already scopes to the action bar via h1's ancestor — the primary
  // path now does the same. Also accepts the "Send a message" variant
  // LinkedIn ships on some profile states.
  const _getActionBarScope = () => {
    const h1 = document.querySelector("main h1");
    let scope = h1?.parentElement;
    for (let i = 0; i < 6 && scope && scope !== document.body; i++) {
      if (scope.querySelector('button[aria-label]')) break;
      scope = scope.parentElement;
    }
    return scope || document.querySelector("main") || document;
  };

  // Wait up to 30s for the Message button to appear (was 15s — too short
  // when LinkedIn's backend is slow to propagate an accept). Pending-state
  // detection extends the wait further: if we see "Pending" on the profile,
  // we've accepted them but LinkedIn hasn't flipped them to 1st-degree yet.
  const profileMsgStart = Date.now();
  const POLL_MAX_MS = 30_000;
  const PENDING_MAX_MS = 45_000; // additional grace when "Pending" is visible
  while (!msgBtn) {
    const elapsed = Date.now() - profileMsgStart;
    if (sawPending ? elapsed > PENDING_MAX_MS : elapsed > POLL_MAX_MS) break;

    const scope = _getActionBarScope();

    // Anchored aria-label matches — must be either exactly "Message",
    // "Message <name>" (starts-with "Message "), or "Send a message...".
    // Bare `*="Message"` would also match "Messaging", "Send Sponsored
    // Message" (ad UI), or card buttons elsewhere on the page.
    let matchedVia = null;
    msgBtn = scope.querySelector(
      'button[aria-label="Message"], '
      + 'button[aria-label^="Message "], '
      + 'button[aria-label*="Send a message" i]'
    );
    if (msgBtn) matchedVia = "aria-label:button";
    if (!msgBtn) {
      msgBtn = scope.querySelector(
        'a[aria-label="Message"][href*="/messaging/"], '
        + 'a[aria-label^="Message "][href*="/messaging/"], '
        + 'a[aria-label*="Send a message" i][href*="/messaging/"]'
      );
      if (msgBtn) matchedVia = "aria-label:anchor";
    }
    if (!msgBtn) {
      // Visible-text fallback, action-bar-scoped. Accepts both "Message"
      // and the longer "Send a message" label LinkedIn renders on some
      // profile states (typically when there is no recent thread).
      const candidates = scope.querySelectorAll('button, a[href*="/messaging/"]');
      for (const el of candidates) {
        if (el.offsetParent === null) continue;
        const text = (el.innerText || el.textContent || "").trim();
        if (text === "Message" || /^Send a message$/i.test(text)) {
          msgBtn = el;
          matchedVia = "visible-text";
          break;
        }
      }
    }
    // r37: log what we picked. If a future compose-drawer failure shows up,
    // this tells us instantly whether we matched the right element. Without
    // this, every failure is opaque.
    if (msgBtn) {
      const al = (msgBtn.getAttribute("aria-label") || "").slice(0, 80);
      const txt = (msgBtn.innerText || msgBtn.textContent || "").trim().slice(0, 40);
      console.log(`[Nexus] profile Message button picked via ${matchedVia}: <${msgBtn.tagName.toLowerCase()} aria-label="${al}" text="${txt}">`);
    }

    if (!msgBtn) {
      // Check for "Pending" — if present, this is a freshly-accepted invite
      // that hasn't propagated yet. Log once and keep polling longer.
      if (!sawPending && _isPendingState()) {
        sawPending = true;
        console.log("[Nexus] profile shows 'Pending' — waiting for LinkedIn backend to flip to 1st-degree");
      }
      await delay(1000);
    }
  }

  // r34: TIGHTENED overflow-menu fallback.
  //
  // The r22 version clicked every "More" button anywhere in <main>. That
  // sometimes opened the "Report or block this person" context menu (LinkedIn
  // uses an identical-looking three-dot trigger for reporting), which popped
  // a dialog the user saw as "report as inappropriate."
  //
  // Two safety changes:
  //   1. Restrict the search to the profile's TOP action bar — the only
  //      overflow that should contain the Message option. Identified by
  //      proximity to the profile heading (h1) and avatar.
  //   2. Before treating a dropdown as "the right one," scan its items for
  //      Report/Block strings. If found, this is the wrong menu — close it
  //      and skip. We never click anything inside a Report-style menu.
  if (!msgBtn) {
    // Find the action-bar area: ancestor that contains both the profile
    // <h1> and at least one action button. Restrict overflow search there.
    const h1 = document.querySelector("main h1");
    let actionBar = h1?.parentElement;
    for (let i = 0; i < 6 && actionBar && actionBar !== document.body; i++) {
      if (actionBar.querySelector('button[aria-label]')) break;
      actionBar = actionBar.parentElement;
    }
    const searchScope = actionBar || document.querySelector("main") || document;

    const moreButtons = Array.from(searchScope.querySelectorAll(
      'button[aria-label*="More" i], button[aria-label*="overflow" i], button.artdeco-dropdown__trigger'
    )).filter(b => {
      if (b.offsetParent === null) return false;
      // Skip triggers whose own aria-label hints they're for reporting,
      // posts, comments, or other context menus — not the profile action bar.
      const al = (b.getAttribute("aria-label") || "").toLowerCase();
      if (al.includes("report") || al.includes("post") || al.includes("comment")) return false;
      return true;
    });

    for (const moreBtn of moreButtons) {
      try { moreBtn.click(); } catch { continue; }
      await delay(700);

      const dropdownCandidates = Array.from(document.querySelectorAll(
        'div[role="menu"] button, div[role="menu"] a, .artdeco-dropdown__content button, .artdeco-dropdown__content a'
      ));
      // Safety check: if this dropdown contains "Report" or "Block" items,
      // we opened the wrong menu — abort, close it, never click anything
      // inside. This is the key fix for the "report as inappropriate" popup.
      const hasReportItem = dropdownCandidates.some(item => {
        const text = (item.innerText || item.textContent || "").trim().toLowerCase();
        return text.includes("report") || text.startsWith("block ") || text === "block";
      });
      if (hasReportItem) {
        console.warn("[Nexus] overflow opened a Report/Block menu — skipping it (NOT clicking anything inside)");
        // Close via Escape so we don't accidentally re-trigger anything.
        try { document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); } catch { /* ignore */ }
        try { moreBtn.click(); } catch { /* ignore */ }
        await delay(300);
        continue;
      }

      for (const item of dropdownCandidates) {
        const text = (item.innerText || item.textContent || "").trim();
        if (/^Message$/i.test(text)) {
          msgBtn = item;
          foundInOverflow = true;
          break;
        }
      }
      if (msgBtn) break;
      // Close this dropdown before trying the next one.
      try { document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); } catch { /* ignore */ }
      try { moreBtn.click(); } catch { /* ignore */ }
      await delay(200);
    }
  }

  if (!msgBtn) {
    // Auto-snapshot so we can see what LinkedIn actually rendered on this
    // profile. Without this dump every failure is opaque.
    const stateNote = sawPending
      ? "Profile-page Message button not found — Pending badge still visible after 45s (backend hadn't flipped accept to 1st-degree)"
      : "Profile-page Message button not found (top bar + overflow checked, no Pending badge)";
    try {
      const snapshot = _buildLinkedInDomSnapshot({ triggerEvent: "profile_message_btn_missing" });
      _uploadDomSnapshot(snapshot, "profile_message_btn_missing", stateNote).catch(() => {});
      console.warn("[Nexus] profile Message button not found — uploaded snapshot for diagnosis");
    } catch { /* ignore */ }
    // sawPending=true signals "retry-worthy" failure: it's a transient
    // LinkedIn propagation delay, not a structural problem. Caller can
    // requeue with a longer wait.
    return {
      success: false,
      error: stateNote,
      errorCode: sawPending ? "pending_state_unresolved" : "selector_missing:profile_message_btn",
      retryWorthy: sawPending,
    };
  }

  if (foundInOverflow) {
    console.log("[Nexus] profile Message button found in overflow menu");
  }

  // r29: detect the "Message is actually an anchor link" case BEFORE clicking.
  // Some LinkedIn profile states render the Message button as
  // `<a href="/messaging/thread/new/?recipient=URN">` instead of a button
  // that opens a slide-out drawer. Clicking the anchor would navigate the
  // tab and tear down our JS context — the promise we returned would never
  // resolve, surfacing later as "compose drawer didn't render".
  //
  // Solution: extract the recipient URN from the href and return it up to
  // the background coordinator, which retries through the normal thread/new
  // path (handleTypeAndSendMessage) that's purpose-built for that page.
  if (msgBtn.tagName === "A") {
    const href = msgBtn.getAttribute("href") || "";
    if (/\/messaging\//.test(href)) {
      // Extract recipient URN from `?recipient=<urn>` or thread fragment.
      let recoveredUrn = null;
      try {
        const fullUrl = href.startsWith("http") ? href : `https://www.linkedin.com${href}`;
        const u = new URL(fullUrl);
        recoveredUrn = u.searchParams.get("recipient");
      } catch { /* fall through */ }
      console.log("[Nexus] Message button is an anchor — returning URN for retry:", recoveredUrn);
      return {
        success: false,
        error: "Message is an anchor link, not a drawer trigger",
        errorCode: "message_btn_is_anchor",
        recoveredUrn,
        recoveredHref: href,
        retryWorthy: !!recoveredUrn,
      };
    }
  }

  msgBtn.click();
  // Wait for the drawer to slide in + Slate editor to mount.
  await delay(2500);

  // 2. Find the compose box in the slide-out drawer. Uses the same selector
  // chain as the thread-page compose since LinkedIn renders the same DOM.
  // Extended to 15s (was 10s) — slow connections sometimes need it.
  const compose = await _waitForFirstMatch(_COMPOSE_INPUT_SELECTORS, 15_000);
  if (!compose) {
    // Auto-snapshot — LinkedIn may be gating the compose drawer for this
    // connection state, or the click silently navigated.
    try {
      const snapshot = _buildLinkedInDomSnapshot({ triggerEvent: "profile_compose_drawer_missing" });
      _uploadDomSnapshot(snapshot, "profile_compose_drawer_missing", "Profile Message click but no compose drawer rendered after 15s").catch(() => {});
    } catch { /* ignore */ }
    return { success: false, error: "compose drawer didn't render", errorCode: "selector_missing:compose_input" };
  }

  // r31: Autonomous Mode (CDP) path — focus the compose and return.
  // The background will CDP-type into the focused element and call
  // li:click-send-after-cdp to click Send. The focus-required typing
  // branch below never runs in this mode, so the "return to LinkedIn"
  // pause never fires.
  if (prepOnlyForCdp) {
    try {
      compose.focus();
      const range = document.createRange();
      range.selectNodeContents(compose);
      range.collapse(false);
      const sel = window.getSelection();
      sel.removeAllRanges();
      sel.addRange(range);
    } catch { /* ignore */ }
    return { success: true, prepReady: true };
  }

  // 3. Type via the Slate-aware path (auto-routes to Slate if detected).
  await _typeIntoContentEditable(compose, message);
  await delay(400 + Math.random() * 300);

  // Nudge React's form validation (same trick as handleTypeAndSendMessage).
  try {
    compose.dispatchEvent(new FocusEvent("blur", { bubbles: true }));
    compose.dispatchEvent(new FocusEvent("focus", { bubbles: true }));
  } catch { /* ignore */ }

  // 4. Find the Send button — wait up to 6s for React to enable it.
  let sendBtn = null;
  let sendBtnDisabled = true;
  const sendStart = Date.now();
  while (Date.now() - sendStart < 6000) {
    sendBtn = await _waitForFirstMatch(_COMPOSE_SEND_SELECTORS, 1500);
    if (!sendBtn) break;
    sendBtnDisabled = sendBtn.disabled || sendBtn.getAttribute("aria-disabled") === "true";
    if (!sendBtnDisabled) break;
    await delay(500);
  }
  if (!sendBtn) {
    return { success: false, error: "drawer Send button not found", errorCode: "selector_missing:compose_send" };
  }
  if (sendBtnDisabled) {
    return { success: false, error: "drawer Send button stayed disabled", errorCode: "send_disabled" };
  }

  // 5. Click Send. Wait for focus (same focus-pause-resume logic) —
  // but skip the wait entirely when Autonomous Mode is on. Element.click()
  // dispatches synchronously regardless of focus; the wait was only ever
  // belt-and-braces for the React-onClick fallback path.
  await delay(200 + Math.random() * 300);
  if (!document.hasFocus() && !(await _isAutonomousModeActive())) {
    const got = await _waitForFocus(300_000);
    if (!got) return { success: false, error: "focus_timeout before profile-message send", errorCode: "focus_timeout" };
  }
  try {
    sendBtn.click();
  } catch (err) {
    return { success: false, error: `send click failed: ${err.message}`, errorCode: "click_failed" };
  }
  // Wait for the drawer to clear (indicates success).
  await delay(1500);
  const stillHasText = (compose.innerText || "").trim().length > 0;
  if (stillHasText) {
    return { success: false, error: "compose still has text after send", errorCode: "send_unconfirmed" };
  }

  return { success: true, viaProfileMessage: true };
}

/**
 * CDP path helper — find the compose box and place focus + caret on it
 * so the subsequent CDP Input.insertText writes into the right element.
 *
 * Why content-script does this: chrome.debugger can target a tab but
 * needs the element pre-focused (or it'd write to whatever the page's
 * current focus is — could be the URL bar, a nav link, etc.).
 */
async function handleFocusCompose() {
  const compose = await _waitForFirstMatch(_COMPOSE_INPUT_SELECTORS, 12_000);
  if (!compose) {
    return { success: false, errorCode: "selector_missing:compose_input", error: "compose box not found before CDP" };
  }
  compose.focus();
  // Place caret at end so insertText appends rather than prepends.
  try {
    const range = document.createRange();
    range.selectNodeContents(compose);
    range.collapse(false);
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  } catch { /* ignore */ }
  return { success: true };
}

/**
 * Click the Send button after CDP has filled the compose with text.
 * Runs the same logic as the tail of handleTypeAndSendMessage but skips
 * the typing portion since CDP already did that work.
 */
async function handleClickSendAfterCdp() {
  // Wait briefly for React to enable the Send button after the CDP-driven
  // insertText. Trusted events go through faster than synthetic ones, so
  // 3s is usually enough.
  let sendBtn = null;
  let sendBtnDisabled = true;
  const start = Date.now();
  while (Date.now() - start < 4000) {
    sendBtn = await _waitForFirstMatch(_COMPOSE_SEND_SELECTORS, 1000);
    if (!sendBtn) break;
    sendBtnDisabled = sendBtn.disabled || sendBtn.getAttribute("aria-disabled") === "true";
    if (!sendBtnDisabled) break;
    await delay(400);
  }
  if (!sendBtn) {
    return { success: false, errorCode: "selector_missing:compose_send", error: "send button missing after CDP typing" };
  }
  if (sendBtnDisabled) {
    return { success: false, errorCode: "send_disabled", error: "send button stayed disabled after CDP — text may not have registered" };
  }
  try {
    sendBtn.click();
  } catch (err) {
    return { success: false, errorCode: "click_failed", error: err.message };
  }
  await delay(1500);
  // Verify the compose cleared (indicates send actually went through).
  const compose = document.querySelector('div[role="textbox"][aria-label*="message" i], div[class*="msg-form__contenteditable"]');
  if (compose && (compose.innerText || "").trim().length > 0) {
    return { success: false, errorCode: "send_unconfirmed", error: "compose still has text after Send click" };
  }
  return { success: true };
}

async function handleTypeAndSendMessage({ message, urn, expectedSlug } = {}) {
  if (!message) return { success: false, error: "missing_message" };

  // Sanity-check we landed on the right page. If the navigation got redirected
  // (LinkedIn occasionally bounces /messaging/thread/new/?recipient=... to
  // /messaging/ alone when the URN is stale), bail early with a clear code so
  // background can decide to fall back.
  const onMessagingPage = window.location.pathname.includes("/messaging/");
  if (!onMessagingPage) {
    return { success: false, error: `not_on_messaging_page (${window.location.pathname})`, errorCode: "wrong_page" };
  }

  // Step 1: wait for compose textbox.
  const compose = await _waitForFirstMatch(_COMPOSE_INPUT_SELECTORS, 12_000);
  if (!compose) {
    return { success: false, error: "compose box not found", errorCode: "selector_missing:compose_input" };
  }
  // Diagnostic: log WHICH element matched so we can spot wrong-element bugs
  // (e.g., matching a sidebar contenteditable instead of the compose form).
  console.log("[Nexus] Compose box matched:", {
    tag: compose.tagName,
    classes: compose.className,
    ariaLabel: compose.getAttribute("aria-label"),
    role: compose.getAttribute("role"),
    parentTag: compose.parentElement?.tagName,
    parentClass: compose.parentElement?.className,
  });

  // Step 2: type humanized.
  await _typeIntoContentEditable(compose, message);
  // Brief settle — React often debounces the Send-button enable state.
  await delay(400 + Math.random() * 300);

  // Nudge React's form validation by triggering focus events — sometimes
  // background-tab React doesn't run its onChange listener until focus is
  // touched. blur-then-focus cycle is enough; doesn't disturb caret.
  try {
    compose.dispatchEvent(new FocusEvent("blur", { bubbles: true }));
    compose.dispatchEvent(new FocusEvent("focus", { bubbles: true }));
  } catch { /* ignore */ }

  // Step 3: find send button. Wait up to ~6s for React to enable it after
  // typing — in background tabs the enable state often lags 1-3s behind the
  // input event. Re-check periodically rather than failing on first read.
  let sendBtn = null;
  let sendBtnDisabled = true;
  const sendBtnStart = Date.now();
  while (Date.now() - sendBtnStart < 6000) {
    sendBtn = await _waitForFirstMatch(_COMPOSE_SEND_SELECTORS, 1500);
    if (!sendBtn) break;
    sendBtnDisabled = sendBtn.disabled || sendBtn.getAttribute("aria-disabled") === "true";
    if (!sendBtnDisabled) break;
    await delay(500);
  }
  if (!sendBtn) {
    return { success: false, error: "send button not found", errorCode: "selector_missing:compose_send" };
  }

  // If the send button stayed disabled, try a stronger typing fallback before
  // giving up. The current `_typeIntoContentEditable` uses execCommand which
  // works for plain contenteditable but fails on newer React editors (Lexical,
  // Slate) that intercept beforeinput and manage state themselves.
  //
  // CRITICAL: when this fails we capture the compose+send HTML and return it
  // in the response. The DM tab will be closed by the coordinator before the
  // user can open DevTools on it, so the HTML needs to travel back through
  // the IPC chain to the user's primary tab where they CAN inspect it.
  if (sendBtnDisabled) {
    console.warn("[Nexus] Send disabled after execCommand typing. Trying full-event-sequence fallback.");

    // Clear and retry with stronger method.
    try {
      compose.innerHTML = "";
      await _typeStrongFallback(compose, message);
    } catch (err) {
      console.warn("[Nexus] Strong fallback threw:", err.message);
    }
    await delay(800);
    // Re-check the send button.
    sendBtnDisabled = sendBtn.disabled || sendBtn.getAttribute("aria-disabled") === "true";
    if (sendBtnDisabled) {
      // Capture a COMPREHENSIVE diagnostic snapshot. This is the "remote
      // debugging tool" — instead of asking the user to paste console output,
      // we upload everything we'd ever want to know to the bot. They run
      // /lidump in Telegram (or we read the DB) and have full context.
      const snapshot = _buildLinkedInDomSnapshot({
        triggerEvent: "send_disabled",
        matchedCompose: compose,
        matchedSendBtn: sendBtn,
      });

      // Best-effort: upload to bot. Also save locally as backup.
      _uploadDomSnapshot(snapshot, "send_disabled").catch(() => { /* offline ok */ });
      try {
        await chrome.storage.local.set({ _nxDmDebug: { ts: new Date().toISOString(), snapshot } });
      } catch { /* ignore */ }

      return {
        success: false,
        error: "send button stayed disabled for 6s even after fallback typing",
        errorCode: "send_disabled",
        snapshotUploaded: true,
        debug: {
          composeInfo: snapshot.matchedCompose?.info,
          sendBtnInfo: snapshot.matchedSendBtn?.info,
          composeHtml: snapshot.matchedCompose?.html,
          sendBtnHtml: snapshot.matchedSendBtn?.html,
        },
      };
    }
    console.log("[Nexus] Strong fallback worked — send button enabled");
  }

  // Step 4: click send. Brief pause first — clicking too fast after typing
  // looks robotic and sometimes triggers LinkedIn's anti-spam heuristics.
  // Also wait for focus — clicking on an unfocused page can cause React to
  // not fire its onClick (the same focus problem as typing).
  await delay(200 + Math.random() * 300);
  if (!document.hasFocus() && !(await _isAutonomousModeActive())) {
    const got = await _waitForFocus(300_000);
    if (!got) return { success: false, error: "focus_timeout before send click", errorCode: "focus_timeout" };
  }
  try {
    sendBtn.click();
  } catch (err) {
    return { success: false, error: `send click failed: ${err.message}`, errorCode: "click_failed" };
  }

  // Step 5: confirm — wait briefly and check that the compose box was cleared
  // (LinkedIn clears it after a successful send). This isn't bulletproof but
  // catches the obvious failure where the click registered but nothing happened.
  await delay(1500);
  const stillHasText = (compose.innerText || "").trim().length > 0;
  if (stillHasText) {
    return { success: false, error: "compose still has text after send — likely silent reject", errorCode: "send_unconfirmed" };
  }

  return { success: true, urn: urn || null };
}

// ── Message Listener (from background.js) ─────────────────────────────

chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  // Panel notifications from background.js (live status updates)
  if (msg?.nexusPanel) {
    if (msg.type === "status") setStatus(msg.data);
    if (msg.type === "log") addLog(msg.data.icon, msg.data.text, msg.data.url || null);
    // r44: cancel relayed from another tab's panel (Stop button) when
    // this tab is running a background batch on its behalf.
    if (msg.type === "cancel") signalCancel();
    return;
  }

  if (!msg || !msg.nexusCommand) return;

  const { action, params, commandId } = msg;
  // Per-run screenshot correlation: a dashboard batch passes runId so each
  // step can be captured for the visual replay. Reset the step counter.
  _NX_LI_RUN_ID = params?.runId || null;
  _liShotStep = 0;

  (async () => {
    try {
      let result;
      switch (action) {
        case "li:extract-search":
          result = await handleExtractSearch(params);
          break;
        case "li:click-connect":
          result = await handleClickConnect(params);
          break;
        case "li:like-posts":
          result = await handleLikePosts(params);
          break;
        case "li:comment":
          result = await handleCommentOnPost(params);
          break;
        case "li:like-and-comment":
          result = await handleLikeAndComment(params);
          break;
        case "li:accept-invites": {
          // r51: when invoked from the dashboard via the worker tab,
          // set _NX_BACKGROUND_BATCH so addLog/setStatus relay back to
          // the originating dashboard tab. Legacy panel calls don't
          // pass panelTabId, so the wrapper is a no-op for them.
          if (params?.panelTabId) _NX_BACKGROUND_BATCH = { panelTabId: params.panelTabId };
          try {
            result = await handleAcceptInvites(params);
          } finally {
            if (params?.panelTabId) _NX_BACKGROUND_BATCH = null;
          }
          break;
        }
        case "li:withdraw-invites": {
          if (params?.panelTabId) _NX_BACKGROUND_BATCH = { panelTabId: params.panelTabId };
          try {
            result = await handleWithdrawInvites(params);
          } finally {
            if (params?.panelTabId) _NX_BACKGROUND_BATCH = null;
          }
          break;
        }
        case "li:follow-connections": {
          if (params?.panelTabId) _NX_BACKGROUND_BATCH = { panelTabId: params.panelTabId };
          try {
            result = await handleFollowConnections(params);
          } finally {
            if (params?.panelTabId) _NX_BACKGROUND_BATCH = null;
          }
          break;
        }
        case "li:endorse-skills":
          result = await handleEndorseSkills(params);
          break;
        case "li:view-profiles":
          result = await handleViewProfiles(params);
          break;
        case "li:get-connections":
          result = await handleMessageConnections(params);
          break;
        case "li:send-message":
          result = await handleSendMessage(params);
          break;
        case "li:check-messages":
          result = await handleCheckMessages();
          break;
        case "li:reply-message":
          result = await handleReplyMessage(params);
          break;
        case "li:extract-last-message":
          result = await handleExtractLastMessage();
          break;
        case "li:ping":
          result = await handlePing();
          break;
        case "li:type-and-send-message":
          result = await handleTypeAndSendMessage(params);
          break;
        case "li:scrape-inbox":
          result = await handleScrapeInbox(params);
          break;
        case "li:profile-message":
          result = await handleProfileMessage(params);
          break;
        case "li:focus-compose":
          result = await handleFocusCompose();
          break;
        case "li:click-send-after-cdp":
          result = await handleClickSendAfterCdp();
          break;
        case "li:like-latest-post-on-page":
          // DOM fallback for the like-latest-post warm-up step in
          // accept-and-DM. Called by background.js _likeLatestPost
          // when the Voyager profile-posts endpoint fails — at that
          // point the worker tab has already navigated to the
          // person's /recent-activity/all/ page.
          result = await handleLikeLatestPostOnPage(params);
          break;
        case "li:run-like-posts": {
          // r50: dashboard-initiated like-posts batch. Reuses existing
          // handleLikeAndComment (the same one the panel calls) but
          // runs inside the worker tab with progress relay back to the
          // dashboard tab via _NX_BACKGROUND_BATCH.
          _NX_BACKGROUND_BATCH = { panelTabId: params?.panelTabId };
          resetCancel();
          try {
            const c = params?.config || {};
            result = await handleLikeAndComment({
              count: c.count || 10,
              commentText: c.commentText || "",
              useAI: false,
              commentEveryN: c.commentEveryN || 0,
              keywordFilter: c.keywordFilter || "",
            });
          } finally {
            _NX_BACKGROUND_BATCH = null;
          }
          break;
        }
        case "li:run-view-profiles": {
          // r50: view N profiles matching a search keyword. Mirrors the
          // panel's view-profiles action — straight calls to the
          // Voyager search + view-profile endpoints.
          _NX_BACKGROUND_BATCH = { panelTabId: params?.panelTabId };
          resetCancel();
          try {
            const c = params?.config || {};
            const keyword = String(c.keyword || "CEO web3").trim();
            const count = Math.max(1, Math.min(50, parseInt(c.count, 10) || 10));
            setStatus(`Searching: "${keyword}"…`);
            addLog("...", `Viewing up to ${count} profiles for "${keyword}"`);
            const searchResult = await linkedinCmd("linkedin:http-search", { query: keyword, limit: count * 2 });
            const people = searchResult?.results || [];
            if (people.length === 0) {
              addLog("-", "No profiles found for that keyword");
              result = { viewed: 0, requested: count };
            } else {
              let viewed = 0;
              for (const person of people) {
                if (viewed >= count || isCancelled()) break;
                const slug = person.publicIdentifier || (person.entityUrn || "").split(":").pop();
                if (!slug) continue;
                const nm = person.title?.text || [person.firstName, person.lastName].filter(Boolean).join(" ") || slug;
                setStatus(`(${viewed + 1}/${count}) Viewing: ${nm}`);
                try {
                  await linkedinCmd("linkedin:http-view-profile", { publicIdentifier: slug });
                  viewed++;
                  addLog("V", `Viewed: ${nm}`, `https://www.linkedin.com/in/${slug}/`);
                } catch (err) {
                  addLog("!", `${nm}: ${err.message}`);
                }
                await humanDelay(2500);
              }
              setStatus(`Done — viewed ${viewed}/${count}`);
              addLog("V", `Complete: ${viewed} profile views logged`);
              result = { viewed, requested: count };
            }
          } finally {
            _NX_BACKGROUND_BATCH = null;
          }
          break;
        }
        case "li:run-add-connections": {
          // r50: search + bulk-connect via Voyager. The panel's
          // add-connections start() is far richer (degree filters,
          // exclude keywords, enrichment, like-first). This is the
          // minimal viable version — keyword search → send connect
          // with optional note. Future passes can add the filters.
          _NX_BACKGROUND_BATCH = { panelTabId: params?.panelTabId };
          resetCancel();
          try {
            const c = params?.config || {};
            const keyword = String(c.keyword || "CEO web3").trim();
            const count = Math.max(1, Math.min(30, parseInt(c.count, 10) || 10));
            const note = c.message ? String(c.message).slice(0, 200) : null;
            const exclude = Array.isArray(c.exclude) ? c.exclude : [];
            const delayMs = parseInt(c.delayMs, 10) || 3000;
            setStatus(`Searching: "${keyword}"…`);
            addLog("...", `Sending up to ${count} connects for "${keyword}"`);
            const searchResult = await linkedinCmd("linkedin:http-search", { query: keyword, limit: count * 3 });
            const people = searchResult?.results || [];
            let sent = 0, skipped = 0, failed = 0;
            for (const person of people) {
              if (sent >= count || isCancelled()) break;
              const slug = person.publicIdentifier || (person.entityUrn || "").split(":").pop();
              if (!slug) { skipped++; continue; }
              const nm = person.title?.text || [person.firstName, person.lastName].filter(Boolean).join(" ") || slug;
              const headlineLc = (person.headline || "").toLowerCase();
              if (exclude.some(k => headlineLc.includes(k))) { addLog("-", `${nm}: skipped (exclude match)`); skipped++; continue; }
              const first = (person.firstName || nm.split(" ")[0] || "").trim();
              const personalizedNote = note ? note.replace(/\{firstName\}/gi, first).replace(/\{name\}/gi, nm) : null;
              setStatus(`(${sent + 1}/${count}) Connect: ${nm}`);
              try {
                const r = await linkedinCmd("linkedin:http-send-connection", {
                  publicIdentifier: slug,
                  message: personalizedNote || undefined,
                });
                if (r?.success) { sent++; addLog("V", `Connected: ${nm}`, `https://www.linkedin.com/in/${slug}/`); }
                else if (r?.terminal) { skipped++; addLog("-", `${nm}: ${r.detail || "skipped"}`); }
                else { failed++; addLog("!", `${nm}: ${r?.error || r?.detail || "failed"}`); }
              } catch (err) {
                failed++;
                addLog("X", `${nm}: ${err.message}`);
              }
              await humanDelay(delayMs);
            }
            setStatus(`Done — ${sent} sent, ${skipped} skipped, ${failed} failed`);
            addLog("V", `Complete: ${sent} connect requests sent`);
            result = { sent, skipped, failed, requested: count };
          } finally {
            _NX_BACKGROUND_BATCH = null;
          }
          break;
        }
        case "li:run-message-connections": {
          // r50: bulk-DM 1st-degree connections. We use the bot's
          // existing send-dm-via-ui for each (so the worker tab path,
          // CDP typing, account-wall + rate-limit gates all apply).
          // The connections-list scrape is DOM-side (lazy implementation:
          // pull names from the current worker tab page).
          _NX_BACKGROUND_BATCH = { panelTabId: params?.panelTabId };
          resetCancel();
          try {
            const c = params?.config || {};
            const template = String(c.template || "").trim();
            const count = Math.max(1, Math.min(30, parseInt(c.count, 10) || 10));
            const delayMs = parseInt(c.delayMs, 10) || 6000;
            if (!template) {
              result = { error: "missing template" };
            } else {
              setStatus("Loading 1st-degree connections…");
              addLog("...", `Reading connection cards from the page (target: ${count} DMs)`);
              // Scrape connection cards from the /mynetwork/invite-connect/connections/ page.
              await delay(2000);
              const cards = Array.from(document.querySelectorAll('a[href*="/in/"]'))
                .map(a => ({
                  href: a.href,
                  slug: (a.href.match(/\/in\/([^\/?#]+)/) || [])[1] || null,
                  name: (a.querySelector("span")?.textContent || "").trim(),
                }))
                .filter(c => c.slug && c.name && c.name.length > 1);
              const unique = Array.from(new Map(cards.map(c => [c.slug, c])).values()).slice(0, count * 2);
              if (unique.length === 0) {
                addLog("X", "No connection cards found on this page — open /mynetwork/invite-connect/connections/ in your browser first and let it load.");
                result = { messaged: 0, requested: count, error: "no_connections_visible" };
              } else {
                let messaged = 0, failed = 0;
                for (const inv of unique) {
                  if (messaged >= count || isCancelled()) break;
                  const first = (inv.name.split(" ")[0] || "").trim();
                  const msg = template
                    .replace(/\{firstName\}/gi, first)
                    .replace(/\{name\}/gi, inv.name)
                    .replace(/\{headline\}/gi, "")
                    .replace(/\{company\}/gi, "")
                    .replace(/\{hook\}/gi, "")
                    .replace(/  +/g, " ").trim();
                  setStatus(`(${messaged + 1}/${count}) DM: ${inv.name}`);
                  try {
                    const dmRes = await new Promise((resolve) => {
                      chrome.runtime.sendMessage({
                        action: "li-send-dm-via-ui",
                        slug: inv.slug,
                        urn: null,
                        message: msg,
                      }, (r) => {
                        if (chrome.runtime.lastError) resolve({ success: false, error: chrome.runtime.lastError.message });
                        else resolve(r || { success: false, error: "no_response" });
                      });
                    });
                    if (dmRes?.success) {
                      messaged++;
                      addLog("V", `DM sent: ${inv.name}`);
                    } else {
                      failed++;
                      addLog("X", `${inv.name}: ${dmRes?.error || "failed"}`);
                    }
                  } catch (err) {
                    failed++;
                    addLog("X", `${inv.name}: ${err.message}`);
                  }
                  await humanDelay(delayMs);
                }
                setStatus(`Done — ${messaged} sent, ${failed} failed`);
                addLog("V", `Complete: ${messaged} DMs`);
                result = { messaged, failed, requested: count };
              }
            }
          } finally {
            _NX_BACKGROUND_BATCH = null;
          }
          break;
        }
        case "li:run-accept-and-dm-batch":
          // r44: invoked on the hidden worker tab by background.js
          // when the user clicks Start from their panel. Setting
          // _NX_BACKGROUND_BATCH causes every addLog / setStatus call
          // inside runAcceptAndDmBatch to also relay back to the
          // originating panel tab via background, so the user sees
          // progress on the tab they clicked Start on.
          _NX_BACKGROUND_BATCH = { panelTabId: params?.panelTabId };
          resetCancel();
          try {
            result = await runAcceptAndDmBatch(params?.config || {});
          } finally {
            _NX_BACKGROUND_BATCH = null;
          }
          break;
        default:
          result = { error: `Unknown action: ${action}` };
      }
      sendResponse({ commandId, ...result });
    } catch (err) {
      sendResponse({ commandId, error: err.message });
    }
  })();

  return true; // keep message channel open for async response
});

// ── Floating Panel UI ──────────────────────────────────────────────────

const PANEL_ID = "nexus-connect-panel";
let panelLog = [];
let panelStatus = "Ready";

// r44: when this tab is running a batch on behalf of another (panel) tab —
// e.g. the user clicked Start on their /received/ panel and we're running
// the actual batch in the hidden worker tab — every addLog / setStatus call
// is also relayed via background.js to the panel tab so the user sees live
// progress on the panel they clicked Start from. Set by the
// li:run-accept-and-dm-batch handler; cleared when the batch ends.
let _NX_BACKGROUND_BATCH = null; // { panelTabId: number } or null

// Per-step screenshot replay: the active run id + a step counter. Set at the
// dispatch entry from params.runId. _liShot() asks the SW to capture the worker
// tab; best-effort (a failed shot never affects the batch).
let _NX_LI_RUN_ID = null;
let _liShotStep = 0;
function _liShot(stepName, status) {
  if (!_NX_LI_RUN_ID) return;
  try {
    chrome.runtime.sendMessage({
      type: "li-shot",
      runId: _NX_LI_RUN_ID,
      stepIndex: _liShotStep++,
      stepName: stepName || null,
      status: status || "info",
    }).catch(() => {});
  } catch { /* ignore */ }
}

// Persist panel state via chrome.storage.local (NOT .session) so a mid-batch
// tab refresh or accidental close doesn't erase the activity history — losing
// "what did the batch just do" mid-run is a trust failure (visibility of
// system status). local survives reloads; we keep the last 40 log entries.
let panelMinimized = false;

// addLog / setStatus fire hundreds of times during a batch — setStatus runs
// once per row inside tight scan loops (e.g. smart-disconnect walks up to 200
// connections with no await between rows), and each raw write structured-clones
// 40 log objects across the extension IPC boundary. Writes are coalesced behind
// a trailing 500ms throttle so at most one lands per window, always carrying the
// latest state; panelLog / panelStatus and the DOM stay synchronous, so the
// panel itself is unchanged. pagehide + visibilitychange flush immediately so a
// refresh, tab switch or close still persists the history.
let _panelSaveTimer = null;

function flushPanelState() {
  if (_panelSaveTimer) { clearTimeout(_panelSaveTimer); _panelSaveTimer = null; }
  try {
    chrome.storage.local.set({ _nxLog: panelLog.slice(0, 40), _nxStatus: panelStatus, _nxMinimized: panelMinimized });
  } catch { /* storage may not be available */ }
}

function savePanelState() {
  if (_panelSaveTimer) return;
  _panelSaveTimer = setTimeout(() => { _panelSaveTimer = null; flushPanelState(); }, 500);
}

window.addEventListener("pagehide", flushPanelState);
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") flushPanelState();
});

async function loadPanelState() {
  try {
    const data = await chrome.storage.local.get(["_nxLog", "_nxStatus", "_nxMinimized"]);
    if (data._nxLog) panelLog = data._nxLog;
    if (data._nxStatus) panelStatus = data._nxStatus;
    if (data._nxMinimized !== undefined) panelMinimized = data._nxMinimized;
  } catch { /* ignore */ }
}

// Pre-flight impact confirmation for bulk / irreversible panel actions.
// Surfaces the real count, an ETA, and today's remaining budget + warmup so a
// misclick can't silently message/disconnect dozens of real people
// (Nielsen #5 — error prevention beats error messages). Returns true to proceed.
async function confirmImpact({ verb, count, budgetKey }) {
  let budgetLine = "";
  let warmupLine = "";
  try {
    const b = await new Promise((resolve) => {
      try { chrome.runtime.sendMessage({ action: "li-budget" }, (r) => resolve(chrome.runtime.lastError ? null : r)); }
      catch { resolve(null); }
    });
    if (b && !b.error) {
      const slot = budgetKey === "dm" ? b.message
        : budgetKey === "connect" ? b.connect_request
        : budgetKey === "like" ? b.like : null;
      if (slot && slot.remainingToday != null) {
        budgetLine = `\n\nToday's budget: ${slot.remainingToday}/${slot.dailyLimit ?? "?"} remaining.`;
        if (count > slot.remainingToday) budgetLine += `\n⚠ ${count} exceeds the ${slot.remainingToday} left — the rest will be skipped to protect the account.`;
        const age = slot.accountAgeMultiplier;
        if (age != null && age < 1.0) warmupLine = `\n🌱 New account (warmup ×${age.toFixed(1)}) — caps are intentionally low while it ages.`;
      }
    }
  } catch { /* budget is best-effort */ }
  const etaMin = Math.max(1, Math.round((count * 6) / 60));
  const eta = count > 1 ? `\nRuns over ~${etaMin} min with humanized pacing.` : "";
  return window.confirm(`${verb} ${count} ${count === 1 ? "person" : "people"}?${eta}${budgetLine}${warmupLine}\n\nThis acts on your real account and can't be undone.`);
}

const LOG_ICONS = { "V": "\u2713", "X": "\u2717", "S": "\u25f7", "-": "\u2139", "!": "\u26a0" };

function addLog(icon, text, url = null) {
  const displayIcon = LOG_ICONS[icon] || icon;
  panelLog.unshift({ icon: displayIcon, text, url, time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) });
  if (panelLog.length > 50) panelLog.length = 50;
  updateLogUI();
  savePanelState();
  // r44: relay to the originating panel tab when we're running a
  // background batch (this tab is the hidden worker; the panel that
  // started the run lives on another tab).
  if (_NX_BACKGROUND_BATCH) {
    try {
      chrome.runtime.sendMessage({
        type: "nx-batch-progress",
        panelTabId: _NX_BACKGROUND_BATCH.panelTabId,
        kind: "log",
        icon, text, url,
      }).catch(() => {});
    } catch { /* ignore */ }
  }
  // Capture a screenshot per logged step when a dashboard run is active.
  if (_NX_LI_RUN_ID) _liShot(text, (icon === "X") ? "fail" : "info");
  // Tap: forward fail/warn entries to the bot's linkedin_action_log so
  // selector breakages and weekly-cap modals show up in the Dashboard
  // "LinkedIn Activity" view, not just the floating panel.
  if (icon === "X" || icon === "!") {
    try {
      chrome.runtime.sendMessage({
        type: "li-log",
        payload: {
          source: "extension",
          action: "panel:warn",
          status: icon === "X" ? "fail" : "ok",
          errorCode: icon === "X" ? "unknown" : null,
          errorMessage: text,
          params: { url: url || location.href },
        },
      }).catch(() => { /* runtime not ready \u2014 local panel still has it */ });
    } catch { /* ignore */ }
  }
}

/**
 * Structured action telemetry \u2014 call this at the start and end of any
 * DOM-driven LinkedIn action (connect-click, message-send, accept-invite,
 * etc.) so the bot's linkedin_action_log gets a proper row with action,
 * status, duration, and error_code.
 *
 *   const t = liActionStart("connect-click", { profileUrl });
 *   try {
 *     ...DOM work...
 *     liActionEnd(t, "ok", { result: { invited: true } });
 *   } catch (err) {
 *     liActionEnd(t, "fail", { errorCode: "selector_missing:connectButton", errorMessage: err.message });
 *     throw err;
 *   }
 */
function liActionStart(action, params = null) {
  try {
    chrome.runtime.sendMessage({
      type: "li-log",
      payload: { source: "extension", action, status: "start", params },
    }).catch(() => {});
  } catch { /* ignore */ }
  return { action, params, startedAt: Date.now() };
}

function liActionEnd(sentinel, status, { errorCode = null, errorMessage = null, result = null } = {}) {
  if (!sentinel) return;
  try {
    chrome.runtime.sendMessage({
      type: "li-log",
      payload: {
        source: "extension",
        action: sentinel.action,
        status,
        durationMs: Date.now() - sentinel.startedAt,
        errorCode,
        errorMessage,
        params: sentinel.params,
        result,
      },
    }).catch(() => {});
  } catch { /* ignore */ }
}

let _showFullHistory = false;

// Log containers that already carry the delegated click handler. updateLogUI
// runs on every addLog, so binding a listener per link + per toggle on every
// render was pure churn — one delegated listener per container does the same
// job for the lifetime of the panel.
const _logClickBound = new WeakSet();

function bindLogClicks(logEl) {
  if (_logClickBound.has(logEl)) return;
  _logClickBound.add(logEl);
  logEl.addEventListener("click", (e) => {
    const src = e.target;
    if (!(src instanceof Element)) return;
    const link = src.closest(".nx-log-link");
    if (link instanceof HTMLElement) {
      e.stopPropagation();
      window.open(link.dataset.url, "_blank");
      return;
    }
    if (src.closest("#nx-log-toggle")) {
      _showFullHistory = !_showFullHistory;
      updateLogUI();
    }
  });
}

function updateLogUI() {
  const logEl = document.getElementById("nexus-connect-panel")
    ?.shadowRoot?.getElementById("nx-log");
  if (!logEl) return;
  bindLogClicks(logEl);
  const items = _showFullHistory ? panelLog : panelLog.slice(0, 8);
  if (items.length === 0) {
    logEl.innerHTML = '<div class="nx-empty">No activity yet</div>';
    return;
  }
  logEl.innerHTML = items.map((l) => {
    const textContent = l.url
      ? `<span class="nx-log-link" data-url="${l.url}">${l.text}</span>`
      : l.text;
    return `<div class="nx-log-item${l.url ? ' clickable' : ''}"><span class="nx-log-icon">${l.icon}</span><span class="nx-log-text">${textContent}</span><span class="nx-log-time">${l.time}</span></div>`;
  }).join("")
    + (panelLog.length > 8 ? `<div class="nx-log-toggle" id="nx-log-toggle">${_showFullHistory ? "Show less" : `Show all (${panelLog.length})`}</div>` : '');
}

function setStatus(text) {
  panelStatus = text;
  const el = document.getElementById("nexus-connect-panel")
    ?.shadowRoot?.getElementById("nx-status");
  if (el) el.textContent = text;
  savePanelState();
  // r44: see addLog above — same relay path for status updates so the
  // user sees the "Scanning…" / "Sending DM…" progress on their panel
  // tab even though the work is happening in the hidden worker tab.
  if (_NX_BACKGROUND_BATCH) {
    try {
      chrome.runtime.sendMessage({
        type: "nx-batch-progress",
        panelTabId: _NX_BACKGROUND_BATCH.panelTabId,
        kind: "status",
        text,
      }).catch(() => {});
    } catch { /* ignore */ }
  }
}

// ── Action cancellation ──────────────────────────────────────────────
// Module-level cancel flag. Any long-running action should:
//   1. Call resetCancel() before starting
//   2. Check isCancelled() between iterations and bail cleanly
//   3. The Start button handler in setupPanelBehavior flips this flag when
//      the user clicks Stop. The button morphs Start <-> Stop based on
//      whether an action is running.
let _actionCancelled = false;
function isCancelled() { return _actionCancelled; }
function signalCancel() { _actionCancelled = true; }
function resetCancel() { _actionCancelled = false; }

// ── Send LinkedIn command via background → bot API ─────────────────────

function friendlyError(msg) {
  if (!msg) return "Unknown error";
  if (msg.includes("Failed to fetch") || msg.includes("NetworkError")) return "Bot server offline — start the server and retry";
  if (msg.includes("context invalidated")) return "Extension reloaded — refresh this page";
  if (msg.includes("session expired")) return "Session expired — open LinkedIn and log in to re-sync";
  if (msg.includes("rate limit") || msg.includes("429")) return "Rate limited — wait a few minutes and try again";
  if (msg.includes("timed out")) return "Request timed out — bot may be busy, try again";
  if (msg.includes("Not authenticated")) return "Not paired — click the extension icon to set up";
  return msg;
}

function linkedinCmd(type, params = {}) {
  return new Promise((resolve, reject) => {
    const attempt = (retryLeft) => {
      chrome.runtime.sendMessage({ action: "linkedin-command", type, params }, (res) => {
        const err = chrome.runtime.lastError;
        if (err && retryLeft > 0 && (err.message.includes("context invalidated") || err.message.includes("establish connection"))) {
          setTimeout(() => attempt(retryLeft - 1), 1000);
          return;
        }
        if (err) return reject(new Error(friendlyError(err.message)));
        if (res?.error) return reject(new Error(friendlyError(res.error)));
        resolve(res);
      });
    };
    attempt(2);
  });
}

// Keep service worker alive while panel is active
let _keepaliveInterval = null;
function startKeepalive() {
  if (_keepaliveInterval) return;
  _keepaliveInterval = setInterval(() => {
    try { chrome.runtime.sendMessage({ action: "keepalive" }).catch(() => {}); }
    catch { clearInterval(_keepaliveInterval); _keepaliveInterval = null; }
  }, 25000);
}

/**
 * r44: The accept-and-DM batch loop, extracted from the panel's start()
 * handler so it can run inside the hidden worker tab while progress is
 * relayed back to whichever tab's panel kicked it off. The panel's
 * start() handler now just reads the form, asks background to spin
 * this up on the worker tab, then sits and waits for a final summary.
 *
 * When run via the worker-tab path, _NX_BACKGROUND_BATCH is set so
 * every addLog / setStatus inside this function transparently relays
 * to the panel tab via background.js. When run locally (legacy or
 * fallback) the flag is null and addLog / setStatus stay local.
 *
 * config: { maxProcess, template, delayMs, skipNoMsg, likeFirst }
 * returns: { accepted, messaged, attempted, error? }
 */
async function runAcceptAndDmBatch(config) {
  const { maxProcess, template, delayMs, skipNoMsg, likeFirst } = config;
  // r47: variation pool — array of pre-generated AI siblings of `template`.
  // Each iteration pulls one without replacement. When the pool empties
  // mid-batch we refill from the original list (sampling with replacement
  // is still better than every recipient seeing the same wording the
  // user typed).
  const variationPool = Array.isArray(config.variations) && config.variations.length > 0
    ? [...config.variations]
    : [template];
  const originalVariations = [...variationPool];

  function nextVariation() {
    if (variationPool.length === 0) {
      // Refill — refill is logged once by the caller, not per-recipient.
      variationPool.push(...originalVariations);
    }
    const idx = Math.floor(Math.random() * variationPool.length);
    return variationPool.splice(idx, 1)[0];
  }

  let attempted = 0, accepted = 0, messaged = 0;

  // r43: REDIRECT_LIMIT circuit breaker — see comments at the original
  // site below. INVITATION_URL stays here so the hard-nav fallback can
  // bounce us back if history.back() doesn't restore us.
  const INVITATION_URL = "https://www.linkedin.com/mynetwork/invitation-manager/received/";

  // r49: active-hours hard gate. The bot's server-side quiet-hours
  // check is 23:00-06:00 only — the client gate covers the broader
  // "outside my working window" case the user can configure. We bail
  // early so the batch never even gets to spin up the worker tab.
  const ahCheck = await _isWithinActiveHours();
  if (!ahCheck.allowed) {
    const window = `${String(ahCheck.startHour).padStart(2,"0")}:00-${String(ahCheck.endHour).padStart(2,"0")}:00`;
    setStatus(`Outside active hours (${window}). Now ${ahCheck.hour}:00 — try again during the window.`);
    addLog("-", `Active-hours guard: now ${ahCheck.hour}:00, window ${window}. Set chrome.storage.local._liActiveHours = {startHour, endHour} to customize.`);
    return { accepted: 0, messaged: 0, attempted: 0, error: "outside_active_hours" };
  }

  // r47: surface the user's starting LinkedIn budget so they see what
  // headroom they have for today before the batch eats into it. The
  // pill informs but doesn't block — actual enforcement happens via
  // _checkLiLimit() before each action in the loop below.
  try {
    const budget = await new Promise((resolve) => {
      chrome.runtime.sendMessage({ action: "li-budget" }, (r) => {
        if (chrome.runtime.lastError) resolve(null); else resolve(r);
      });
    });
    if (budget && !budget.error) {
      const dm = budget.message;
      const conn = budget.connect_request;
      const lik = budget.like;
      const age = dm?.accountAgeMultiplier;
      const ageBit = age != null && age < 1.0 ? ` · age x${age.toFixed(1)} (warmup)` : "";
      addLog("-", `Today's LinkedIn budget — DMs ${dm?.remainingToday ?? "?"}/${dm?.dailyLimit ?? "?"} · connects ${conn?.remainingToday ?? "?"}/${conn?.dailyLimit ?? "?"} · likes ${lik?.remainingToday ?? "?"}/${lik?.dailyLimit ?? "?"}${ageBit}`);
    }
  } catch { /* budget readout is best-effort, never block on it */ }

  setStatus("Scanning invitations on this page…");
  addLog("...", "Looking for pending invitations in the DOM");

  try {
    let snapshot = await scanPendingInvitations({ scroll: true });
    if (snapshot.length === 0) {
      setStatus("No pending invitations");
      addLog("-", "No Accept buttons found on this page");
      return { accepted: 0, messaged: 0, attempted: 0 };
    }
    addLog("V", `Found ${snapshot.length} pending invitations on first scan`);

    // Cross-reference with Voyager pending-invitations API for URN fills.
    const nameToUrn = new Map();
    try {
      const apiRes = await Promise.race([
        linkedinCmd("linkedin:http-pending-invitations", { count: 50 }),
        new Promise((_, rej) => setTimeout(() => rej(new Error("URN prefetch timeout (8s)")), 8000)),
      ]);
      const invs = apiRes?.invitations || [];
      for (const i of invs) {
        const key = (i.senderName || "").toLowerCase().trim();
        if (!key) continue;
        const rawUrn = i.senderUrn || i.miniProfile?.objectUrn || i.miniProfile?.entityUrn || "";
        const fsdMatch = rawUrn.match(/fsd_profile:([A-Za-z0-9_-]+)/);
        const memMatch = rawUrn.match(/member:(\d+)/);
        let urn = null;
        if (fsdMatch) urn = `urn:li:fsd_profile:${fsdMatch[1]}`;
        else if (memMatch) urn = `urn:li:fsd_profile:${memMatch[1]}`;
        if (urn) nameToUrn.set(key, urn);
      }
      addLog("-", `Loaded ${nameToUrn.size} URNs from invitations API (will use to fill DOM gaps)`);
    } catch (err) {
      addLog("!", `Could not prefetch URNs from API — ${err.message}. DOM-only mode.`);
    }

    for (const entry of snapshot) {
      if (!entry.urn && entry.name) {
        const apiUrn = nameToUrn.get(entry.name.toLowerCase().trim());
        if (apiUrn) { entry.urn = apiUrn; entry._urnSource = "api"; }
      }
    }

    let noProgressLoops = 0;
    const NO_PROGRESS_LIMIT = 3;
    let consecutiveDmFailures = 0;
    const DM_FAILURE_LIMIT = 3;
    let consecutiveRedirects = 0;
    const REDIRECT_LIMIT = 3;
    const seenBtns = new WeakSet();
    let didLoadMoreScroll = false;

    while (attempted < maxProcess) {
      if (isCancelled()) { addLog("-", "Cancelled before next invitation"); break; }
      if (noProgressLoops >= NO_PROGRESS_LIMIT) {
        addLog("-", `Stopping: ${noProgressLoops} consecutive iterations with no pickable invitation`);
        break;
      }
      if (consecutiveDmFailures >= DM_FAILURE_LIMIT) {
        addLog("X", `Stopping: ${consecutiveDmFailures} DMs failed in a row — LinkedIn likely rate-limited DM sending for this session. Try again in 1-2h. Connections were still accepted.`);
        break;
      }

      // r48: account-wall watchdog. Cheap DOM + URL check per iteration.
      // If LinkedIn shows captcha / restricted / phone-verify / checkpoint,
      // report (fires Telegram alert + 24h global pause via the bot)
      // and abort this batch immediately.
      const wall = _detectAccountWall();
      if (wall) {
        addLog("X", `LinkedIn account wall: ${wall.type} at ${wall.url} — pausing batch. Check Telegram.`);
        setStatus(`⛔ Account wall (${wall.type}) — paused`);
        try { await _reportAccountWall(wall); } catch { /* best-effort */ }
        // Promote from an easy-to-miss log line to a blocking alert — a wall is
        // a ban-risk event the user MUST notice (visibility of system status).
        try { window.alert(`⛔ LinkedIn hit a security checkpoint (${wall.type}).\n\nThe batch was paused to protect your account. Open LinkedIn, resolve the checkpoint, and wait a while before running more automation.`); } catch {}
        break;
      }

      // Soft wall — 200 OK, but LinkedIn is telling us in prose that this
      // action category is capped. The hard-wall check above never sees these.
      // This batch's outbound action is the DM, so generic throttle phrases
      // are attributed to `message`; invitation-limit phrases carry their own
      // category (connect_request) and freeze only that.
      const soft = _detectSoftWall("message");
      if (soft) {
        addLog(
          "X",
          `LinkedIn soft wall: "${soft.phrase}" — freezing ${soft.actionType} for ${soft.hours}h and stopping this batch. Other action types keep running.`,
        );
        setStatus(`⏸ ${soft.actionType} capped by LinkedIn — paused`);
        try { await _reportSoftWall(soft); } catch { /* best-effort */ }
        break;
      }

      // r43: LinkedIn auto-redirects mid-batch (most often to a thread
      // page after accepting someone who already DM'd you). Try
      // history.back() to recover without losing the loop, fall back
      // to a hard nav as a last resort.
      if (!location.pathname.includes("/mynetwork/invitation-manager/received")) {
        consecutiveRedirects++;
        const redirectedTo = location.pathname;
        if (consecutiveRedirects > REDIRECT_LIMIT) {
          addLog("X", `LinkedIn redirected ${consecutiveRedirects} times in a row (most recent: ${redirectedTo}) — aborting batch. Likely a rate-limit or security check. Try again in 1-2h. Connections accepted so far were saved.`);
          try { window.location.assign(INVITATION_URL); } catch { /* ignore */ }
          break;
        }
        addLog("!", `LinkedIn redirected to ${redirectedTo} — recovering (${consecutiveRedirects}/${REDIRECT_LIMIT})…`);
        setStatus("Recovering from LinkedIn redirect…");
        let recovered = false;
        try {
          window.history.back();
          for (let waited = 0; waited < 5000 && !recovered; waited += 500) {
            await delay(500);
            if (location.pathname.includes("/mynetwork/invitation-manager/received")) recovered = true;
          }
        } catch (err) {
          addLog("X", `history.back() threw: ${err.message}`);
        }
        if (!recovered) {
          addLog("!", `history.back() didn't restore us — reloading to ${INVITATION_URL}. Click Start to resume after the page loads.`);
          try { window.location.assign(INVITATION_URL); } catch { /* ignore */ }
          break;
        }
        await delay(1500);
        snapshot = [];
        noProgressLoops = 0;
        addLog("V", `Recovered from redirect — back on invitation manager, resuming`);
        continue;
      }
      consecutiveRedirects = 0;

      // Dismiss leftover modal dialogs from a previous misclick.
      try {
        const openDialog = document.querySelector('div[role="dialog"]:not([aria-hidden="true"]), .artdeco-modal:not([aria-hidden="true"])');
        if (openDialog) {
          addLog("!", "Closing a leftover dialog before scanning");
          document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
          await delay(300);
          const dismiss = openDialog.querySelector('button[aria-label*="Dismiss" i], button[aria-label*="Close" i], button.artdeco-modal__dismiss');
          if (dismiss) dismiss.click();
          await delay(500);
        }
      } catch { /* ignore */ }

      // Re-scan when snapshot is empty.
      if (snapshot.length === 0) {
        snapshot = await scanPendingInvitations({ scroll: false });
        if (snapshot.length === 0) {
          if (didLoadMoreScroll) break;
          didLoadMoreScroll = true;
          addLog("-", "Snapshot empty — scrolling once to load more invitations");
          await _scrollToLoadMore({ waitMs: 2000, returnToTop: false });
          snapshot = await scanPendingInvitations({ scroll: false });
          if (snapshot.length === 0) break;
        }
        for (const entry of snapshot) {
          if (!entry.urn && entry.name) {
            const apiUrn = nameToUrn.get(entry.name.toLowerCase().trim());
            if (apiUrn) { entry.urn = apiUrn; entry._urnSource = "api"; }
          }
        }
      }

      // Pick the next pickable invitation.
      let inv = null;
      while (snapshot.length > 0) {
        const candidate = snapshot.shift();
        if (seenBtns.has(candidate.btn)) continue;
        if (!candidate.btn || !candidate.btn.isConnected) {
          if (candidate.btn) seenBtns.add(candidate.btn);
          continue;
        }
        if (skipNoMsg && !candidate.customMessage) {
          addLog("-", `${candidate.name}: skipped (no note)`);
          seenBtns.add(candidate.btn);
          continue;
        }
        inv = candidate;
        break;
      }
      if (!inv) {
        noProgressLoops++;
        continue;
      }

      noProgressLoops = 0;
      attempted++;
      seenBtns.add(inv.btn);
      setStatus(`(${attempted}/${maxProcess}) ${inv.name} — accepting…`);

      // Step 1: Accept.
      try {
        inv.btn.click();
        accepted++;
        addLog("V", `Accepted: ${inv.name}`);
        await delay(4000 + Math.random() * 2000);
      } catch (err) {
        addLog("X", `${inv.name}: accept failed — ${err.message}`);
        continue;
      }

      // Step 1.5: Like latest post (optional). r47: gated by daily/weekly
      // like budget — if today's like cap was already hit by a previous
      // batch / cron job, we skip the like step silently and still send
      // the DM. Likes are low-risk so we don't pause the whole batch
      // when they're capped, just drop the warm-up step.
      if (likeFirst && inv.publicIdentifier) {
        const likeBudget = await _checkLiLimit("like-latest-post-on-page");
        if (!likeBudget.allowed) {
          addLog("-", `${inv.name}: like-step skipped — ${likeBudget.blockedReason || "no budget"} — proceeding to DM`);
        } else {
        setStatus(`(${attempted}/${maxProcess}) ${inv.name} — liking latest post…`);
        try {
          const likeRes = await Promise.race([
            new Promise((resolve) => {
              chrome.runtime.sendMessage({
                action: "li-like-latest-post",
                slug: inv.publicIdentifier,
                urn: inv.urn || null,
              }, (response) => {
                const err = chrome.runtime.lastError;
                if (err) resolve({ success: false, error: err.message });
                else resolve(response || { success: false, error: "no response from background" });
              });
            }),
            new Promise((resolve) => setTimeout(() => resolve({
              success: false, reason: "like_timeout", error: "like step exceeded 30s — skipping",
            }), 30_000)),
          ]);
          if (likeRes?.success) {
            const path = likeRes.path || "unknown";
            const previewBit = likeRes.postText ? ` "${String(likeRes.postText).slice(0, 30)}…"` : "";
            const alreadyBit = likeRes.alreadyLiked ? " (already liked)" : "";
            addLog("V", `Liked latest post (${path}): ${inv.name}${previewBit}${alreadyBit}`);
          } else if (likeRes?.reason === "no_posts") {
            addLog("-", `${inv.name}: no recent posts to like — proceeding to DM`);
          } else {
            addLog("!", `${inv.name}: like step skipped (${likeRes?.reason || likeRes?.error || "unknown"}) — proceeding to DM`);
          }
        } catch (err) {
          addLog("!", `${inv.name}: like step threw — ${err.message} — proceeding to DM`);
        }
        await delay(1500 + Math.random() * 1500);
        } // close `else` branch of like-budget gate (r47)
      }

      // r48: organic-look random skip. Real humans accept connection
      // requests without always reaching out. Skip the DM 10% of the
      // time (per-recipient, never the first 3 of a batch so the
      // user still gets the bulk of their sends). The accept is
      // already done; the recipient sees a notification regardless.
      if (attempted > 3 && Math.random() < 0.10) {
        addLog("-", `${inv.name}: organic skip (no DM, looks more human)`);
        await humanDelay(delayMs * sessionWarmup(accepted));
        continue;
      }

      // Step 2: DM.
      if (!inv.publicIdentifier && !inv.urn) {
        addLog("-", `${inv.name}: no profile URL/URN on the card — DM skipped (check console for card sample)`);
        await humanDelay(delayMs * sessionWarmup(accepted));
        continue;
      }

      // r47/r48: DM gate. Two failure modes:
      //   (a) recipient_cooldown — we've already DM'd this person in the
      //       last 90d. SKIP this person, continue with the next
      //       (cooldown is per-recipient; other recipients are fine).
      //   (b) daily/weekly cap or quiet_hours — applies to the whole
      //       account. STOP the batch entirely; resume tomorrow.
      const dmBudget = await _checkLiLimit("send-dm-via-ui", {
        recipientSlug: inv.publicIdentifier || null,
        recipientUrn: inv.urn || null,
      });
      if (!dmBudget.allowed) {
        if (dmBudget.blockedReason && dmBudget.blockedReason.startsWith("recipient_cooldown")) {
          addLog("-", `${inv.name}: skipped — ${dmBudget.blockedReason}`);
          await humanDelay(delayMs * sessionWarmup(accepted));
          continue;
        }
        addLog("X", `Daily DM cap reached — ${dmBudget.blockedReason}. ${messaged} DMs sent this batch. Stopping; resume tomorrow.`);
        setStatus(`Stopped at DM cap — ${messaged} sent, ${accepted - messaged} accepted-without-DM`);
        break;
      }

      const first = (inv.name.split(" ")[0] || "").trim();
      const last = inv.name.split(" ").slice(1).join(" ").trim();
      // r47: pull the next variation from the AI-generated pool so
      // every recipient in the batch gets a distinct wording. When
      // the pool was empty (AI failed during setup), this just
      // returns the original template every time — identical to the
      // pre-r47 behavior, so worst case we don't regress.
      const chosenTemplate = nextVariation();

      // r49: per-recipient context for {headline}/{company}/{hook}
      // placeholders. Only fetched when the variation actually needs
      // them — most batches with simple templates skip this entirely.
      // 20s soft cap so a hung Voyager call can't stall the whole
      // batch (DM hang cap is 90s; we still need headroom for that).
      let recipientHeadline = "";
      let recipientCompany = "";
      let recipientHook = "";
      const needsHeadline = /\{headline\}/i.test(chosenTemplate);
      const needsCompany  = /\{company\}/i.test(chosenTemplate);
      const needsHook     = /\{hook\}/i.test(chosenTemplate);
      if ((needsHeadline || needsCompany || needsHook) && inv.publicIdentifier) {
        setStatus(`(${attempted}/${maxProcess}) ${inv.name} — fetching context…`);
        try {
          const ctx = await Promise.race([
            new Promise((resolve) => {
              chrome.runtime.sendMessage({
                action: "li-recipient-context",
                slug: inv.publicIdentifier,
                baseTemplate: chosenTemplate,
                generateHook: needsHook,
              }, (r) => {
                const e = chrome.runtime.lastError;
                if (e) resolve({ error: e.message });
                else resolve(r || { error: "no_response" });
              });
            }),
            new Promise((resolve) => setTimeout(() => resolve({ error: "context_timeout (20s)" }), 20_000)),
          ]);
          if (!ctx?.error) {
            recipientHeadline = ctx.headline || "";
            recipientCompany = ctx.company || "";
            recipientHook = ctx.hook || "";
            const noteBits = [];
            if (recipientHeadline) noteBits.push("headline");
            if (recipientCompany) noteBits.push(`company=${recipientCompany}`);
            if (recipientHook) noteBits.push("hook");
            if (noteBits.length > 0 && !ctx.cached) {
              addLog("-", `${inv.name}: context loaded (${noteBits.join(", ")})`);
            }
            if (needsHook && !recipientHook) {
              addLog("-", `${inv.name}: hook empty (${ctx.hookReason || "no reason"}) — variation will send without it`);
            }
          } else {
            addLog("-", `${inv.name}: context fetch skipped (${ctx.error}) — using empty values`);
          }
        } catch (err) {
          addLog("-", `${inv.name}: context fetch threw (${err.message}) — using empty values`);
        }
      }

      const msg = chosenTemplate
        .replace(/\{firstName\}/gi, first)
        .replace(/\{lastName\}/gi, last)
        .replace(/\{name\}/gi, inv.name)
        .replace(/\{headline\}/gi, recipientHeadline)
        .replace(/\{company\}/gi, recipientCompany)
        .replace(/\{hook\}/gi, recipientHook)
        // Tidy up: collapse the double-spaces / leading-comma artifacts
        // that show up when a {hook} substitutes empty.
        .replace(/  +/g, " ")
        .replace(/\s+([,.!?])/g, "$1")
        .trim();

      setStatus(`(${attempted}/${maxProcess}) ${inv.name} — sending DM (DOM)…`);
      let dmSucceeded = false;
      let dmDetail = "";
      try {
        const domDm = await Promise.race([
          new Promise((resolve) => {
            chrome.runtime.sendMessage({
              action: "li-send-dm-via-ui",
              slug: inv.publicIdentifier || null,
              urn: inv.urn || null,
              message: msg,
            }, (response) => {
              const err = chrome.runtime.lastError;
              if (err) resolve({ success: false, error: err.message, errorCode: "runtime_error" });
              else resolve(response || { success: false, error: "no response from background" });
            });
          }),
          new Promise((resolve) => setTimeout(() => resolve({
            success: false, error: "DM call hung — service worker timeout (90s)", errorCode: "sw_timeout",
          }), 90_000)),
        ]);
        if (domDm?.success) {
          dmSucceeded = true;
          messaged++;
          addLog("V", `DM sent (DOM): ${inv.name}`);
        } else {
          dmDetail = domDm?.error || "unknown DOM-path failure";
          addLog("-", `${inv.name}: DOM DM failed — ${dmDetail} (will try Voyager fallback)`);
          console.warn("[Nexus] DOM DM failure, will fallback:", { name: inv.name, slug: inv.publicIdentifier, response: domDm });
          if (domDm?.debug) {
            console.warn("[Nexus] DM diagnostic dump:");
            console.warn("  composeInfo:", domDm.debug.composeInfo);
            console.warn("  sendBtnInfo:", domDm.debug.sendBtnInfo);
            console.warn("  composeHtml:", domDm.debug.composeHtml);
            console.warn("  sendBtnHtml:", domDm.debug.sendBtnHtml);
            addLog("!", `Compose HTML dumped to console for diagnostics`);
          }
        }
      } catch (err) {
        dmDetail = err.message;
        addLog("-", `${inv.name}: DOM DM threw — ${err.message} (will try Voyager fallback)`);
      }

      let voyagerDetail = "";
      if (!dmSucceeded && !isCancelled() && inv.publicIdentifier) {
        setStatus(`(${attempted}/${maxProcess}) ${inv.name} — Voyager fallback…`);
        try {
          const dm = await linkedinCmd("linkedin:http-message", {
            publicIdentifier: inv.publicIdentifier, message: msg,
          });
          if (dm?.success) {
            dmSucceeded = true;
            messaged++;
            addLog("V", `DM sent (Voyager fallback): ${inv.name}`);
          } else {
            voyagerDetail = dm?.error ? dm.error : `unexpected response: ${JSON.stringify(dm).slice(0, 200)}`;
            console.warn("[Nexus] accept-and-dm Voyager failed:", { name: inv.name, slug: inv.publicIdentifier, voyager: dm });
          }
        } catch (err) {
          voyagerDetail = `threw: ${err.message}`;
          console.error("[Nexus] accept-and-dm Voyager threw:", { name: inv.name, slug: inv.publicIdentifier, error: err });
        }
      }

      if (!dmSucceeded && !isCancelled() && (inv.publicIdentifier || inv.urn)) {
        addLog("-", `${inv.name}: both paths failed — waiting 30s for LinkedIn to propagate the accept, then retrying`);
        setStatus(`(${attempted}/${maxProcess}) ${inv.name} — waiting 30s, then retry…`);
        for (let waited = 0; waited < 30000 && !isCancelled(); waited += 2000) {
          await delay(2000);
        }
        if (!isCancelled()) {
          setStatus(`(${attempted}/${maxProcess}) ${inv.name} — retry DM…`);
          try {
            const retryDm = await Promise.race([
              new Promise((resolve) => {
                chrome.runtime.sendMessage({
                  action: "li-send-dm-via-ui",
                  slug: inv.publicIdentifier || null,
                  urn: inv.urn || null,
                  message: msg,
                }, (response) => {
                  const err = chrome.runtime.lastError;
                  if (err) resolve({ success: false, error: err.message, errorCode: "runtime_error" });
                  else resolve(response || { success: false, error: "no response from background" });
                });
              }),
              new Promise((resolve) => setTimeout(() => resolve({
                success: false, error: "retry DM call hung — service worker timeout (90s)", errorCode: "sw_timeout",
              }), 90_000)),
            ]);
            if (retryDm?.success) {
              dmSucceeded = true;
              messaged++;
              addLog("V", `DM sent (retry after 30s wait): ${inv.name}`);
            } else {
              addLog("X", `${inv.name}: DM error after retry — DOM: ${dmDetail || "n/a"} | Voyager: ${voyagerDetail || "n/a"} | Retry: ${retryDm?.error || "no response"}`);
            }
          } catch (err) {
            addLog("X", `${inv.name}: retry threw — ${err.message}`);
          }
        }
      } else if (!dmSucceeded) {
        addLog("!", `${inv.name}: BOTH paths failed — DOM: ${dmDetail || "n/a"} | Voyager: ${voyagerDetail || "n/a"}`);
      }

      if (dmSucceeded) consecutiveDmFailures = 0;
      else consecutiveDmFailures++;

      await humanDelay(delayMs * sessionWarmup(accepted));
    }

    setStatus(`Done — accepted ${accepted}, messaged ${messaged}`);
    addLog("V", `Complete: ${accepted} accepted, ${messaged} DM'd`);
    return { accepted, messaged, attempted };
  } catch (err) {
    setStatus("Error: " + err.message);
    addLog("X", err.message);
    return { accepted, messaged, attempted, error: err.message };
  }
}

// ── Config templates per action ────────────────────────────────────────

const ACTION_CONFIGS = {
  "add-connections": {
    title: "Add New Connections",
    html: `
      <label class="nx-label">Search keyword</label>
      <input class="nx-input" id="cfg-keyword" type="text" placeholder="CEO web3 DeFi crypto" value="CEO web3">
      <label class="nx-label">Connections</label>
      <div class="nx-input-row">
        <label class="nx-checkbox"><input type="checkbox" id="cfg-deg2" checked> 2nd</label>
        <label class="nx-checkbox"><input type="checkbox" id="cfg-deg3"> 3rd+</label>
      </div>
      <label class="nx-label">Personal message</label>
      <div class="nx-var-chips">
        <span class="nx-chip" data-var="@firstName">@firstName</span>
        <span class="nx-chip" data-var="@lastName">@lastName</span>
        <span class="nx-chip" data-var="@name">@name</span>
      </div>
      <textarea class="nx-input nx-textarea" id="cfg-message" rows="3" placeholder="Hi @firstName! I'd love to connect..."></textarea>
      <label class="nx-label">Exclude keywords</label>
      <input class="nx-input" id="cfg-exclude" type="text" placeholder="Marketing, Manager, John">
      <div class="nx-input-row">
        <div><label class="nx-label">Count</label><input class="nx-input nx-small" id="cfg-count" type="number" value="10" min="1" max="50"></div>
        <div><label class="nx-label">Delay (sec)</label><input class="nx-input nx-small" id="cfg-delay" type="number" value="3" min="1" max="30"></div>
      </div>
      <label class="nx-checkbox" style="margin:8px 0;"><input type="checkbox" id="cfg-like-first" checked> Like their latest activity before connecting</label>
      <label class="nx-checkbox" style="margin:4px 0;"><input type="checkbox" id="cfg-enrich"> Filter by profile relevance (check headline matches keyword)</label>`,
    impact: (shadow) => ({ verb: "Send connection requests to", count: clampCount(shadow.getElementById("cfg-count").value, 10), budgetKey: "connect" }),
    async start(shadow) {
      const keyword = shadow.getElementById("cfg-keyword").value.trim() || "CEO web3";
      const count = clampCount(shadow.getElementById("cfg-count").value, 10);
      const message = shadow.getElementById("cfg-message").value.trim() || null;
      const exclude = shadow.getElementById("cfg-exclude").value.trim().toLowerCase().split(",").map(s => s.trim()).filter(Boolean);
      const delayMs = (parseInt(shadow.getElementById("cfg-delay").value) || 3) * 1000;
      const likeFirst = shadow.getElementById("cfg-like-first").checked;
      const enrichFilter = shadow.getElementById("cfg-enrich").checked;

      addLog("S", `Searching: "${keyword}" — target: ${count} connects`);

      try {
        let sent = 0, skipped = 0, page = 0;
        const maxPages = 10; // up to 100 search results

        while (sent < count && page < maxPages) {
          const start = page * 10;
          setStatus(`(${sent}/${count} sent) Searching page ${page + 1}...`);

          let results;
          try {
            const searchResult = await linkedinCmd("linkedin:http-search", { query: keyword, limit: 10, start });
            results = searchResult.results || [];
            if (page === 0 && results.length === 0) {
              addLog("X", "Search returned 0 results — LinkedIn session may need re-sync. Open LinkedIn in browser and reload extension.");
            }
          } catch (err) {
            addLog("X", `Search failed: ${err.message}`);
            break;
          }
          if (results.length === 0) { addLog("-", `No more search results (page ${page + 1})`); break; }

          // Filter out already-attempted profiles
          const profileIds = results.map(p => {
            const urn = p.entityUrn || p.trackingUrn || "";
            return urn.includes(":") ? urn.split(":").pop() : urn;
          }).filter(Boolean);

          let alreadyAttempted = new Set();
          try {
            const checkResult = await linkedinCmd("linkedin:connect-attempts-check", { profileIds });
            alreadyAttempted = new Set((checkResult.attempted || []).map(String));
          } catch {}

          const fresh = results.filter(p => {
            const urn = p.entityUrn || p.trackingUrn || "";
            const id = urn.includes(":") ? urn.split(":").pop() : urn;
            return !alreadyAttempted.has(id);
          });

          if (fresh.length === 0) {
            // Entire page already attempted — skip to next page
            addLog("-", `Page ${page + 1}: all ${results.length} already attempted`);
            page++;
            continue;
          }
          addLog("-", `Page ${page + 1}: ${fresh.length}/${results.length} fresh`);

          // Send connections for fresh candidates on this page
          for (const person of fresh) {
            if (sent >= count) break;
            const profileUrn = person.entityUrn || person.trackingUrn || "";
            // Keep the full URN — the API needs it to identify the profile correctly
            const profileId = profileUrn;
            const name = person.title?.text || [person.firstName, person.lastName].filter(Boolean).join(" ") || "Unknown";
            // Extract publicIdentifier from navigationUrl if not directly available
            let pubId = person.publicIdentifier;
            if (!pubId && person.navigationUrl) {
              const m = person.navigationUrl.match(/\/in\/([^/?]+)/);
              if (m) pubId = m[1];
            }
            const navUrl = person.navigationUrl || (pubId ? `https://www.linkedin.com/in/${pubId}/` : null);

            // Skip if excluded keyword
            if (exclude.length > 0 && exclude.some(ex => name.toLowerCase().includes(ex))) {
              skipped++;
              continue;
            }

            // Profile enrichment filter — check headline matches search keyword
            if (enrichFilter && pubId) {
              try {
                const profile = await linkedinCmd("linkedin:http-view-profile", { publicIdentifier: pubId });
                const headline = (profile.profile?.headline || "").toLowerCase();
                const searchTerms = keyword.toLowerCase().split(/\s+/);
                const matches = searchTerms.some(t => headline.includes(t));
                if (!matches) {
                  skipped++;
                  addLog("-", `${name}: headline doesn't match "${keyword}"`, navUrl);
                  continue;
                }
              } catch {} // If enrichment fails, proceed anyway
              await humanDelay(1000);
            }

            setStatus(`(${sent + 1}/${count}) ${likeFirst ? "Engaging" : "Connecting"}: ${name}...`);

            // Like their latest activity before connecting
            if (likeFirst && pubId) {
              try {
                const activity = await linkedinCmd("linkedin:http-user-activity", { publicIdentifier: pubId });
                const latestPost = (activity.activities || [])[0];
                if (latestPost?.urn) {
                  await linkedinCmd("linkedin:http-like-post", { postUrn: latestPost.urn });
                  addLog("H", `Liked ${name}'s post`, navUrl);
                  await humanDelay(2000);
                }
              } catch {} // non-critical
            }

            // Personalize message
            let personalizedMsg = message;
            if (personalizedMsg) {
              const firstName = (person.firstName || name.split(" ")[0] || "").trim();
              const lastName = (person.lastName || name.split(" ").slice(1).join(" ") || "").trim();
              personalizedMsg = personalizedMsg
                .replace(/@firstName/gi, firstName)
                .replace(/@lastName/gi, lastName)
                .replace(/@name/gi, name);
            }

            try {
              // profileUrn may be a tracking URN — extract the numeric ID for the invitation API
              const connectId = profileId || profileUrn;
              if (!connectId) { addLog("-", `${name}: no profile ID, skipping`); continue; }

              const result = await linkedinCmd("linkedin:http-send-connection", { profileUrn: connectId, publicIdentifier: pubId, message: personalizedMsg || "" });
              if (result.success) {
                sent++;
                addLog("V", `Sent: ${name}`, navUrl);
                linkedinCmd("linkedin:connect-attempts-log", { profileId: connectId, keyword, status: "sent" }).catch(() => {});
                crmSync({ platform: "linkedin", identifier: pubId || name, displayName: name, source: `search:${keyword}`, action: "connect_sent" });
              } else {
                const detail = typeof result.detail === "string" ? result.detail.slice(0, 60) : `status ${result.status || "unknown"}`;
                addLog("-", `${name}: ${detail}`, navUrl);
                linkedinCmd("linkedin:connect-attempts-log", { profileId: connectId, keyword, status: "failed" }).catch(() => {});
              }
            } catch (err) {
              addLog("X", `${name}: ${err.message}`);
              // Stop entirely on session expired or rate limit
              if (err.message.includes("session expired") || err.message.includes("rate limit")) {
                setStatus(err.message);
                return;
              }
            }
            await maybeFatiguePause(sent);
            await humanDelay((delayMs + 1500) * sessionWarmup(sent));
          }

          page++;
        }

        setStatus(`Done! Sent: ${sent}${skipped ? `, Skipped: ${skipped}` : ""}`);
        addLog("V", `Done! ${sent}/${count} connections sent`);
      } catch (err) {
        setStatus("Error: " + err.message);
        addLog("X", err.message);
      }
    },
  },
  "like-posts": {
    title: "Like Posts",
    html: `
      <label class="nx-label">Posts to like</label>
      <input class="nx-input nx-small" id="cfg-count" type="number" value="10" min="1" max="100">
      <label class="nx-label">Keyword filter (optional)</label>
      <input class="nx-input" id="cfg-keywords" type="text" placeholder="crypto, web3, DeFi">
      <label class="nx-label">Commenting</label>
      <label class="nx-checkbox"><input type="checkbox" id="cfg-comment"> Also comment on posts</label>
      <textarea class="nx-input nx-textarea" id="cfg-comment-text" rows="2" placeholder="Comment text" disabled></textarea>
      <div class="nx-input-row">
        <div><label class="nx-label">Every N</label><input class="nx-input nx-small" id="cfg-every" type="number" value="3" min="1" max="20" disabled></div>
      </div>`,
    init(shadow) {
      const cb = shadow.getElementById("cfg-comment");
      cb.addEventListener("change", () => {
        shadow.getElementById("cfg-comment-text").disabled = !cb.checked;
        shadow.getElementById("cfg-every").disabled = !cb.checked;
      });
    },
    async start(shadow) {
      const count = clampCount(shadow.getElementById("cfg-count").value, 10);
      const keywordFilter = shadow.getElementById("cfg-keywords").value.trim().toLowerCase();
      const doComment = shadow.getElementById("cfg-comment").checked;
      const commentText = shadow.getElementById("cfg-comment-text").value.trim();
      const commentEveryN = parseInt(shadow.getElementById("cfg-every").value) || 3;

      // Sanity: like+comment needs to be on a page with a visible feed.
      // The DOM handler will scroll the current page to load posts. If we're
      // not on /feed/ or a similar page with posts, tell the user.
      const onFeedPage = window.location.pathname === "/feed/"
        || window.location.pathname.startsWith("/feed")
        || window.location.pathname.includes("/in/"); // profile pages also have post lists
      if (!onFeedPage) {
        setStatus("Open linkedin.com/feed/ first");
        addLog("-", `This page doesn't have a feed. Navigate to /feed/ then click Start.`);
        return;
      }

      addLog("...", `Liking ${count} posts${doComment ? " + commenting every " + commentEveryN : ""}${keywordFilter ? ` (filter: "${keywordFilter}")` : ""}`);

      // Use the DOM-based handler — it scrolls the visible feed and operates
      // on real post elements (no Voyager feed-fetch which 400s in 2026).
      // The handler has cancel-awareness + per-post Voyager fallback baked in.
      try {
        const result = await handleLikeAndComment({
          count,
          commentText: doComment ? commentText : "",
          useAI: false,
          commentEveryN: doComment ? commentEveryN : 0,
          keywordFilter,
        });
        if (result.success) {
          setStatus(`Done! Liked ${result.liked}${result.commented ? `, Commented ${result.commented}` : ""}`);
        }
      } catch (err) {
        setStatus("Error: " + err.message);
        addLog("X", err.message);
      }
    },
  },
  "message-connections": {
    title: "Message Connections",
    html: `
      <label class="nx-label">Message template</label>
      <div class="nx-var-chips">
        <span class="nx-chip" data-var="{name}">{name}</span>
        <span class="nx-chip" data-var="{firstName}">{firstName}</span>
      </div>
      <textarea class="nx-input nx-textarea" id="cfg-message" rows="3" placeholder="Hi {name}! I wanted to reach out about..."></textarea>
      <div class="nx-input-row">
        <div><label class="nx-label">Count</label><input class="nx-input nx-small" id="cfg-count" type="number" value="10" min="1" max="50"></div>
        <label class="nx-checkbox"><input type="checkbox" id="cfg-ai"> AI personalize</label>
      </div>`,
    impact: (shadow) => shadow.getElementById("cfg-message").value.trim()
      ? ({ verb: "Message", count: clampCount(shadow.getElementById("cfg-count").value, 10), budgetKey: "dm" })
      : null,
    async start(shadow) {
      const message = shadow.getElementById("cfg-message").value.trim();
      const count = clampCount(shadow.getElementById("cfg-count").value, 10);
      if (!message) { setStatus("Enter a message"); return; }

      setStatus("Searching connections to message...");
      addLog("...", `Messaging ${count} connections`);

      // Use search to find 1st-degree connections, then message them
      try {
        const searchResult = await linkedinCmd("linkedin:http-search", { query: "*", limit: count * 2 });
        // Note: messaging requires recipientUrn which needs profile entity URN
        // For now, fall back to the old navigation-based approach for messaging
        // since we need the recipient's full member URN to send messages via API
        chrome.runtime.sendMessage({ action: "panel-message-connections", message, count, useAI: false }, (res) => {
          if (res?.error) { setStatus("Error: " + res.error); addLog("X", res.error); }
        });
      } catch {
        // Fallback to navigation-based approach
        chrome.runtime.sendMessage({ action: "panel-message-connections", message, count, useAI: false }, (res) => {
          if (res?.error) { setStatus("Error: " + res.error); addLog("X", res.error); }
        });
      }
    },
  },
  "accept-invites": {
    title: "Accept Received Invites",
    html: `
      <label class="nx-label">Max to accept</label>
      <input class="nx-input nx-small" id="cfg-count" type="number" value="20" min="1" max="100">
      <label class="nx-checkbox"><input type="checkbox" id="cfg-msg"> Send follow-up message</label>
      <textarea class="nx-input nx-textarea" id="cfg-message" rows="2" placeholder="Thanks for connecting, {name}!" disabled></textarea>`,
    init(shadow) {
      shadow.getElementById("cfg-msg").addEventListener("change", (e) => {
        shadow.getElementById("cfg-message").disabled = !e.target.checked;
      });
    },
    start(shadow) {
      setStatus("Accepting invites...");
      addLog("...", "Accepting pending invites");
      chrome.runtime.sendMessage({ action: "panel-accept-invites" }, (res) => {
        if (res?.error) { setStatus("Error: " + res.error); addLog("X", res.error); }
      });
    },
  },
  "withdraw-invites": {
    title: "Withdraw Sent Invites",
    html: `
      <label class="nx-label">Max to withdraw</label>
      <input class="nx-input nx-small" id="cfg-count" type="number" value="20" min="1" max="100">`,
    start(shadow) {
      setStatus("Withdrawing sent invites...");
      addLog("...", "Withdrawing sent invites");
      chrome.runtime.sendMessage({ action: "panel-withdraw-invites" }, (res) => {
        if (res?.error) { setStatus("Error: " + res.error); addLog("X", res.error); }
      });
    },
  },
  "follow-connections": {
    title: "Follow New Connections",
    html: `
      <label class="nx-label">Max to follow</label>
      <input class="nx-input nx-small" id="cfg-count" type="number" value="20" min="1" max="100">`,
    start(shadow) {
      setStatus("Following suggested connections...");
      addLog("...", "Following connections");
      chrome.runtime.sendMessage({ action: "panel-follow-connections" }, (res) => {
        if (res?.error) { setStatus("Error: " + res.error); addLog("X", res.error); }
      });
    },
  },
  "view-profiles": {
    title: "View Profiles",
    html: `
      <label class="nx-label">Search keyword</label>
      <input class="nx-input" id="cfg-keyword" type="text" placeholder="CEO web3" value="CEO web3">
      <label class="nx-label">Profiles to view</label>
      <input class="nx-input nx-small" id="cfg-count" type="number" value="10" min="1" max="50">
      <p class="nx-hint">Searches and views profiles via HTTP — counts as a profile visit.</p>`,
    async start(shadow) {
      const keyword = shadow.getElementById("cfg-keyword").value.trim() || "CEO web3";
      const count = clampCount(shadow.getElementById("cfg-count").value, 10);
      setStatus(`Searching profiles: "${keyword}"...`);
      addLog("...", `Viewing ${count} profiles for "${keyword}"`);

      try {
        const searchResult = await linkedinCmd("linkedin:http-search", { query: keyword, limit: count * 2 });
        if (!searchResult.results?.length) {
          setStatus("No profiles found");
          return;
        }

        let viewed = 0;
        for (const person of searchResult.results) {
          if (viewed >= count) break;
          const slug = person.publicIdentifier || person.entityUrn?.split(":").pop();
          if (!slug) continue;
          const name = person.title?.text || [person.firstName, person.lastName].filter(Boolean).join(" ") || slug;
          const profileUrl = `https://www.linkedin.com/in/${slug}/`;

          setStatus(`(${viewed + 1}/${count}) Viewing: ${name}`);
          try {
            await linkedinCmd("linkedin:http-view-profile", { publicIdentifier: slug });
            viewed++;
            addLog("E", `Viewed: ${name}`, profileUrl);
          } catch (err) {
            addLog("-", `${name}: ${err.message}`);
          }
          await humanDelay(4000 * sessionWarmup(viewed));
        }
        setStatus(`Done! Viewed ${viewed} profiles`);
        addLog("V", `Viewed ${viewed} profiles`);
      } catch (err) {
        setStatus("Error: " + err.message);
        addLog("X", err.message);
      }
    },
  },
  "connect-commenters": {
    title: "Connect with Commenters",
    html: `
      <label class="nx-label">Connections to send</label>
      <input class="nx-input nx-small" id="cfg-count" type="number" value="10" min="1" max="50">
      <label class="nx-label">Personal message (optional)</label>
      <textarea class="nx-input nx-textarea" id="cfg-message" rows="2" placeholder="Hi @firstName! Loved your comment..."></textarea>
      <label class="nx-label">Post keyword filter (optional)</label>
      <input class="nx-input" id="cfg-keywords" type="text" placeholder="crypto, web3, defi">
      <p class="nx-hint">Finds people who commented on posts in your feed and sends them connection requests.</p>`,
    impact: (shadow) => ({ verb: "Send connection requests to", count: clampCount(shadow.getElementById("cfg-count").value, 10), budgetKey: "connect" }),
    async start(shadow) {
      const count = clampCount(shadow.getElementById("cfg-count").value, 10);
      const message = shadow.getElementById("cfg-message").value.trim() || null;
      const keywordFilter = shadow.getElementById("cfg-keywords").value.trim().toLowerCase();

      addLog("S", `Finding commenters — target: ${count} connects`);

      try {
        // Step 1: Fetch feed posts
        setStatus("Fetching feed posts...");
        let feedResult;
        try {
          feedResult = await linkedinCmd("linkedin:http-feed", { count: 30 });
        } catch (err) {
          // LinkedIn's /voyager/api/feed endpoint family is heavily deprecated
          // in 2026 — returns 400 for most accounts. The DOM-based approach
          // for THIS action (scrape feed + expand comments per post) is
          // possible but non-trivial and not yet built. For now, surface a
          // clear error instead of the cryptic "400" so users know it's
          // a known LinkedIn API issue, not their config.
          if (String(err.message).includes("400") || String(err.message).includes("rate limit")) {
            setStatus("LinkedIn deprecated this endpoint — feature offline");
            addLog("!", `Feed-fetch endpoint returned ${err.message}. LinkedIn deprecated it; this action needs a DOM-based rewrite. Other actions (connects, likes, accept-and-dm) still work.`);
            return;
          }
          throw err;
        }
        if (!feedResult.posts?.length) {
          setStatus("No feed posts found");
          return;
        }

        let posts = feedResult.posts;
        // Optional keyword filter on post content
        if (keywordFilter) {
          posts = posts.filter(p => p.text.toLowerCase().includes(keywordFilter));
          addLog("-", `${posts.length} posts match "${keywordFilter}"`);
        }
        if (posts.length === 0) { setStatus("No matching posts"); return; }

        // Step 2: Extract commenters from posts
        setStatus(`Scanning comments on ${posts.length} posts...`);
        const allCommenters = [];
        const seenUrns = new Set();

        for (const post of posts) {
          if (allCommenters.length >= count * 3) break; // enough candidates
          try {
            const result = await linkedinCmd("linkedin:http-get-comments", { postUrn: post.urn, count: 20 });
            for (const c of (result.commenters || [])) {
              if (c.entityUrn && !seenUrns.has(c.entityUrn)) {
                seenUrns.add(c.entityUrn);
                allCommenters.push({ ...c, sourcePost: post.actorName });
              }
            }
          } catch {}
          await humanDelay(800);
        }

        if (allCommenters.length === 0) {
          setStatus("No commenters found");
          addLog("-", "No comments on feed posts");
          return;
        }

        addLog("V", `Found ${allCommenters.length} unique commenters`);

        // Step 3: Filter already attempted
        const profileIds = allCommenters.map(c => {
          const urn = c.entityUrn || "";
          return urn.includes(":") ? urn.split(":").pop() : urn;
        }).filter(Boolean);

        let alreadyAttempted = new Set();
        try {
          const check = await linkedinCmd("linkedin:connect-attempts-check", { profileIds });
          alreadyAttempted = new Set((check.attempted || []).map(String));
        } catch {}

        const fresh = allCommenters.filter(c => {
          const id = (c.entityUrn || "").includes(":") ? c.entityUrn.split(":").pop() : c.entityUrn;
          return !alreadyAttempted.has(id);
        });

        if (fresh.length === 0) {
          setStatus("All commenters already attempted");
          return;
        }

        // Step 4: Like comment → then send connection
        let sent = 0;
        for (const person of fresh) {
          if (sent >= count) break;
          const name = [person.firstName, person.lastName].filter(Boolean).join(" ") || "Unknown";
          const profileId = person.entityUrn?.includes(":") ? person.entityUrn.split(":").pop() : person.entityUrn;
          const profileUrl = person.publicIdentifier ? `https://www.linkedin.com/in/${person.publicIdentifier}/` : null;

          // Like their comment first (warms up the connection request)
          if (person.commentUrn) {
            try {
              await linkedinCmd("linkedin:http-like-comment", { commentUrn: person.commentUrn });
              addLog("H", `Liked comment by ${name}`, profileUrl);
            } catch {} // non-critical — continue even if like fails
            await humanDelay(2000);
          }

          setStatus(`(${sent + 1}/${count}) Connecting: ${name}...`);

          let personalizedMsg = message;
          if (personalizedMsg) {
            personalizedMsg = personalizedMsg
              .replace(/@firstName/gi, person.firstName || name.split(" ")[0] || "")
              .replace(/@name/gi, name);
          }

          try {
            const result = await linkedinCmd("linkedin:http-send-connection", { profileUrn: person.entityUrn, publicIdentifier: person.publicIdentifier, message: personalizedMsg || "" });
            if (result.success) {
              sent++;
              addLog("V", `Sent: ${name} (commented on ${person.sourcePost || "post"})`, profileUrl);
              linkedinCmd("linkedin:connect-attempts-log", { profileId, keyword: "commenter", status: "sent" }).catch(() => {});
              crmSync({ platform: "linkedin", identifier: person.publicIdentifier || name, displayName: name, headline: person.headline, source: "commenters", action: "connect_sent" });
            } else if (result.status === 301) {
              linkedinCmd("linkedin:connect-attempts-log", { profileId, keyword: "commenter", status: "already_connected" }).catch(() => {});
            }
          } catch (err) {
            addLog("X", `${name}: ${err.message}`);
          }
          await maybeFatiguePause(sent);
          await humanDelay(3500 * sessionWarmup(sent));
        }

        setStatus(`Done! Sent ${sent}/${count} connections to commenters`);
        addLog("V", `Done! ${sent} commenter connections sent`);
      } catch (err) {
        setStatus("Error: " + err.message);
        addLog("X", err.message);
      }
    },
  },
  "smart-disconnect": {
    title: "Smart Disconnect",
    html: `
      <label class="nx-label">Keep connections matching keyword</label>
      <input class="nx-input" id="cfg-keyword" type="text" placeholder="crypto, web3, blockchain">
      <label class="nx-label">Max to scan per run</label>
      <input class="nx-input nx-small" id="cfg-count" type="number" value="50" min="10" max="200">
      <label class="nx-checkbox" style="margin:8px 0;"><input type="checkbox" id="cfg-preview" checked> Preview before disconnecting</label>
      <p class="nx-hint">Scans your connections and removes those whose profile doesn't match the keyword. Uses synonym matching (crypto = blockchain/web3/defi/etc).</p>`,
    // Only confirm a REAL disconnect — preview mode is harmless.
    impact: (shadow) => shadow.getElementById("cfg-preview").checked
      ? null
      : ({ verb: "Permanently disconnect up to", count: clampCount(shadow.getElementById("cfg-count").value, 50, 10, 200), budgetKey: null }),
    async start(shadow) {
      const rawKeyword = shadow.getElementById("cfg-keyword").value.trim();
      const maxScan = clampCount(shadow.getElementById("cfg-count").value, 50, 10, 200);
      const preview = shadow.getElementById("cfg-preview").checked;

      if (!rawKeyword) { setStatus("Enter a keyword"); return; }

      const SYNONYM_MAP = {
        "crypto": ["crypto", "cryptocurrency", "blockchain", "web3", "defi", "nft", "token", "smart contract", "solidity", "ethereum", "bitcoin", "digital asset", "dapp", "dao"],
        "ai": ["ai", "artificial intelligence", "machine learning", "ml", "deep learning", "llm", "gpt", "neural network", "data science", "nlp"],
        "fintech": ["fintech", "financial technology", "payments", "banking", "neobank", "lending", "insurtech"],
        "saas": ["saas", "software as a service", "b2b", "enterprise software", "cloud"],
        "marketing": ["marketing", "growth", "seo", "content", "branding", "social media", "performance marketing", "demand gen"],
      };

      // Build match terms: user keywords + their synonyms
      const userKeywords = rawKeyword.toLowerCase().split(",").map(s => s.trim()).filter(Boolean);
      const matchTerms = new Set();
      for (const kw of userKeywords) {
        matchTerms.add(kw);
        // Check synonym map
        for (const [root, synonyms] of Object.entries(SYNONYM_MAP)) {
          if (kw === root || synonyms.includes(kw)) {
            for (const syn of synonyms) matchTerms.add(syn);
            matchTerms.add(root);
          }
        }
      }

      // Materialised once — matchTerms is never mutated past this point, and the
      // per-connection loop below would otherwise rebuild this array on every row.
      const matchTermList = Array.from(matchTerms);

      addLog("S", `Scanning connections — keeping "${rawKeyword}" (${matchTerms.size} match terms)`);

      try {
        // Step 1: Fetch connections by searching through different letters
        setStatus("Loading connections...");
        let allConnections = [];
        const seenUrns = new Set();
        const letters = ["a", "e", "i", "o", "s", "m", "r", "t", "c", "d", "b", "j", "k", "l", "n", "p"];

        for (const letter of letters) {
          if (allConnections.length >= maxScan) break;
          let start = 0;
          for (let page = 0; page < 3; page++) { // up to 3 pages per letter
            if (allConnections.length >= maxScan) break;
            const result = await linkedinCmd("linkedin:http-list-connections", { count: 10, start, searchLetter: letter });
            const batch = (result.connections || []).filter(c => {
              const key = c.entityUrn || c.publicIdentifier;
              if (!key || seenUrns.has(key)) return false;
              seenUrns.add(key);
              return true;
            });
            if (batch.length === 0) break;
            allConnections.push(...batch);
            start += 10;
            setStatus(`Loaded ${allConnections.length} connections (letter: ${letter})...`);
          }
        }

        allConnections = allConnections.slice(0, maxScan);
        addLog("V", `Loaded ${allConnections.length} connections`);

        if (allConnections.length === 0) {
          setStatus("No connections found");
          return;
        }

        // Step 2: Check each connection's headline against keywords
        const nonMatching = [];
        const matching = [];
        for (let i = 0; i < allConnections.length; i++) {
          const conn = allConnections[i];
          const name = [conn.firstName, conn.lastName].filter(Boolean).join(" ") || "Unknown";
          const headline = (conn.headline || "").toLowerCase();

          setStatus(`(${i + 1}/${allConnections.length}) Checking: ${name}`);

          // Check headline against all match terms
          const matches = matchTermList.some(term => headline.includes(term));

          if (matches) {
            matching.push(conn);
          } else {
            nonMatching.push(conn);
          }
        }

        addLog("V", `${matching.length} match "${rawKeyword}", ${nonMatching.length} don't match`);

        if (nonMatching.length === 0) {
          setStatus("All connections match — nothing to disconnect");
          return;
        }

        if (preview) {
          // Show preview in activity log
          setStatus(`${nonMatching.length} non-matching connections found. Review in log, then run again with preview OFF to disconnect.`);
          for (const conn of nonMatching.slice(0, 20)) {
            const name = [conn.firstName, conn.lastName].filter(Boolean).join(" ");
            const profileUrl = conn.publicIdentifier ? `https://www.linkedin.com/in/${conn.publicIdentifier}/` : null;
            addLog("-", `${name}: "${(conn.headline || "no headline").slice(0, 60)}"`, profileUrl);
          }
          if (nonMatching.length > 20) addLog("-", `...and ${nonMatching.length - 20} more`);
          return;
        }

        // Step 3: Disconnect non-matching
        let disconnected = 0;
        for (const conn of nonMatching) {
          const name = [conn.firstName, conn.lastName].filter(Boolean).join(" ") || "Unknown";
          const slug = conn.publicIdentifier;
          const profileUrl = slug ? `https://www.linkedin.com/in/${slug}/` : null;

          if (!slug) { addLog("-", `${name}: no profile slug, skipping`); continue; }

          setStatus(`(${disconnected + 1}/${nonMatching.length}) Disconnecting: ${name}...`);
          try {
            const result = await linkedinCmd("linkedin:http-disconnect", { publicIdentifier: slug });
            if (result.success) {
              disconnected++;
              addLog("X", `Disconnected: ${name} — "${(conn.headline || "").slice(0, 40)}"`, profileUrl);
            } else {
              addLog("-", `${name}: disconnect failed`, profileUrl);
            }
          } catch (err) {
            addLog("-", `${name}: ${err.message}`);
          }
          await humanDelay(2000);
        }

        setStatus(`Done! Disconnected ${disconnected}/${nonMatching.length}`);
        addLog("V", `Done! ${disconnected} disconnected, ${matching.length} kept`);
      } catch (err) {
        setStatus("Error: " + err.message);
        addLog("X", err.message);
      }
    },
  },
  "watchlist": {
    title: "Company Watchlist",
    html: `
      <label class="nx-label">Add company to watchlist</label>
      <input class="nx-input" id="cfg-company" type="text" placeholder="https://linkedin.com/company/name or slug">
      <div class="nx-input-row" style="margin-top:8px;">
        <div><label class="nx-label">Scan every</label><input class="nx-input nx-small" id="cfg-interval" type="number" value="24" min="6" max="168"> hrs</div>
        <div><label class="nx-label">Max/scan</label><input class="nx-input nx-small" id="cfg-max" type="number" value="20" min="5" max="50"></div>
      </div>
      <div style="margin-top:8px;display:flex;gap:6px;">
        <button class="nx-start-btn" id="cfg-add-btn" style="flex:1;margin:0;padding:8px;font-size:11px;">Add to Watchlist</button>
        <button class="nx-start-btn" id="cfg-list-btn" style="flex:1;margin:0;padding:8px;font-size:11px;background:#2a2a38;">View Watchlist</button>
      </div>
      <p class="nx-hint">Companies in your watchlist are auto-scanned by the autopilot. New employee post engagers are discovered and engaged automatically.</p>`,
    init(shadow) {
      shadow.getElementById("cfg-add-btn").addEventListener("click", async () => {
        const input = shadow.getElementById("cfg-company").value.trim();
        if (!input) return;
        let slug = input;
        const m = input.match(/company\/([^/?]+)/);
        if (m) slug = m[1];
        slug = slug.replace(/\/$/, "").toLowerCase();
        const interval = parseInt(shadow.getElementById("cfg-interval").value) || 24;
        const max = parseInt(shadow.getElementById("cfg-max").value) || 20;

        setStatus(`Adding ${slug} to watchlist...`);
        try {
          // Look up company first
          const lookup = await linkedinCmd("linkedin:http-company-lookup", { companyUrl: slug });
          const name = lookup.company?.name || slug;

          await linkedinCmd("linkedin:watchlist-add", { companySlug: slug, companyName: name, companyUrn: lookup.company?.entityUrn, intervalHours: interval, maxPerDay: max });
          addLog("V", `Added to watchlist: ${name} (every ${interval}h)`);
          setStatus(`${name} added to watchlist`);
        } catch (err) {
          addLog("X", err.message);
          setStatus("Error: " + err.message);
        }
      });

      shadow.getElementById("cfg-list-btn").addEventListener("click", async () => {
        setStatus("Loading watchlist...");
        try {
          const result = await linkedinCmd("linkedin:watchlist-list", {});
          const list = result.watchlist || [];
          if (list.length === 0) { addLog("-", "Watchlist is empty"); setStatus("No companies watched"); return; }

          setStatus(`${list.length} companies in watchlist`);
          for (const w of list) {
            const s = w.stats || {};
            const statusIcon = w.status === "active" ? "V" : "-";
            const lastRun = w.last_run_at ? new Date(w.last_run_at).toLocaleDateString() : "never";
            addLog(statusIcon, `${w.company_name} (${w.status}) — ${s.employees_found || 0} emp, ${s.engagers_found || 0} eng, ${s.connections_sent || 0} conn — last: ${lastRun}`,
              `https://www.linkedin.com/company/${w.company_slug}/`);
          }
        } catch (err) { addLog("X", err.message); }
      });
    },
    async start(shadow) {
      // Manual scan of all active watchlist items
      setStatus("Running watchlist scan...");
      try {
        const result = await linkedinCmd("linkedin:watchlist-list", {});
        const active = (result.watchlist || []).filter(w => w.status === "active");
        if (active.length === 0) { setStatus("No active watchlist items"); return; }

        for (const w of active) {
          setStatus(`Scanning: ${w.company_name}...`);
          addLog("...", `Scanning ${w.company_name}`);
          try {
            const scanResult = await linkedinCmd("linkedin:watchlist-scan", { companySlug: w.company_slug });
            addLog("V", `${w.company_name}: ${scanResult.employees} emp, ${scanResult.postsScanned} posts, ${scanResult.engagers} engagers`);
          } catch (err) { addLog("X", `${w.company_name}: ${err.message}`); }
          await humanDelay(3000);
        }
        setStatus("Watchlist scan complete");
      } catch (err) { setStatus("Error: " + err.message); }
    },
  },
  "competitor-siphon": {
    title: "Competitor Siphon",
    html: `
      <label class="nx-label">Company URL or name</label>
      <input class="nx-input" id="cfg-company" type="text" placeholder="https://linkedin.com/company/competitor-name">
      <label class="nx-label">Engagement depth</label>
      <select class="nx-input" id="cfg-depth">
        <option value="light">Light (like posts only)</option>
        <option value="medium" selected>Medium (like + connect)</option>
        <option value="heavy">Heavy (like + comment + connect)</option>
      </select>
      <label class="nx-label">Max actions this run</label>
      <input class="nx-input nx-small" id="cfg-max" type="number" value="20" min="5" max="50">
      <p class="nx-hint">Discovers competitor employees, scans their posts, engages with people who interact with them (commenters + likers), and sends connection requests.</p>`,
    async start(shadow) {
      const companyInput = shadow.getElementById("cfg-company").value.trim();
      const depth = shadow.getElementById("cfg-depth").value;
      const maxActions = parseInt(shadow.getElementById("cfg-max").value) || 20;
      if (!companyInput) { setStatus("Enter a company URL or name"); return; }

      // Extract slug from URL or use as-is
      let slug = companyInput;
      const urlMatch = companyInput.match(/company\/([^/?]+)/);
      if (urlMatch) slug = urlMatch[1];
      slug = slug.replace(/\/$/, "").toLowerCase();

      let actions = 0;

      try {
        // Step 1: Look up company
        setStatus(`Looking up company: ${slug}...`);
        addLog("S", `Siphon: ${slug} (${depth})`);
        const companyResult = await linkedinCmd("linkedin:http-company-lookup", { companyUrl: slug });
        if (!companyResult.success) { setStatus("Company not found: " + (companyResult.error || "")); return; }
        const company = companyResult.company;
        addLog("V", `Found: ${company.name || slug} (${company.staffCount || "?"} employees)`);

        // Step 2: Find employees
        setStatus(`Finding employees at ${company.name || slug}...`);
        let employees = [];
        for (let page = 0; page < 3 && employees.length < 30; page++) {
          const empResult = await linkedinCmd("linkedin:http-company-employees", { companySlug: slug, companyUrn: company.entityUrn, count: 10, start: page * 10 });
          employees.push(...(empResult.employees || []));
          if ((empResult.employees || []).length < 10) break;
        }
        if (employees.length === 0) { setStatus("No employees found"); addLog("-", "No employees discoverable"); return; }
        addLog("V", `Found ${employees.length} employees`);

        // Step 3: Scan employee posts for engagers
        setStatus(`Scanning employee posts for engagers...`);
        const allEngagers = [];
        const seenUrns = new Set();
        let postsScanned = 0;

        // Track endpoint health across the loop so a single 400 doesn't get
        // logged 30 times. After the first 400 on a given endpoint, skip the
        // rest of that endpoint's calls and surface a clear panel error.
        let userActivityBroken = false, postEngagersBroken = false;
        let activityErrCount = 0, engagersErrCount = 0;

        for (const emp of employees.slice(0, 10)) { // top 10 employees
          if (actions >= maxActions) break;
          if (!emp.publicIdentifier) continue;
          if (userActivityBroken) break; // endpoint dead, no point continuing

          // Get their recent posts
          let posts = [];
          try {
            const actResult = await linkedinCmd("linkedin:http-user-activity", { publicIdentifier: emp.publicIdentifier });
            posts = actResult.activities || [];
          } catch (err) {
            activityErrCount++;
            const msg = String(err.message || "");
            if (msg.includes("400") || msg.includes("deprecated")) {
              userActivityBroken = true;
              addLog("!", `LinkedIn deprecated http-user-activity endpoint (400). Siphon can't read employee posts; will still try direct-connect for ${employees.length - employees.indexOf(emp)} remaining employees.`);
            }
          }
          if (posts.length === 0 || userActivityBroken) continue;

          // Get engagers on each post
          for (const post of posts.slice(0, 3)) { // top 3 posts per employee
            if (actions >= maxActions) break;
            if (postEngagersBroken) break;
            postsScanned++;
            try {
              const engResult = await linkedinCmd("linkedin:http-post-engagers", { postUrn: post.urn, count: 20 });
              for (const e of (engResult.engagers || [])) {
                if (e.entityUrn && !seenUrns.has(e.entityUrn)) {
                  seenUrns.add(e.entityUrn);
                  allEngagers.push({ ...e, sourceEmployee: emp.name, sourcePostUrn: post.urn });
                }
              }
            } catch (err) {
              engagersErrCount++;
              const msg = String(err.message || "");
              if (msg.includes("400") || msg.includes("deprecated")) {
                postEngagersBroken = true;
                addLog("!", `LinkedIn deprecated http-post-engagers endpoint (400). Siphon can't find engagers; will only have direct-connect to employees.`);
              }
            }
            await humanDelay(1500);
          }
          await humanDelay(2000);
        }
        // Final summary of endpoint health so the user knows why a siphon
        // returned few results (vs. a broken account vs. a niche company).
        if (userActivityBroken || postEngagersBroken) {
          addLog("-", `Endpoint failures: user-activity=${activityErrCount}, post-engagers=${engagersErrCount}. Falling back to employee-only connects.`);
        }

        addLog("V", `${postsScanned} posts scanned, ${allEngagers.length} unique engagers found`);

        if (allEngagers.length === 0) { setStatus("No engagers found"); return; }

        // Step 4: Filter already-attempted
        const profileIds = allEngagers.map(e => {
          const urn = e.entityUrn || "";
          return urn.includes(":") ? urn.split(":").pop() : urn;
        }).filter(Boolean);

        let alreadyAttempted = new Set();
        try {
          const check = await linkedinCmd("linkedin:connect-attempts-check", { profileIds });
          alreadyAttempted = new Set((check.attempted || []).map(String));
        } catch {}

        const fresh = allEngagers.filter(e => {
          const id = (e.entityUrn || "").includes(":") ? e.entityUrn.split(":").pop() : e.entityUrn;
          return !alreadyAttempted.has(id);
        });

        addLog("-", `${fresh.length} fresh engagers (${allEngagers.length - fresh.length} already attempted)`);

        // Step 5: Engage — like + connect
        let liked = 0, connected = 0;
        for (const engager of fresh) {
          if (actions >= maxActions) break;
          const name = [engager.firstName, engager.lastName].filter(Boolean).join(" ") || "Unknown";
          const profileUrl = engager.publicIdentifier ? `https://www.linkedin.com/in/${engager.publicIdentifier}/` : null;

          setStatus(`(${actions + 1}/${maxActions}) Engaging: ${name}...`);

          // Like the source post (if depth >= light)
          if (engager.sourcePostUrn) {
            try {
              await linkedinCmd("linkedin:http-like-post", { postUrn: engager.sourcePostUrn });
              liked++;
              actions++;
            } catch {}
            await humanDelay(1500);
          }

          // Send connection request (if depth >= medium)
          if (depth !== "light" && engager.entityUrn) {
            const fsdMatch = engager.entityUrn.match(/fsd_profile:([A-Za-z0-9_-]+)/);
            const connectId = fsdMatch ? fsdMatch[1] : engager.entityUrn;
            try {
              const result = await linkedinCmd("linkedin:http-send-connection", { profileUrn: connectId });
              if (result.success) {
                connected++;
                addLog("V", `Connected: ${name} (via ${engager.sourceEmployee})`, profileUrl);
                crmSync({ platform: "linkedin", identifier: engager.publicIdentifier || name, displayName: name, headline: engager.headline, source: `siphon:${slug}`, action: "connect_sent" });
              }
              const pid = connectId.includes(":") ? connectId.split(":").pop() : connectId;
              linkedinCmd("linkedin:connect-attempts-log", { profileId: pid, keyword: `siphon:${slug}`, status: result.success ? "sent" : "failed" }).catch(() => {});
            } catch {}
            actions++;
            await maybeFatiguePause(actions);
            await humanDelay(3000 * sessionWarmup(connected));
          }
        }

        setStatus(`Done! Liked ${liked}, Connected ${connected} from ${company.name || slug}`);
        addLog("V", `Siphon complete: ${liked} likes, ${connected} connections, ${postsScanned} posts scanned`);
      } catch (err) {
        setStatus("Error: " + err.message);
        addLog("X", err.message);
      }
    },
  },
  "accept-invites-http": {
    title: "Accept Invitations",
    html: `
      <label class="nx-label">Max to accept</label>
      <input class="nx-input nx-small" id="cfg-count" type="number" value="20" min="1" max="50">
      <p class="nx-hint">Accepts pending invitations by clicking the real Accept buttons on the page. If you're elsewhere on LinkedIn, the panel will navigate you to /received/ — re-click after it loads.</p>`,
    impact: (shadow) => ({ verb: "Accept invitations from", count: clampCount(shadow.getElementById("cfg-count").value, 20), budgetKey: "connect" }),
    async start(shadow) {
      const maxAccept = clampCount(shadow.getElementById("cfg-count").value, 20);
      if (!location.pathname.includes("/mynetwork/invitation-manager/received")) {
        setStatus("Opening invitation manager…");
        addLog("...", "Navigating to /mynetwork/invitation-manager/received/ — click again once it loads");
        window.location.href = "https://www.linkedin.com/mynetwork/invitation-manager/received/";
        return;
      }
      setStatus("Scanning invitations on this page…");
      addLog("...", "Looking for pending invitations in the DOM");

      try {
        const result = await handleAcceptAndCollect({ count: maxAccept, skipNoMsg: false });
        if (!result.found) { setStatus("No pending invitations"); addLog("-", "No Accept buttons found — inbox may be empty"); return; }

        addLog("V", `Found ${result.found} pending invitations`);
        let accepted = 0;
        for (const inv of result.results) {
          if (inv.accepted) {
            accepted++;
            addLog("V", `Accepted: ${inv.name}`);
          } else {
            addLog("-", `${inv.name}: ${inv.reason || "not accepted"}`);
          }
          await humanDelay(1500 * sessionWarmup(accepted));
        }
        setStatus(`Done! Accepted ${accepted} invitations`);
        addLog("V", `Accepted ${accepted}/${result.found}`);
      } catch (err) {
        setStatus("Error: " + err.message);
        addLog("X", err.message);
      }
    },
  },
  "accept-and-dm": {
    title: "Accept & Message",
    html: `
      <label class="nx-label">Max to process</label>
      <input class="nx-input nx-small" id="cfg-count" type="number" value="15" min="1" max="25">
      <label class="nx-label">Message template</label>
      <div class="nx-var-chips">
        <span class="nx-chip" data-var="{firstName}">{firstName}</span>
        <span class="nx-chip" data-var="{lastName}">{lastName}</span>
        <span class="nx-chip" data-var="{name}">{name}</span>
        <span class="nx-chip" data-var="{headline}">{headline}</span>
        <span class="nx-chip" data-var="{company}">{company}</span>
        <span class="nx-chip" data-var="{hook}">{hook}</span>
      </div>
      <textarea class="nx-input nx-textarea" id="cfg-message" rows="4" placeholder="Hi {firstName}, thanks for connecting! {hook} Wanted to ..."></textarea>
      <label class="nx-label">Delay between people (sec)</label>
      <input class="nx-input nx-small" id="cfg-delay" type="number" value="6" min="3" max="60">
      <label class="nx-checkbox" style="display:block;margin-top:8px;"><input type="checkbox" id="cfg-skip-nomsg"> Skip senders who left no note</label>
      <label class="nx-checkbox" style="display:block;margin-top:8px;"><input type="checkbox" id="cfg-like-first" checked> Like their latest post before sending the DM</label>
      <p class="nx-hint">Clicks Accept on each visible invitation on /received/, optionally likes their latest post, then sends a personalized DM. LinkedIn throttles DMs — keep runs under ~20/day. Placeholders {headline}/{company} pull from each recipient's profile; {hook} adds an AI-written 1-line opener (slower, +5-10s per recipient). Daily/weekly caps enforced server-side.</p>`,
    impact: (shadow) => ({ verb: "Accept & message", count: clampCount(shadow.getElementById("cfg-count").value, 15), budgetKey: "dm" }),
    async start(shadow) {
      const maxProcess = clampCount(shadow.getElementById("cfg-count").value, 15);
      const template = shadow.getElementById("cfg-message").value.trim();
      const delayMs = (parseInt(shadow.getElementById("cfg-delay").value, 10) || 6) * 1000;
      const skipNoMsg = shadow.getElementById("cfg-skip-nomsg").checked;
      const likeFirst = shadow.getElementById("cfg-like-first")?.checked !== false;

      if (!template) { setStatus("Enter a message template"); addLog("X", "Message template is required"); return; }

      // r44: kick the batch off in the hidden worker tab instead of
      // running it on the user's current tab. Two reasons:
      //   1. The user's tab is never navigated / re-rendered / yanked
      //      around (the actual complaint that triggered this refactor).
      //   2. LinkedIn's mid-batch redirects (auto-opening a thread page
      //      after accepting someone who already DM'd you) land on the
      //      worker tab — invisible to the user. The history.back()
      //      recovery in runAcceptAndDmBatch fires silently.
      // The panel here still shows live progress because addLog /
      // setStatus calls inside the worker tab relay back through
      // background.js → this tab's nexusPanel listener — see the
      // _NX_BACKGROUND_BATCH flag and the nx-batch-progress handler.
      // r47: pre-batch AI variation pool. Ask the bot to rewrite the
      // template into ~12 distinct siblings BEFORE the worker tab
      // starts. Each recipient picks one from the pool so no two
      // people get the same wording. Voice-conditioned on the
      // account's existing voice samples. If AI fails for any reason
      // (no API key, timeout, parse error), the bot returns the
      // original template as a one-element array and the batch
      // proceeds with no-variation behavior — same as pre-r47.
      setStatus("Asking AI for message variations…");
      addLog("...", "Generating message variations (12 siblings) for batch");
      const variationRes = await new Promise((resolve) => {
        chrome.runtime.sendMessage({
          action: "li-generate-variations",
          template,
          count: 12,
        }, (response) => {
          const err = chrome.runtime.lastError;
          if (err) resolve({ error: err.message });
          else resolve(response || { error: "no response from background" });
        });
      });
      let variations = null;
      if (Array.isArray(variationRes?.variations) && variationRes.variations.length > 0) {
        variations = variationRes.variations;
        if (variationRes.fallback) {
          addLog("!", `Variation fallback: ${variationRes.fallback} — proceeding with ${variations.length} variant(s)`);
        } else {
          const voiceBit = variationRes.voiceUsed ? " (voice-conditioned)" : "";
          addLog("V", `Got ${variations.length} message variations${voiceBit} — recipients won't see duplicates`);
        }
      } else {
        addLog("!", `Variation generator returned no usable output (${variationRes?.error || "no variations"}) — falling back to single template`);
      }

      setStatus("Starting batch in background tab — your current tab stays put.");
      addLog("...", "Spinning up the hidden worker tab…");

      const result = await new Promise((resolve) => {
        chrome.runtime.sendMessage({
          action: "li-start-accept-and-dm-batch-bg",
          config: { maxProcess, template, delayMs, skipNoMsg, likeFirst, variations },
        }, (response) => {
          const err = chrome.runtime.lastError;
          if (err) resolve({ error: err.message });
          else resolve(response || { error: "no response from background" });
        });
      });

      if (result?.error) {
        setStatus(`Batch failed: ${result.error}`);
        addLog("X", `Batch failed: ${result.error}`);
      }
      // On success the worker tab already relayed its final "Done — …"
      // setStatus + complete log line through to this panel, so no
      // additional setStatus is needed here.
    },
  },
  "network-map": {
    title: "Network Map",
    html: `
      <label class="nx-label">Company slug</label>
      <input class="nx-input" id="cfg-slug" type="text" placeholder="competitor-name">
      <p class="nx-hint">Shows a visual map of a company's network — employees, engagers, and your connections.</p>`,
    async start(shadow) {
      const slug = shadow.getElementById("cfg-slug").value.trim();
      if (!slug) { setStatus("Enter a company slug"); return; }
      setStatus("Loading network map...");
      try {
        const result = await linkedinCmd("linkedin:map-company", { companySlug: slug });
        if (!result.success) { setStatus(result.error || "No data"); return; }

        // Render the map in a new window
        const mapData = JSON.stringify(result);
        const mapWindow = window.open("", "_blank", "width=1200,height=800");
        mapWindow.document.write(`<!DOCTYPE html><html><head><title>Network Map: ${result.company.name}</title>
          <style>body{margin:0;background:#0a0a12;font-family:sans-serif;color:#fff}canvas{display:block}
          #info{position:fixed;top:10px;right:10px;background:#1a1a28;padding:12px;border-radius:8px;font-size:12px;max-width:250px}
          #legend{position:fixed;bottom:10px;left:10px;font-size:11px;color:#888}
          .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px}</style></head>
          <body><canvas id="c"></canvas>
          <div id="info"><b>${result.company.name}</b><br>${result.nodes.length} people mapped<br>${result.edges.length} connections</div>
          <div id="legend">
            <span class="dot" style="background:#4ADE80"></span>Employee
            <span class="dot" style="background:#1d9bf0"></span>Engager
            <span class="dot" style="background:#4ade80"></span>Connected
          </div>
          <script>
          const data=${mapData};
          const canvas=document.getElementById("c"),ctx=canvas.getContext("2d");
          canvas.width=window.innerWidth;canvas.height=window.innerHeight;
          const cx=canvas.width/2,cy=canvas.height/2;
          const nodes=data.nodes.map((n,i)=>{
            const angle=(i/data.nodes.length)*Math.PI*2;
            const r=n.circle===1?150:n.circle===2?300:420;
            return{...n,x:cx+Math.cos(angle)*r*(0.8+Math.random()*0.4),y:cy+Math.sin(angle)*r*(0.8+Math.random()*0.4),
              r:n.circle===1?8:5,color:n.circle===1?"#4ADE80":n.degree===1?"#4ade80":"#1d9bf0"};
          });
          const nodeMap=new Map(nodes.map(n=>[n.id,n]));
          function draw(){
            ctx.clearRect(0,0,canvas.width,canvas.height);
            // Draw edges
            ctx.strokeStyle="rgba(255,255,255,0.05)";ctx.lineWidth=0.5;
            for(const e of data.edges){const s=nodeMap.get(e.source),t=nodeMap.get(e.target);
              if(s&&t){ctx.beginPath();ctx.moveTo(s.x,s.y);ctx.lineTo(t.x,t.y);ctx.stroke();}}
            // Draw company center
            ctx.beginPath();ctx.arc(cx,cy,20,0,Math.PI*2);ctx.fillStyle="#ff6b6b";ctx.fill();
            ctx.fillStyle="#fff";ctx.font="bold 10px sans-serif";ctx.textAlign="center";ctx.fillText(data.company.name.slice(0,10),cx,cy+4);
            // Draw nodes
            for(const n of nodes){ctx.beginPath();ctx.arc(n.x,n.y,n.r,0,Math.PI*2);ctx.fillStyle=n.color;ctx.fill();
              ctx.fillStyle="#fff";ctx.font="9px sans-serif";ctx.textAlign="center";ctx.fillText((n.name||"").slice(0,15),n.x,n.y+n.r+10);}
          }
          draw();
          canvas.addEventListener("click",e=>{
            const rect=canvas.getBoundingClientRect();const mx=e.clientX-rect.left,my=e.clientY-rect.top;
            for(const n of nodes){if(Math.hypot(n.x-mx,n.y-my)<n.r+5){
              document.getElementById("info").innerHTML="<b>"+n.name+"</b><br>"+
                (n.headline||"")+"<br>Circle: "+n.circle+" | Score: "+(n.score||0).toFixed(1)+
                (n.profileUrl?"<br><a href='"+n.profileUrl+"' target='_blank' style='color:#1d9bf0'>View Profile</a>":"");
              break;}
            }
          });
          </script></body></html>`);
        mapWindow.document.close();
        setStatus(`Map loaded: ${result.nodes.length} people`);
        addLog("V", `Network map: ${result.nodes.length} nodes, ${result.edges.length} edges`);
      } catch (err) {
        setStatus("Error: " + err.message);
        addLog("X", err.message);
      }
    },
  },
  "crm-dashboard": {
    title: "Open CRM Dashboard",
    html: `<p class="nx-hint">View the full CRM dashboard in a new tab — funnel, top leads, account health, weekly trends, and cross-platform stats live here.</p>
      <p class="nx-hint" style="opacity:0.7;">Press <b>Start</b> below to open it.</p>`,
    async start(shadow) {
      try {
        const url = chrome.runtime.getURL("dashboard.html#crm");
        window.open(url, "_blank", "noopener");
        setStatus("Opening dashboard…");
        addLog("V", "Opened CRM dashboard in new tab");
      } catch (err) {
        setStatus("Error: " + err.message);
        addLog("X", err.message);
      }
    },
  },
  "campaigns": {
    title: "Open Campaigns Dashboard",
    html: `<p class="nx-hint">View running campaigns, queue, and detailed health in the dashboard. Click <b>Start</b> below to open it in a new tab.</p>
      <p class="nx-hint" style="opacity:0.7;">To create a new campaign: open the dashboard and click <b>+ New</b> in the top bar.</p>`,
    async start() {
      try {
        const url = chrome.runtime.getURL("dashboard.html#campaigns");
        window.open(url, "_blank", "noopener");
        setStatus("Opening campaigns…");
        addLog("V", "Opened Campaigns dashboard in new tab");
      } catch (err) {
        setStatus("Error: " + err.message);
        addLog("X", err.message);
      }
    },
  },
  "autopilot": {
    title: "Autopilot",
    html: `
      <label class="nx-checkbox" style="margin:8px 0;"><input type="checkbox" id="cfg-enabled"> Enable LinkedIn Autopilot</label>
      <label class="nx-label">Active hours</label>
      <div class="nx-input-row">
        <div><label class="nx-label">From</label><input class="nx-input nx-small" id="cfg-start" type="number" value="9" min="0" max="23"></div>
        <div><label class="nx-label">To</label><input class="nx-input nx-small" id="cfg-end" type="number" value="18" min="0" max="23"></div>
      </div>
      <label class="nx-checkbox" style="margin:6px 0;"><input type="checkbox" id="cfg-accept" checked> Auto-accept invitations</label>
      <label class="nx-checkbox" style="margin:6px 0;"><input type="checkbox" id="cfg-engage" checked> Daily feed engagement</label>
      <div class="nx-input-row">
        <div><label class="nx-label">Likes/day</label><input class="nx-input nx-small" id="cfg-likes" type="number" value="10" min="1" max="30"></div>
      </div>
      <p class="nx-hint">Runs in the background every hour during active hours. Accepts invites, likes posts, and maintains account warmth.</p>`,
    async init(shadow) {
      try {
        const data = await chrome.storage.local.get(["liAutopilot"]);
        const config = data.liAutopilot || {};
        shadow.getElementById("cfg-enabled").checked = !!config.enabled;
        shadow.getElementById("cfg-start").value = config.startHour ?? 9;
        shadow.getElementById("cfg-end").value = config.endHour ?? 18;
        shadow.getElementById("cfg-accept").checked = config.acceptInvites !== false;
        shadow.getElementById("cfg-engage").checked = config.dailyEngage !== false;
        shadow.getElementById("cfg-likes").value = config.likesPerDay || 10;
      } catch {}
    },
    async start(shadow) {
      const config = {
        enabled: shadow.getElementById("cfg-enabled").checked,
        startHour: parseInt(shadow.getElementById("cfg-start").value) || 9,
        endHour: parseInt(shadow.getElementById("cfg-end").value) || 18,
        acceptInvites: shadow.getElementById("cfg-accept").checked,
        dailyEngage: shadow.getElementById("cfg-engage").checked,
        likesPerDay: parseInt(shadow.getElementById("cfg-likes").value) || 10,
      };
      await chrome.storage.local.set({ liAutopilot: config });
      if (config.enabled) {
        setStatus("Autopilot ON — runs every hour");
        addLog("V", `Autopilot enabled: ${config.startHour}:00-${config.endHour}:00, ${config.likesPerDay} likes/day`);
      } else {
        setStatus("Autopilot OFF");
        addLog("-", "Autopilot disabled");
      }
    },
  },
  "daily-schedule": {
    title: "Daily Schedule",
    html: `
      <p class="nx-hint">Automatically run a connect batch once per day when your browser is open with LinkedIn.</p>
      <label class="nx-checkbox" style="margin:8px 0;"><input type="checkbox" id="cfg-enabled"> Enable daily schedule</label>
      <label class="nx-label">Keyword</label>
      <input class="nx-input" id="cfg-keyword" type="text" placeholder="CEO web3" value="CEO web3">
      <div class="nx-input-row" style="margin-top:8px;">
        <div><label class="nx-label">Count</label><input class="nx-input nx-small" id="cfg-count" type="number" value="10" min="1" max="50"></div>
        <div><label class="nx-label">Time (hour)</label><input class="nx-input nx-small" id="cfg-hour" type="number" value="9" min="0" max="23"></div>
        <div><label class="nx-label">Minute</label><input class="nx-input nx-small" id="cfg-min" type="number" value="0" min="0" max="59"></div>
      </div>
      <label class="nx-label">Degree</label>
      <div class="nx-input-row">
        <label class="nx-checkbox"><input type="checkbox" id="cfg-deg2" checked> 2nd</label>
        <label class="nx-checkbox"><input type="checkbox" id="cfg-deg3"> 3rd+</label>
      </div>
      <label class="nx-label">Personal message (optional)</label>
      <textarea class="nx-input nx-textarea" id="cfg-message" rows="2" placeholder="Hi! I'd love to connect..."></textarea>
      <div id="cfg-status" style="margin-top:8px;font-size:11px;color:#888;"></div>`,
    async init(shadow) {
      // Load current schedule
      const data = await chrome.storage.local.get(["liSchedule"]);
      const s = data.liSchedule || {};
      if (s.enabled) shadow.getElementById("cfg-enabled").checked = true;
      if (s.keyword) shadow.getElementById("cfg-keyword").value = s.keyword;
      if (s.count) shadow.getElementById("cfg-count").value = s.count;
      if (s.hour !== undefined) shadow.getElementById("cfg-hour").value = s.hour;
      if (s.minute !== undefined) shadow.getElementById("cfg-min").value = s.minute;
      if (s.message) shadow.getElementById("cfg-message").value = s.message;
      if (s.lastRun) shadow.getElementById("cfg-status").textContent = `Last run: ${s.lastRun}`;
    },
    async start(shadow) {
      const schedule = {
        enabled: shadow.getElementById("cfg-enabled").checked,
        keyword: shadow.getElementById("cfg-keyword").value.trim() || "CEO web3",
        count: clampCount(shadow.getElementById("cfg-count").value, 10),
        hour: parseInt(shadow.getElementById("cfg-hour").value) || 9,
        minute: parseInt(shadow.getElementById("cfg-min").value) || 0,
        degree: (shadow.getElementById("cfg-deg2").checked && shadow.getElementById("cfg-deg3").checked) ? "SO"
              : shadow.getElementById("cfg-deg2").checked ? "S"
              : shadow.getElementById("cfg-deg3").checked ? "O" : "SO",
        message: shadow.getElementById("cfg-message").value.trim() || null,
        lastRun: (await chrome.storage.local.get(["liSchedule"])).liSchedule?.lastRun || null,
      };
      await chrome.storage.local.set({ liSchedule: schedule });
      if (schedule.enabled) {
        setStatus(`Schedule saved: daily at ${String(schedule.hour).padStart(2,"0")}:${String(schedule.minute).padStart(2,"0")} — ${schedule.count} connects`);
        addLog("V", `Daily schedule: ${schedule.hour}:${String(schedule.minute).padStart(2,"0")}, ${schedule.count} connects`);
      } else {
        setStatus("Daily schedule disabled");
        addLog("-", "Daily schedule disabled");
      }
    },
  },
  "autonomous-mode": {
    title: "Autonomous Mode (no focus required)",
    html: `
      <p class="nx-hint">Currently when you send a DM, the extension must briefly activate the messaging tab so the typing has document focus (Chrome security requirement). If you switch windows mid-send, typing pauses.</p>
      <p class="nx-hint"><b>Autonomous Mode uses Chrome's debugger API</b> to inject typing at the browser-process level — no focus needed. DMs send while you do anything else.</p>
      <p class="nx-hint"><b>Trade-off:</b> Chrome shows a yellow banner ("Nexus Connect started debugging this browser") while autonomous mode is sending. You can dismiss it but it re-appears each session. The banner is Chrome's security warning — there's no way to hide it.</p>
      <div id="cfg-status" style="margin-top:8px;font-size:11px;color:#888;">Status: checking…</div>
      <p class="nx-hint" style="margin-top:8px;">Click <b>Start</b> to toggle autonomous mode. The first time you enable, Chrome will ask for debugger permission — accept it.</p>
      <button class="nx-btn" id="cfg-reset-prompt" style="margin-top:10px;">Reset auto-prompt</button>
      <p class="nx-hint" style="margin-top:2px;">If you dismissed Chrome's permission dialog by accident on your first Accept & Message run, click this to make it offer again on your next run.</p>`,
    async init(shadow) {
      // Show current state
      const enabled = await new Promise((resolve) => {
        chrome.runtime.sendMessage({ action: "li-autonomous-status" }, (r) => {
          if (chrome.runtime.lastError) resolve(false);
          else resolve(!!r?.enabled);
        });
      });
      const status = shadow.getElementById("cfg-status");
      if (status) status.textContent = `Status: ${enabled ? "✅ Enabled (DMs work without focus)" : "❌ Disabled (must keep messaging tab focused during DM)"}`;

      const resetBtn = shadow.getElementById("cfg-reset-prompt");
      if (resetBtn) {
        resetBtn.addEventListener("click", async () => {
          resetBtn.textContent = "Resetting…";
          resetBtn.disabled = true;
          const result = await new Promise((resolve) => {
            chrome.runtime.sendMessage({ action: "li-autonomous-reset-prompt" }, (r) => {
              if (chrome.runtime.lastError) resolve({ ok: false, error: chrome.runtime.lastError.message });
              else resolve(r || { ok: false });
            });
          });
          if (result.ok) {
            resetBtn.textContent = "✓ Reset — next Accept & Message will re-prompt";
            addLog("V", "Autonomous Mode auto-prompt reset — Chrome will ask again on your next Accept & Message run");
          } else {
            resetBtn.textContent = "Reset failed";
            resetBtn.disabled = false;
            addLog("X", `Reset failed: ${result.error || "unknown"}`);
          }
        });
      }
    },
    async start(shadow) {
      // Decide enable vs disable from the status text we already populated
      // in init(). No round-trip needed — preserves user gesture for the
      // permissions.request call in the SW.
      const statusEl = shadow.getElementById("cfg-status");
      const currentlyOn = statusEl && /Enabled/.test(statusEl.textContent || "");
      const action = currentlyOn ? "li-autonomous-disable" : "li-autonomous-enable";
      setStatus("Toggling autonomous mode…");
      try {
        const result = await new Promise((resolve, reject) => {
          chrome.runtime.sendMessage({ action }, (r) => {
            const err = chrome.runtime.lastError;
            if (err) reject(new Error(err.message));
            else resolve(r);
          });
        });
        if (result?.enabled) {
          setStatus("Autonomous mode ENABLED ✓");
          addLog("V", "Autonomous mode enabled — DMs now send without focus. Yellow Chrome banner will appear during sends.");
        } else if (result?.permissionDenied) {
          setStatus("Permission denied. Open chrome://extensions → Nexus Connect → Details, enable Debugger under Optional permissions, then click again.");
          addLog("X", "Debugger permission denied. Grant it manually at chrome://extensions → Nexus Connect → Details → Optional permissions → Debugger.");
        } else if (result?.error) {
          setStatus("Toggle error: " + result.error);
          addLog("X", `Toggle error: ${result.error}`);
        } else if (action === "li-autonomous-disable") {
          setStatus("Autonomous mode DISABLED");
          addLog("-", "Autonomous mode disabled — DMs use focus-required path");
        } else {
          setStatus("Unexpected response: " + JSON.stringify(result));
          addLog("X", `Toggle returned: ${JSON.stringify(result)}`);
        }
      } catch (err) {
        setStatus("Toggle failed: " + err.message);
        addLog("X", err.message);
      }
      refreshAutonomousChip();
    },
  },
  "diagnostic-capture": {
    title: "Diagnostic: Capture this page",
    html: `
      <p class="nx-hint">Uploads a comprehensive DOM snapshot of the page you're currently on (whatever <code>linkedin.com</code> URL is loaded in this tab) to the bot. Used for remote debugging — when an automation fails because LinkedIn changed something, capturing the current page gives the developer the exact selectors + HTML they need.</p>
      <label class="nx-label">Optional note (what's broken?)</label>
      <textarea class="nx-input nx-textarea" id="cfg-note" rows="2" placeholder="e.g., 'Send button stays disabled for me on this thread'"></textarea>
      <p class="nx-hint">Best pages to capture:
        <br>• <code>/messaging/thread/new/?recipient=…</code> — compose-box debugging
        <br>• <code>/mynetwork/invitation-manager/received/</code> — invitation cards
        <br>• <code>/feed/</code> — feed post engagement</p>`,
    async start(shadow) {
      const note = shadow.getElementById("cfg-note").value.trim() || null;
      setStatus("Capturing page DOM…");
      addLog("S", `Capturing snapshot of ${window.location.pathname}`);
      try {
        const snapshot = _buildLinkedInDomSnapshot({ triggerEvent: "manual_capture" });
        const ok = await _uploadDomSnapshot(snapshot, "manual_capture", note);
        if (ok) {
          setStatus("Snapshot uploaded ✓");
          addLog("V", `Snapshot uploaded. Run /lidump in Telegram to view, or node scripts/show-li-dump.js`);
        } else {
          setStatus("Upload failed — saved locally");
          addLog("!", `Upload failed (bot offline?). Backup saved to chrome.storage.local._nxDmDebug`);
        }
      } catch (err) {
        setStatus("Capture failed: " + err.message);
        addLog("X", err.message);
      }
    },
  },
};

const MENU_ITEMS = [
  // ── Intelligence ──
  { id: "watchlist", icon: "W", label: "Company Watchlist", hint: "Monitor target companies and their employees" },
  { id: "competitor-siphon", icon: "S", label: "Competitor Siphon", hint: "Engage followers of a target account" },
  // ── Engage ──
  { id: "add-connections", icon: "+", label: "Add Connections", hint: "Search and bulk connect by keyword" },
  { id: "connect-commenters", icon: "C", label: "Connect with Commenters", hint: "Connect with people who comment on posts" },
  { id: "like-posts", icon: "H", label: "Engage Feed", hint: "Like and comment on feed posts" },
  { id: "view-profiles", icon: "V", label: "View Profiles", hint: "Visit profiles to warm up connections" },
  // ── Manage ──
  { id: "accept-invites-http", icon: "I", label: "Accept Invites", hint: "Auto-accept pending connection requests" },
  { id: "accept-and-dm", icon: "D", label: "Accept & Message", hint: "Accept pending requests and send each a personalized DM" },
  { id: "smart-disconnect", icon: "X", label: "Clean Up Connections", hint: "Remove inactive or irrelevant connections" },
  { id: "autopilot", icon: "A", label: "Autopilot", hint: "Continuous automated engagement during active hours" },
  // ── Diagnostics ──
  { id: "autonomous-mode", icon: "*", label: "Autonomous Mode", hint: "DMs send without keeping the tab focused (uses debugger API)" },
  { id: "diagnostic-capture", icon: "?", label: "Diagnostic: Capture this page", hint: "Upload DOM snapshot for remote debugging" },
];

function injectPanel() {
  if (document.getElementById(PANEL_ID)) return;

  const host = document.createElement("div");
  host.id = PANEL_ID;
  host.style.cssText = "position:fixed;top:80px;right:16px;z-index:2147483647;";
  const shadow = host.attachShadow({ mode: "open" });

  shadow.innerHTML = buildPanelHTML();
  document.body.appendChild(host);
  setupPanelBehavior(host, shadow);
  startKeepalive();
}

function buildPanelHTML() {
  const menuHTML = MENU_ITEMS.map(m =>
    `<div class="nx-menu-item" data-action="${m.id}"><span class="nx-menu-icon">${m.icon}</span><div style="flex:1;overflow:hidden;"><span class="nx-menu-label">${m.label}</span>${m.hint ? `<span class="nx-menu-hint">${m.hint}</span>` : ""}</div><span class="nx-menu-arrow">&rsaquo;</span></div>`
  ).join("");

  return `
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    :host { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; }
    .nx-panel {
      width: 300px;
      background: #0f0f11;
      border: 1px solid #2a2a38;
      border-radius: 12px;
      box-shadow: 0 8px 32px rgba(0,0,0,0.5);
      overflow: hidden;
      color: #e0e0e8;
    }
    .nx-panel.minimized .nx-body { display: none; }
    .nx-panel.minimized { width: auto; border-radius: 10px; }
    .nx-header {
      display: flex;
      align-items: center;
      padding: 10px 14px;
      background: #13131a;
      border-bottom: 1px solid #222;
      cursor: move;
      -webkit-user-select: none;
      user-select: none;
    }
    .nx-logo { font-size: 16px; margin-right: 8px; }
    .nx-title { font-size: 13px; font-weight: 600; color: #fff; flex: 1; }
    .nx-header-btn {
      background: none; border: none; color: #666; cursor: pointer;
      font-size: 16px; padding: 2px 4px; line-height: 1;
    }
    .nx-header-btn:hover { color: #ccc; }
    .nx-body { padding: 12px; }
    .nx-status {
      font-size: 11px; color: #4ADE80; padding: 6px 0; min-height: 20px;
    }
    .nx-section-title {
      font-size: 10px; color: #555; text-transform: uppercase;
      letter-spacing: 1px; margin: 10px 0 6px; padding-top: 8px;
      border-top: 1px solid #1a1a22;
    }
    .nx-section-title:first-child { border-top: none; margin-top: 0; padding-top: 0; }
    .nx-btn {
      display: block; width: 100%; padding: 8px 10px; margin-bottom: 6px;
      background: #1a1a22; border: 1px solid #2a2a38; border-radius: 6px;
      color: #e0e0e8; font-size: 12px; font-weight: 500; cursor: pointer;
      text-align: left; transition: background 0.15s;
    }
    .nx-btn:hover { background: #222230; border-color: #3a3a48; }
    .nx-btn:active { background: #2a2a38; }
    .nx-btn .nx-btn-icon { margin-right: 6px; }
    .nx-btn.primary { background: #4ADE80; border-color: #4ADE80; color: #fff; }
    .nx-btn.primary:hover { background: #6b5aee; }
    .nx-input-row { display: flex; gap: 6px; margin-bottom: 6px; }
    .nx-input {
      flex: 1; background: #1a1a22; border: 1px solid #2a2a38; border-radius: 6px;
      color: #e0e0e8; padding: 7px 10px; font-size: 12px; outline: none;
    }
    .nx-input:focus { border-color: #4ADE80; }
    .nx-input::placeholder { color: #555; }
    .nx-log { max-height: 240px; overflow-y: auto; }
    .nx-log-item {
      display: flex; align-items: center; padding: 4px 0;
      font-size: 11px; border-bottom: 1px solid #1a1a22;
    }
    .nx-log-item.clickable { cursor: pointer; }
    .nx-log-item.clickable:hover { background: #1a1a22; }
    .nx-log-icon { margin-right: 6px; font-size: 12px; flex-shrink: 0; }
    .nx-log-text { flex: 1; color: #ccc; word-break: break-word; white-space: normal; line-height: 1.4; }
    .nx-log-link { color: #4ADE80; cursor: pointer; text-decoration: none; }
    .nx-log-link:hover { text-decoration: underline; }
    .nx-log-time { color: #555; font-size: 10px; margin-left: 6px; flex-shrink: 0; }
    .nx-log-toggle { text-align: center; padding: 6px 0; font-size: 10px; color: #4ADE80; cursor: pointer; border-top: 1px solid #1a1a22; }
    .nx-log-toggle:hover { color: #9b8fff; }
    .nx-empty { color: #444; font-size: 11px; text-align: center; padding: 12px 0; }
    .nx-small { width: 70px; flex: 0 0 70px; text-align: center; }
    .nx-textarea { resize: vertical; min-height: 36px; font-family: inherit; line-height: 1.4; padding: 6px 10px; }
    select.nx-input { appearance: auto; cursor: pointer; }
    .nx-checkbox { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #aaa; cursor: pointer; flex: 1; white-space: nowrap; }
    .nx-checkbox input { margin: 0; cursor: pointer; }
    .nx-badge {
      display: inline-block; font-size: 10px; padding: 1px 6px;
      border-radius: 8px; margin-left: 6px; font-weight: 500;
    }
    .nx-badge.on { background: #1a3a1a; color: #4ade80; }
    .nx-badge.off { background: #3a1a1a; color: #f87171; }
    .nx-badge.clickable { cursor: pointer; }
    .nx-badge.clickable:hover { filter: brightness(1.2); }
    .nx-badge.warn { background: #3a2e1a; color: #fbbf24; }
    .nx-menu-item {
      display: flex; align-items: center; padding: 9px 10px; cursor: pointer;
      border-bottom: 1px solid #1a1a22; transition: background 0.12s;
    }
    .nx-menu-item:hover { background: #1a1a22; }
    .nx-menu-item:active { background: #222230; }
    .nx-menu-icon {
      width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
      background: #1a1a22; border-radius: 4px; font-size: 11px; font-weight: 700;
      color: #4ADE80; margin-right: 10px; flex-shrink: 0;
    }
    .nx-menu-label { font-size: 12px; color: #e0e0e8; display: block; }
    .nx-menu-hint { font-size: 10px; color: #555; display: block; line-height: 1.3; }
    .nx-menu-arrow { color: #444; font-size: 16px; }
    .nx-back-row {
      display: flex; align-items: center; padding: 8px 0; margin-bottom: 8px;
      border-bottom: 1px solid #1a1a22;
    }
    .nx-back-btn {
      background: none; border: none; color: #4ADE80; cursor: pointer;
      font-size: 13px; padding: 2px 8px 2px 0; font-weight: 500;
    }
    .nx-back-btn:hover { color: #9b8fff; }
    .nx-config-title { font-size: 13px; font-weight: 600; color: #fff; flex: 1; }
    .nx-label { display: block; font-size: 11px; color: #888; margin: 8px 0 4px; }
    .nx-label:first-child { margin-top: 0; }
    .nx-hint { font-size: 11px; color: #555; margin-top: 6px; line-height: 1.4; }
    .nx-var-chips { display: flex; gap: 4px; margin-bottom: 4px; }
    .nx-chip {
      font-size: 10px; padding: 2px 6px; background: #222230; border: 1px solid #2a2a38;
      border-radius: 4px; color: #4ADE80; cursor: pointer;
    }
    .nx-chip:hover { background: #2a2a3a; }
    .nx-start-btn {
      display: block; width: 100%; padding: 10px; margin-top: 12px;
      background: #4ADE80; border: none; border-radius: 6px;
      color: #fff; font-size: 13px; font-weight: 600; cursor: pointer;
    }
    .nx-start-btn:hover { background: #6b5aee; }
    .nx-start-btn:disabled { opacity: 0.4; cursor: not-allowed; }
    .nx-start-btn.nx-stop-mode { background: #d33f4e; }
    .nx-start-btn.nx-stop-mode:hover { background: #c0303f; }
    .nx-auto-toggle {
      display: flex; align-items: center; padding: 9px 10px;
      border-bottom: 1px solid #1a1a22;
    }
    .nx-auto-toggle .nx-menu-label { flex: 1; }
  </style>
  <div class="nx-panel" id="nx-root">
    <div class="nx-header" id="nx-drag">
      <span class="nx-logo">N</span>
      <span class="nx-title">Nexus</span>
      <span class="nx-badge warn clickable" id="nx-auto-chip" title="Autonomous Mode — click to toggle">Auto: ?</span>
      <span class="nx-badge on" id="nx-conn-badge">connected</span>
      <button class="nx-header-btn" id="nx-minimize" title="Minimize">_</button>
      <button class="nx-header-btn" id="nx-close" title="Hide">x</button>
    </div>
    <div class="nx-body">
      <div id="nx-status" class="nx-status">Ready</div>

      <!-- Screen 1: Main Menu -->
      <div id="nx-menu">
        ${menuHTML}
        <div class="nx-auto-toggle">
          <span class="nx-menu-icon">A</span>
          <span class="nx-menu-label">Auto-Respond <span style="font-size:9px;color:#555;">(soon)</span></span>
          <input type="checkbox" id="nx-auto-respond">
        </div>
      </div>

      <!-- Screen 2: Config Page (populated dynamically) -->
      <div id="nx-config" style="display:none">
        <div class="nx-back-row">
          <button class="nx-back-btn" id="nx-back">&lsaquo; Back</button>
          <span class="nx-config-title" id="nx-config-title"></span>
        </div>
        <div id="nx-config-form"></div>
        <button class="nx-start-btn" id="nx-start">Start</button>
      </div>

      <div class="nx-section-title">Activity Log</div>
      <div class="nx-log" id="nx-log">
        <div class="nx-empty">No activity yet</div>
      </div>
    </div>
  </div>`;
}

function setupPanelBehavior(host, shadow) {
  const root = shadow.getElementById("nx-root");
  const dragHandle = shadow.getElementById("nx-drag");
  let currentAction = null;

  // Minimize (persisted across navigation)
  shadow.getElementById("nx-minimize").addEventListener("click", () => {
    panelMinimized = !panelMinimized;
    root.classList.toggle("minimized", panelMinimized);
    savePanelState();
  });

  // Close (hide until next page load)
  shadow.getElementById("nx-close").addEventListener("click", () => {
    host.style.display = "none";
  });

  // Autonomous Mode chip — clicking it toggles. To preserve Chrome's user
  // gesture (required for chrome.permissions.request in MV3), we must NOT
  // round-trip through the service worker to read state first — that consumes
  // the gesture window. Instead, we read the current state from the chip's
  // own text (set by refreshAutonomousChip on panel boot + after toggles)
  // and send a direct enable / disable message in a single round-trip.
  const autoChip = shadow.getElementById("nx-auto-chip");
  if (autoChip) {
    autoChip.addEventListener("click", async (e) => {
      e.stopPropagation();
      // Decide enable vs disable from the chip's current text. ON = "Auto: ON".
      const currentlyOn = (autoChip.textContent || "").includes("ON");
      const action = currentlyOn ? "li-autonomous-disable" : "li-autonomous-enable";
      autoChip.textContent = "Auto: …";
      autoChip.className = "nx-badge warn clickable";
      try {
        // No awaits between the click and the sendMessage — keeps the user
        // gesture alive for Chrome's permissions.request inside the SW.
        const result = await new Promise((resolve, reject) => {
          chrome.runtime.sendMessage({ action }, (r) => {
            const err = chrome.runtime.lastError;
            if (err) reject(new Error(err.message));
            else resolve(r);
          });
        });
        if (result?.enabled) {
          addLog("V", "Autonomous Mode ENABLED — DMs work without focus");
        } else if (action === "li-autonomous-enable" && (result?.permissionDenied || result?.error)) {
          // The service-worker path failed (most likely Chrome silently
          // rejected because the gesture didn't propagate). Fall back to
          // opening the dedicated grant page in a new tab — that page has
          // its OWN guaranteed user-gesture context and always works.
          addLog("-", "Opening grant page (the in-place prompt didn't fire — Chrome's gesture forwarding can be flaky)");
          try {
            const grantUrl = chrome.runtime.getURL("autonomous-grant.html");
            chrome.runtime.sendMessage({ action: "open-tab", url: grantUrl }).catch(() => {
              window.open(grantUrl, "_blank");
            });
          } catch {
            addLog("X", "Could not open grant page. Manual fallback: chrome://extensions → Nexus Connect → Details → Optional permissions → Debugger.");
          }
        } else if (action === "li-autonomous-disable") {
          addLog("-", "Autonomous Mode disabled — DMs require focus");
        } else {
          addLog("X", `Autonomous Mode toggle returned unexpected response: ${JSON.stringify(result)}`);
        }
      } catch (err) {
        addLog("X", `Toggle failed: ${err.message}`);
      }
      await refreshAutonomousChip();
    });
  }

  // Drag
  let isDragging = false, dragX = 0, dragY = 0;
  dragHandle.addEventListener("mousedown", (e) => {
    if (e.target.closest(".nx-header-btn")) return;
    isDragging = true;
    dragX = e.clientX - host.getBoundingClientRect().left;
    dragY = e.clientY - host.getBoundingClientRect().top;
    e.preventDefault();
  });
  document.addEventListener("mousemove", (e) => {
    if (!isDragging) return;
    host.style.left = (e.clientX - dragX) + "px";
    host.style.top = (e.clientY - dragY) + "px";
    host.style.right = "auto";
  });
  document.addEventListener("mouseup", () => { isDragging = false; });

  // ── Menu navigation ──
  function showMenu() {
    shadow.getElementById("nx-menu").style.display = "block";
    shadow.getElementById("nx-config").style.display = "none";
    currentAction = null;
  }

  function showConfig(actionId) {
    const config = ACTION_CONFIGS[actionId];
    if (!config) return;
    currentAction = actionId;
    shadow.getElementById("nx-menu").style.display = "none";
    shadow.getElementById("nx-config").style.display = "block";
    shadow.getElementById("nx-config-title").textContent = config.title;
    shadow.getElementById("nx-config-form").innerHTML = config.html;
    // Init handler for dynamic form behavior (e.g., checkbox toggles)
    if (config.init) config.init(shadow);
    // Wire up template variable chips
    shadow.querySelectorAll(".nx-chip").forEach(chip => {
      chip.addEventListener("click", () => {
        const varText = chip.dataset.var;
        const textarea = shadow.querySelector("textarea");
        if (textarea) {
          const pos = textarea.selectionStart || textarea.value.length;
          textarea.value = textarea.value.slice(0, pos) + varText + textarea.value.slice(pos);
          textarea.focus();
        }
      });
    });
  }

  // Menu item clicks → open config page
  shadow.querySelectorAll(".nx-menu-item").forEach(item => {
    item.addEventListener("click", () => showConfig(item.dataset.action));
  });

  // Back button
  shadow.getElementById("nx-back").addEventListener("click", showMenu);

  // Start / Stop button — the same button toggles role while an action runs.
  // Why one button instead of two: keeps the panel compact; and users always
  // know "this button controls the current action" regardless of state.
  let _actionRunning = false;
  shadow.getElementById("nx-start").addEventListener("click", async () => {
    const btn = shadow.getElementById("nx-start");

    // Second click while an action is running = Stop request.
    if (_actionRunning) {
      signalCancel();
      // r44: when the action is running as a background batch on the
      // worker tab (accept-and-DM), signalCancel() above only flips
      // THIS tab's flag — the worker tab has its own copy. Forward
      // the cancel through background so it reaches the worker tab.
      try {
        chrome.runtime.sendMessage({ action: "li-cancel-bg-batch" }).catch(() => {});
      } catch { /* ignore */ }
      btn.disabled = true;
      btn.textContent = "Stopping…";
      setStatus("Stopping — finishing the current step…");
      addLog("!", "Stop requested — will halt after current step");
      return;
    }

    if (!currentAction) return;
    const config = ACTION_CONFIGS[currentAction];
    if (!config) return;

    // Pre-flight confirm for destructive / bulk actions that expose `impact`.
    if (typeof config.impact === "function") {
      let imp = null;
      try { imp = config.impact(shadow); } catch {}
      if (imp && imp.count > 0) {
        const ok = await confirmImpact(imp);
        if (!ok) { setStatus("Cancelled — nothing was sent."); return; }
      }
    }

    _actionRunning = true;
    resetCancel();
    btn.textContent = "Stop";
    btn.classList.add("nx-stop-mode");

    try {
      await config.start(shadow);
    } catch (err) {
      setStatus("Error: " + err.message);
      addLog("X", err.message);
    }

    _actionRunning = false;
    btn.disabled = false;
    btn.textContent = "Start";
    btn.classList.remove("nx-stop-mode");
    if (isCancelled()) {
      setStatus("Stopped by user");
      addLog("-", "Action stopped");
      resetCancel();
    }
  });

  // Auto-respond toggle (stays on main menu)
  shadow.getElementById("nx-auto-respond").addEventListener("change", (e) => {
    const enabled = e.target.checked;
    chrome.runtime.sendMessage({ action: "toggle-auto-respond", enabled });
    if (enabled) {
      setStatus("Auto-respond ON — checking every 30s");
      addLog("V", "Auto-respond ON");
    } else {
      setStatus("Auto-respond OFF");
      addLog("-", "Auto-respond OFF");
    }
  });

  // Restore state from previous page
  loadPanelState().then(() => {
    updateLogUI();
    const statusEl = shadow.getElementById("nx-status");
    if (statusEl && panelStatus) statusEl.textContent = panelStatus;
    if (panelMinimized) root.classList.add("minimized");
  });

  checkBotConnection();
  refreshAutonomousChip();
}

/**
 * Refresh the Autonomous Mode header chip. Queries the service worker for
 * the current state (uses chrome.permissions.contains under the hood, so
 * it's accurate even if user revoked permission via chrome://extensions).
 * Called on panel boot, after the user toggles the panel action, and after
 * the Accept & Message auto-prompt completes — so the user can SEE whether
 * Autonomous Mode is actually on without digging into a config screen.
 */
async function refreshAutonomousChip() {
  const host = document.getElementById(PANEL_ID);
  if (!host) return false;
  const chip = host.shadowRoot.getElementById("nx-auto-chip");
  if (!chip) return false;
  try {
    const enabled = await new Promise((resolve) => {
      let resolved = false;
      chrome.runtime.sendMessage({ action: "li-autonomous-status" }, (r) => {
        if (resolved) return;
        resolved = true;
        if (chrome.runtime.lastError) resolve(false);
        else resolve(!!r?.enabled);
      });
      setTimeout(() => { if (!resolved) { resolved = true; resolve(false); } }, 1500);
    });
    chip.textContent = enabled ? "Auto: ON" : "Auto: OFF";
    chip.className = "nx-badge clickable " + (enabled ? "on" : "off");
    chip.title = enabled
      ? "Autonomous Mode ON — DMs send without focus. Click to disable."
      : "Autonomous Mode OFF — DMs require keeping LinkedIn tab focused. Click to enable.";
    return enabled;
  } catch {
    chip.textContent = "Auto: ?";
    chip.className = "nx-badge warn clickable";
    return false;
  }
}

async function checkBotConnection() {
  const host = document.getElementById(PANEL_ID);
  if (!host) return;
  const badge = host.shadowRoot.getElementById("nx-conn-badge");
  try {
    const { token } = await chrome.storage.local.get(["token"]);
    // Query the service worker for its build version. If it doesn't respond
    // (extension reloading) or returns a stale build, surface that here so
    // the user can see immediately whether their reload took.
    let buildTag = "";
    let buildNotes = "";
    try {
      const buildRes = await new Promise((resolve) => {
        chrome.runtime.sendMessage({ type: "li-build-version" }, (r) => {
          if (chrome.runtime.lastError) resolve(null);
          else resolve(r);
        });
        setTimeout(() => resolve(null), 1500);
      });
      if (buildRes?.build) {
        buildTag = ` · ${buildRes.build}`;
        buildNotes = buildRes.notes || "";
      }
    } catch { /* ignore */ }
    badge.textContent = (token ? "connected" : "no token") + buildTag;
    badge.title = buildNotes;
    badge.className = token ? "nx-badge on" : "nx-badge off";
  } catch {
    badge.textContent = "error";
    badge.className = "nx-badge off";
  }
}

// Hook into command execution to update the panel
const _origHandleExtractSearch = handleExtractSearch;
handleExtractSearch = async function(params) {
  setStatus("Extracting search results...");
  const result = await _origHandleExtractSearch(params);
  if (result.results?.length) {
    setStatus(`Found ${result.results.length} results`);
    addLog("V", `Found ${result.results.length} people`);
  } else {
    setStatus("No results found");
    addLog("-", "No results on this page");
  }
  return result;
};

const _origHandleClickConnect = handleClickConnect;
handleClickConnect = async function(params) {
  const name = document.querySelector("h1")?.textContent?.trim() || "profile";
  const profileUrl = window.location.href.split("?")[0];
  setStatus(`Connecting to ${name}...`);
  addLog("...", `Connecting: ${name}`, profileUrl);
  const result = await _origHandleClickConnect(params);
  if (result.success) {
    setStatus(`Connected to ${name}`);
    addLog("V", `Sent: ${name}`, profileUrl);
    crmSync({ platform: "linkedin", identifier: profileUrl, displayName: name, action: "connect_sent" });
  } else {
    setStatus(`Failed: ${result.reason || "unknown"}`);
    addLog("X", `${name}: ${result.reason || "failed"}`, profileUrl);
  }
  return result;
};

// ── CRM Context: detect profile pages and show contact info ───────────

function detectProfileAndShowCRM() {
  const url = window.location.href;
  const match = url.match(/linkedin\.com\/in\/([^/?#]+)/);
  if (!match) {
    // Not a profile page — remove CRM section if it exists
    const host = document.getElementById(PANEL_ID);
    if (host) { const el = host.shadowRoot?.getElementById("nx-crm-context"); if (el) el.remove(); }
    return;
  }

  const slug = match[1];
  // Use enriched intelligence endpoint (falls back to basic crm-lookup in background.js)
  chrome.runtime.sendMessage({ action: "crm-intelligence", platform: "linkedin", identifier: slug }, (data) => {
    if (chrome.runtime.lastError) {
      const name = document.querySelector("h1")?.textContent?.trim() || slug;
      renderCrmContext({ found: false, contact: { name, stage: "unknown" }, interactions: [], outreach: null, relationship: null, deal: null });
      return;
    }
    renderCrmContext(data || { found: false, contact: { name: slug, stage: "unknown" }, interactions: [], outreach: null, relationship: null, deal: null });
  });
}

function renderCrmContext(data) {
  const host = document.getElementById(PANEL_ID);
  if (!host) return;
  const shadow = host.shadowRoot;
  const existing = shadow.getElementById("nx-crm-context");
  if (existing) existing.remove();

  const { contact, interactions = [], outreach, relationship, deal } = data;
  if (!contact) return;

  const stageColors = { cold: "#666", contacted: "#fbbf24", replied: "#60a5fa", engaged: "#4ADE80", warm: "#f97316", hot: "#ef4444", customer: "#4ade80", unknown: "#555" };
  const stageColor = stageColors[contact.stage] || "#555";

  let html = `<div class="nx-section-title" style="border-top:1px solid #1a1a22;padding-top:8px;margin-top:8px;">CRM</div>`;
  html += `<div style="padding:0 0 8px;font-size:11px;">`;
  html += `<div style="margin-bottom:4px;"><b style="color:#fff;">${contact.name}</b>`;
  if (contact.company) html += ` <span style="color:#888;">@ ${contact.company}</span>`;
  html += `</div>`;
  // Lead score gauge
  if (contact.leadScore > 0) {
    const scoreColor = contact.leadScore >= 60 ? "#ef4444" : contact.leadScore >= 30 ? "#fbbf24" : "#60a5fa";
    html += `<div style="margin-bottom:4px;">Score: <span style="color:${scoreColor};font-weight:700;">${contact.leadScore}</span>`;
    if (data.engagementVelocity === "up") html += ` <span style="color:#4ade80;">↑</span>`;
    else if (data.engagementVelocity === "down") html += ` <span style="color:#ef4444;">↓</span>`;
    html += `</div>`;
  }
  html += `<div style="margin-bottom:4px;">Stage: <span style="color:${stageColor};font-weight:600;">${contact.stage}</span>`;
  if (contact.role) html += ` | ${contact.role}`;
  html += `</div>`;
  // Reachable platforms
  if (contact.reachableOn?.length > 0) {
    const platformIcons = { twitter: "X", linkedin: "LI", telegram: "TG", email: "Em" };
    const badges = contact.reachableOn.map(p => `<span style="background:#1a1a22;padding:1px 5px;border-radius:4px;font-size:9px;color:#888;">${platformIcons[p] || p}</span>`).join(" ");
    html += `<div style="margin-bottom:4px;">${badges}</div>`;
  }

  if (relationship) {
    html += `<div style="margin-bottom:4px;color:#888;">Interactions: ${relationship.interactions} | Strength: ${Math.round(relationship.strength * 100)}%</div>`;
  }

  if (outreach) {
    // Campaign status with color-coded per-channel progress
    const channelStatus = (s) => {
      if (!s || s === "not_started") return '<span style="color:#555;">-</span>';
      if (s === "converted" || s === "replied") return `<span style="color:#4ade80;">${s}</span>`;
      if (s === "sent" || s === "connected" || s === "following") return `<span style="color:#60a5fa;">${s}</span>`;
      return `<span style="color:#fbbf24;">${s}</span>`;
    };
    html += `<div style="margin-bottom:4px;font-size:10px;">`;
    html += `Campaign: ${channelStatus(outreach.status)}<br>`;
    html += `LI: ${channelStatus(outreach.linkedin)} | X: ${channelStatus(outreach.twitter)} | TG: ${channelStatus(outreach.telegram)} | Em: ${channelStatus(outreach.email)}`;
    html += `</div>`;

    // Next action suggestion
    const nextActions = [];
    if (!outreach.linkedin || outreach.linkedin === "not_started") nextActions.push({ label: "Send Connect", action: "connect" });
    else if (outreach.linkedin === "connected") nextActions.push({ label: "Send Message", action: "message" });
    if (!outreach.twitter || outreach.twitter === "not_started") nextActions.push({ label: "Follow on X", action: "follow-x" });
    if (contact.telegram) nextActions.push({ label: "TG DM", action: "tg-dm" });
    if (nextActions.length > 0) {
      html += `<div style="margin-top:4px;display:flex;gap:4px;">`;
      for (const na of nextActions) {
        html += `<button class="nx-crm-action" data-action="${na.action}" style="font-size:10px;padding:3px 8px;background:#4ADE80;color:#fff;border:none;border-radius:4px;cursor:pointer;">${na.label}</button>`;
      }
      html += `</div>`;
    }
  }

  // ── Intelligence-enriched fields (from /api/crm/contact-intelligence) ──

  // Deal info
  if (deal || data.deal) {
    const d = deal || data.deal;
    const dealStageColors = { prospect: "#fbbf24", qualified: "#60a5fa", proposal: "#4ADE80", negotiation: "#f97316", won: "#4ade80" };
    const dColor = dealStageColors[d.stage] || "#888";
    html += `<div style="margin-bottom:4px;padding:3px 6px;background:#1a1a22;border-radius:4px;">`;
    html += `<span style="color:#888;font-size:10px;">Deal:</span> <b style="color:${dColor};">${d.stage}</b>`;
    if (d.value_usd) html += ` <span style="color:#4ade80;">$${Number(d.value_usd).toLocaleString()}</span>`;
    if (d.auto_created) html += ` <span style="color:#4ADE80;font-size:9px;">AI</span>`;
    html += `</div>`;
  }

  // Warm intro path
  if (data.warmIntro) {
    const wi = data.warmIntro;
    const strength = Math.round((wi.strength || 0) * 100);
    html += `<div style="margin-bottom:4px;padding:3px 6px;background:#1a2a1a;border-radius:4px;font-size:10px;">`;
    html += `<span style="color:#4ade80;">Warm intro:</span> via <b style="color:#fff;">${wi.introducerName || "?"}</b> (${strength}%)`;
    html += `</div>`;
  }

  // Reply intent
  if (data.replyIntent) {
    const ri = data.replyIntent;
    const intentEmojis = { hot_interest: "\u{1f525}", warm_interest: "\u{1f31f}", question: "\u2753", soft_no: "\u{1f44b}", hard_no: "\u{1f6d1}" };
    const intentLabels = { hot_interest: "Hot Interest", warm_interest: "Warm Interest", question: "Question", soft_no: "Soft No", hard_no: "Hard No" };
    html += `<div style="margin-bottom:4px;font-size:10px;">`;
    html += `${intentEmojis[ri.intent] || ""} <b style="color:#fff;">${intentLabels[ri.intent] || ri.intent}</b> (${Math.round(ri.confidence * 100)}%)`;
    html += `</div>`;
  }

  // Follow-up status
  if (data.followUp) {
    const fu = data.followUp;
    const step = (fu.current_step || 0) + 1;
    let fuLabel = `Step ${step}`;
    if (fu.next_follow_up_at) {
      const hoursUntil = Math.max(0, Math.round((new Date(fu.next_follow_up_at) - Date.now()) / 3600000));
      fuLabel += hoursUntil < 24 ? ` \u2022 next ${hoursUntil}h` : ` \u2022 next ${Math.round(hoursUntil / 24)}d`;
    }
    html += `<div style="margin-bottom:4px;font-size:10px;color:#60a5fa;">\u{1f504} Follow-up: ${fuLabel}</div>`;
  }

  if (relationship) {
    html += `<div style="color:#888;font-size:10px;">${relationship.interactions} interactions | ${Math.round(relationship.strength * 100)}% strength</div>`;
  }

  if (interactions && interactions.length > 0) {
    html += `<div style="margin-top:4px;color:#666;font-size:10px;">Recent:</div>`;
    for (const i of interactions.slice(0, 3)) {
      html += `<div style="color:#888;font-size:10px;">${i.platform}/${i.type} -- ${new Date(i.date).toLocaleDateString()}</div>`;
    }
  }

  if (contact.notes) {
    html += `<div style="margin-top:4px;color:#666;font-size:10px;">Notes: ${contact.notes.slice(0, 80)}</div>`;
  }

  html += `</div>`;

  const div = document.createElement("div");
  div.id = "nx-crm-context";
  div.innerHTML = html;

  // Insert before the activity log
  const logTitle = shadow.querySelector(".nx-section-title:last-of-type");
  if (logTitle) logTitle.parentNode.insertBefore(div, logTitle);

  // Wire up campaign action buttons
  div.querySelectorAll(".nx-crm-action").forEach(btn => {
    btn.addEventListener("click", async () => {
      const action = btn.dataset.action;
      btn.disabled = true;
      btn.textContent = "...";
      if (action === "connect") {
        const result = await handleClickConnect({ note: null });
        addLog(result.success ? "V" : "X", `Connect: ${result.success ? "sent" : result.reason || "failed"}`);
        chrome.runtime.sendMessage({ action: "crm-event", platform: "linkedin", type: "connect_sent", identifier: contact.name });
      } else if (action === "message") {
        const result = await handleSendMessage({ message: `Hi ${contact.name.split(" ")[0]}! I'd love to connect about potential synergies.` });
        addLog(result.success ? "V" : "X", `Message: ${result.success ? "sent" : result.reason || "failed"}`);
      } else if (action === "follow-x" && contact.twitter) {
        chrome.runtime.sendMessage({ action: "twitter-command", type: "twitter:http-follow-by-username", params: { targetUsername: contact.twitter } });
        addLog("V", `Followed @${contact.twitter} on X`);
      } else if (action === "tg-dm" && contact.telegram) {
        const msg = prompt(`Send Telegram DM to @${contact.telegram}:`, `Hi ${contact.name?.split(" ")[0] || "there"}!`);
        if (msg) {
          chrome.runtime.sendMessage({ action: "telegram-dm", params: { recipientUsername: contact.telegram, message: msg } }, (res) => {
            addLog(res?.error ? "X" : "V", res?.error ? `TG DM failed: ${res.error}` : `TG DM sent to @${contact.telegram}`);
          });
        }
      }
      btn.textContent = "Done";
      // Refresh CRM context after action
      setTimeout(detectProfileAndShowCRM, 2000);
    });
  });
}

// Run CRM detection on page load and URL changes
detectProfileAndShowCRM();
let _lastUrl = window.location.href;
setInterval(() => {
  if (window.location.href !== _lastUrl) {
    _lastUrl = window.location.href;
    detectProfileAndShowCRM();
  }
}, 2000);

// Inject the panel once DOM is ready
if (document.body) {
  injectPanel();
} else {
  document.addEventListener("DOMContentLoaded", injectPanel);
}

console.log("[Nexus Connect] LinkedIn content script active on", window.location.href);

// ── Daily auto-capture per page-type ─────────────────────────────────
//
// Whenever the user lands on one of these LinkedIn surfaces, we capture
// a comprehensive DOM snapshot if we haven't seen this page-type in the
// last 24h. Lets the developer pull fresh data via /lidump or the CLI
// script without the user having to manually trigger anything — the
// snapshots accumulate naturally as the user uses LinkedIn.
//
// Throttled per-page-type per-day to avoid spam:
//   chrome.storage.local._nxAutoCapture = { "<pageType>": "<ISO>" }

const _AUTO_CAPTURE_PAGE_TYPES = {
  "messaging-thread-new": p => p.startsWith("/messaging/thread/new"),
  "messaging-thread":     p => /^\/messaging\/thread\/[^/]+/.test(p) && !p.startsWith("/messaging/thread/new"),
  "messaging-inbox":      p => p === "/messaging/" || p === "/messaging",
  "received-invitations": p => p.startsWith("/mynetwork/invitation-manager/received"),
  "feed":                 p => p === "/feed/" || p === "/feed",
};

async function _maybeAutoCapture() {
  try {
    const path = window.location.pathname;
    let pageType = null;
    for (const [type, match] of Object.entries(_AUTO_CAPTURE_PAGE_TYPES)) {
      if (match(path)) { pageType = type; break; }
    }
    if (!pageType) return; // not an interesting page

    const { _nxAutoCapture: state = {} } = await chrome.storage.local.get("_nxAutoCapture");
    const lastIso = state[pageType];
    if (lastIso) {
      const ageMs = Date.now() - new Date(lastIso).getTime();
      if (ageMs < 24 * 60 * 60 * 1000) return; // captured within last 24h
    }

    // Wait for the page to actually render (LinkedIn is heavy React).
    // 4 seconds is conservative — feeds and compose pages typically settle
    // by then. We want to capture the REAL DOM, not the skeleton loading state.
    await delay(4000);

    const snapshot = _buildLinkedInDomSnapshot({ triggerEvent: `auto:${pageType}` });
    const ok = await _uploadDomSnapshot(snapshot, `auto:${pageType}`, `Auto-captured on natural visit to ${path}`);
    if (ok) {
      state[pageType] = new Date().toISOString();
      await chrome.storage.local.set({ _nxAutoCapture: state });
      console.log(`[Nexus] Auto-captured ${pageType} snapshot. Next capture for this page-type allowed in 24h.`);
    }
  } catch (err) {
    // Auto-capture is best-effort — never block the user's normal browsing.
    console.warn("[Nexus] Auto-capture failed:", err?.message || err);
  }
}

// Fire after panel injection — we want both to coexist, with auto-capture
// running in the background while the user does their thing.
if (document.body) {
  setTimeout(_maybeAutoCapture, 500);
} else {
  document.addEventListener("DOMContentLoaded", () => setTimeout(_maybeAutoCapture, 500));
}
