// adom-browser-extension :: MV3 service worker
// ---------------------------------------------
// Holds the native-messaging port to the AD bridge (via the native host), routes nbrowser_* commands
// to native.js (chrome.tabs/windows — no banner) or cdp.js (chrome.debugger — screenshot/eval), and
// answers the host's keepalive ping (which keeps this service worker resident).

import * as native from './native.js';
import * as cdp from './cdp.js';
import * as record from './record.js';

const HOST = 'inc.adom.native_browser';
const VERSION = chrome.runtime.getManifest().version;

let port = null;
let connecting = false;
let gotMessage = false;
let _safetySent = false;

// Bounded reconnect: exponential backoff + a give-up state. NEVER a fixed-rate spin — a fast reconnect
// loop here re-spawns native-host processes, which is what exhausted the machine's sockets on
// 2026-06-19. After SW_GIVEUP misses we stop the fast loop; the 0.5-min keepalive alarm then re-arms
// exactly ONE attempt per tick (a slow heartbeat), so we still recover without hammering connectNative.
const SW_BASE = 1500, SW_MAX = 60000, SW_GIVEUP = 40;
let swBackoff = SW_BASE;
let swAttempts = 0;
let givenUp = false;
let reconnectTimer = null;

function setStatus(connected) {
  chrome.storage.session.set({ connected, since: Date.now() }).catch(() => {});
}

function scheduleConnect() {
  if (givenUp || port || connecting || reconnectTimer) return; // never stack timers / parallel attempts
  if (swAttempts >= SW_GIVEUP) { givenUp = true; setStatus(false); return; }
  const delay = Math.min(swBackoff, SW_MAX) + Math.floor(Math.random() * 400);
  swBackoff = Math.min(swBackoff * 2, SW_MAX);
  reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay);
}

// Idempotent: exactly ONE native port / one in-flight attempt at a time. Multiple callers (onDisconnect
// + keepalive alarm + onStartup) must NOT open parallel ports — that spawns multiple host processes and
// the bridge flaps between them ("newest host wins"). The guards below make concurrent calls no-ops.
function connect() {
  if (port || connecting || givenUp) return;
  connecting = true;
  swAttempts++;
  let p;
  try {
    p = chrome.runtime.connectNative(HOST);
  } catch (e) {
    connecting = false;
    setStatus(false);
    scheduleConnect();
    return;
  }
  port = p;
  connecting = false;
  gotMessage = false;
  setStatus(true);
  port.onMessage.addListener(onMessage);
  port.onDisconnect.addListener(() => {
    if (port === p) port = null;
    helloSent = false;
    setStatus(false);
    scheduleConnect(); // backoff, NOT a fixed 1.5s retry
  });
  sendHello(); // identify THIS profile so the bridge can route per-profile (multi-profile support)
}

// Tell the host+bridge which browser PROFILE this is, so one bridge can hold a connection per profile
// ([email protected], [email protected], …) and the AI can target/switch between them. Email comes from
// chrome.identity; if the profile isn't signed into Google, fall back to a stable random id.
let helloSent = false;
let _emailResolved = false, _emailTries = 0;
async function stableId() {
  try {
    const got = await chrome.storage.local.get('_nbProfileId');
    let pid = got && got._nbProfileId;
    if (!pid) { pid = 'p_' + Math.random().toString(36).slice(2, 10); await chrome.storage.local.set({ _nbProfileId: pid }); }
    return pid;
  } catch { return 'p_unknown'; }
}
async function resolveEmail() {
  try {
    const info = await Promise.race([
      new Promise((res) => { try { chrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }, res); } catch { res(null); } }),
      new Promise((res) => setTimeout(() => res(null), 1500)),
    ]);
    return (info && info.email) || '';
  } catch { return ''; }
}
function postHello(profile) { try { if (port) { port.postMessage({ type: 'hello', profile, version: VERSION, browser: detectBrowser() }); helloSent = true; } } catch {} }
async function sendHello() {
  // Stable id FIRST — always available, so the hello can ALWAYS be sent even if identity misbehaves.
  const profile = { email: await resolveEmail(), id: await stableId(), name: '' };
  if (profile.email) _emailResolved = true;
  postHello(profile);
  // If the email didn't resolve (identity slow, or briefly signed-out), retry and RE-announce once it
  // lands - so the bridge re-keys "pending/id" -> the real "chrome:[email protected]" WITHOUT the caller
  // doing anything. This is what removes the "pending-N / empty email until driven" gap.
  if (!_emailResolved) scheduleEmailRetry();
}
function scheduleEmailRetry() {
  if (_emailResolved || _emailTries >= 6) return;
  _emailTries++;
  setTimeout(async () => {
    if (_emailResolved) return;
    const email = await resolveEmail();
    if (email) { _emailResolved = true; postHello({ email, id: await stableId(), name: '' }); }
    else scheduleEmailRetry();
  }, Math.min(1500 * _emailTries, 10000));
}

function reply(id, ok, payload, error, hint) {
  if (!port) return;
  port.postMessage(ok ? { id, ok: true, payload } : { id, ok: false, error, _hint: hint });
}

async function onMessage(msg) {
  if (!msg) return;
  if (!gotMessage) { gotMessage = true; swAttempts = 0; swBackoff = SW_BASE; if (!helloSent) sendHello(); } // proven-good connection → ensure the profile handshake went out
  if (msg.ping) { try { port && port.postMessage({ pong: true }); } catch {} return; }
  const { id, verb, args } = msg;
  if (id == null || !verb) return;
  try {
    let payload = await dispatch(verb, args || {});
    // First-contact safety banner: teach the cardinal rule once per SW lifetime, in-band.
    if (!_safetySent && payload && typeof payload === 'object' && !Array.isArray(payload)) {
      _safetySent = true;
      payload._safety = '\u26d4 Cardinal rules: (1) REGISTER every window: nbrowser_open_window needs sessionId + thread + purpose ' +
        '(who is driving + why) or it is refused (session_unregistered). (2) Only drive windows YOU open; bare mutating verbs ' +
        'and targets in user-opened windows are refused (no_target / not_owned). (3) A page you drive can SPAWN windows ' +
        '(a Gmail-Contacts email click opens a compose window) \u2014 those are auto-adopted; your next verb is refused ' +
        '(unacknowledged_spawns) until you nbrowser_owned then nbrowser_cleanup. At task end, close everything you own. ' +
        '(4) Work in the BACKGROUND. NEVER raise/maximize over the user\u2019s work or fall back to OS-level launches \u2014 it is auto-reverted + ' +
        'logged as a focus_violation. Foregrounding is the user\u2019s choice alone (userRequestedForeground:true). ' +
        'Start with nbrowser_profiles to pick the right profile.';
    }
    reply(id, true, payload);
  } catch (e) {
    // GuardError (ownership/no-target refusals) carries a teaching hint — deliver it verbatim so
    // the calling AI is corrected at the moment of need, not pointed at a skill it may never read.
    reply(id, false, undefined, String((e && e.message) || e), (e && e.hintText) || 'verb threw in extension');
  }
}

async function dispatch(verb, args) {
  switch (verb) {
    // --- meta -------------------------------------------------------------
    case 'nbrowser_status': {
      const own = native.ownershipSummary(); // dirty_session nag: surfaced while owned/spawned windows remain
      return { ok: true, ext: 'adom-browser-extension', version: VERSION, engine: 'native', browser: detectBrowser(), ownedWindows: own.ownedWindows, spawnedWindows: own.spawnedWindows, sessions: own.sessions, _hint: own._nag };
    }
    case 'nbrowser_whoami': {
      // Force a fresh Google-email resolve + RE-announce, so the bridge re-keys this connection to
      // "chrome:[email protected]" eagerly (fixes "pending-N / empty email until driven"). Returns the email.
      _emailResolved = false; _emailTries = 0;
      const email = await resolveEmail();
      if (email) _emailResolved = true;
      const id = await stableId();
      postHello({ email, id, name: '' });
      return { ok: true, email, id, browser: detectBrowser(), signedIn: !!email,
        _hint: email ? 'Resolved this profile\'s Google email and re-announced it to the bridge.' : 'This browser profile is NOT signed into Google, so it has no email - target it by its id-key or by browser:.' };
    }
    case 'nbrowser_ping':
      return { ok: true, pong: true };
    case 'nbrowser_dev_reload': {
      // HARD-GATED. chrome.runtime.reload() restarts the SW, which re-launches the native host. This is
      // a bridge-DISPATCHABLE verb, so a looping/buggy bridge could call it repeatedly → hosts
      // accumulate → port exhaustion (the 2026-06-19 incident). Throttle it, persisted in storage so an
      // SW restart cannot reset the clock and bypass it. 90s is plenty: a reload-per-90s cannot exhaust
      // ports (that needed many/second), and the per-profile host singleton + bridge circuit-breaker
      // (20 connects/10s → trip) are the real backstops. 10min was too coarse for iteration.
      const MIN_INTERVAL = 90 * 1000;
      const now = Date.now();
      const { _lastReload = 0 } = await chrome.storage.local.get('_lastReload');
      if (now - _lastReload < MIN_INTERVAL) {
        return { ok: false, error: 'dev_reload throttled',
          _hint: `last reload ${Math.round((now - _lastReload) / 1000)}s ago; min interval ${MIN_INTERVAL / 1000}s. If you truly need it now, reload from chrome://extensions.` };
      }
      // 2-pass reload: chrome.runtime.reload() does a SOFT reload first (serves cached extension
      // resources, so a single call kept the OLD code on disk-change), and only a 2nd reload re-reads
      // files. Arm a one-shot flag so the SW completes the HARD pass itself on restart — a single
      // dev_reload then reliably picks up on-disk changes. Completion logic is in the init at EOF.
      await chrome.storage.local.set({ _lastReload: now, _devReload2: now });
      setTimeout(() => chrome.runtime.reload(), 150); // reply first, then reload
      return { ok: true, reloading: true,
        _hint: 'Reloading in 2 self-completing passes (soft now → hard on restart) to load your on-disk changes; reconnects in ~6-10s. Poll nbrowser_status until version reflects the edit.' };
    }

    // --- windows / sessions ----------------------------------------------
    case 'nbrowser_open_window':   return native.openWindow(args);
    case 'nbrowser_close_window':  return native.closeWindow(args);
    case 'nbrowser_switch_window': return native.switchWindow(args);
    case 'nbrowser_list_windows':  return native.listWindows(args);
    case 'nbrowser_owned':         return native.owned(args);   // opened + spawned windows this session owns
    case 'nbrowser_cleanup':       return native.cleanup(args); // close ONLY this session's spawned windows
    case 'nbrowser_force_close':   return native.forceClose(args); // last-resort orphan override (two-step attestation)
    case 'nbrowser_window_state':  return native.windowState(args);
    case 'nbrowser_fullscreen':    return native.windowState({ ...args, state: args.state || 'fullscreen' });
    case 'nbrowser_login_state':   return native.loginState(args);
    case 'nbrowser_clear_cookies': return native.clearCookies(args); // BACKGROUND logout — drops a site's cookies (incl. httpOnly)
    case 'nbrowser_rescan':        return native.rescan();
    case 'nbrowser_resync_sessions': return native.resyncSessions(); // lightweight session-tracking rebuild (no dev_reload)

    // --- demo aids -------------------------------------------------------
    case 'nbrowser_caption':       return native.caption(args);
    case 'nbrowser_focus':         return native.focus(args);

    // --- tabs -------------------------------------------------------------
    case 'nbrowser_open_tab':      return native.openTab(args);
    case 'nbrowser_close_tab':     return native.closeTab(args);
    case 'nbrowser_switch_tab':    return native.switchTab(args);
    case 'nbrowser_list_tabs':     return native.listTabs(args);

    // --- navigation -------------------------------------------------------
    case 'nbrowser_navigate':      return native.navigate(args);
    case 'nbrowser_reload':        return native.reload(args);
    case 'nbrowser_back':          return native.back(args);
    case 'nbrowser_forward':       return native.forward(args);
    case 'nbrowser_wait':          return native.wait(args);
    case 'nbrowser_wait_for':      return native.waitFor(args);
    case 'nbrowser_keepalive':     return native.keepalive(args);

    // --- input (CDP, isTrusted) ------------------------------------------
    case 'nbrowser_input_dispatch':
      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_input_dispatch' }), args);
    case 'nbrowser_click':
      return cdp.clickChanged(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_click' }), args); // #5 robust: reports changed:bool
    case 'nbrowser_select': // open a custom/native dropdown, pick option by VISIBLE TEXT, return selected value
      return cdp.selectOption(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_select' }), args);
    case 'nbrowser_set_date':      // #7 react-aria segmented date field
      return cdp.setDate(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_set_date' }), args);
    case 'nbrowser_set_file_input': // CDP file upload - no OS picker (DOM.setFileInputFiles); armForNextChooser for transient inputs
      return cdp.setFileInput(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_set_file_input' }), args);
    case 'nbrowser_totp':          // #3 type the bridge-computed 6-digit code (args.__code) into the focused 2FA field
      return cdp.typeTotp(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_totp' }), args);
    case 'nbrowser_download_wait': // #6 wait for a download to complete; optional saveToSubfolder
      return native.downloadWait(args);
    case 'nbrowser_type':
      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_type' }), { ...args, type: 'type' });
    case 'nbrowser_press_key':
      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_press_key' }), { ...args, type: 'key' });
    case 'nbrowser_hover':
      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_hover' }), { ...args, type: 'hover' });
    case 'nbrowser_double_click':
      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_double_click' }), { ...args, type: 'dblclick' });
    case 'nbrowser_right_click':
      return cdp.inputDispatch(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_right_click' }), { ...args, type: 'rightclick' });
    case 'nbrowser_login_form':
      // Detect a login form's state (autofilled? SSO? 2FA?) and recommend the smartest path. ALWAYS call
      // before driving a login: if the form is already autofilled, just submit (background) — no popup/SSO.
      return cdp.detectLoginForm(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_login_form' }));
    case 'nbrowser_autologin':
      // Bridge injected args.__secret (the laptop-decrypted saved password). The extension injects it via
      // CDP and returns ONLY {ok,loggedIn,needs2FA} — never the secret. See cdp.autologin.
      return cdp.autologin(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_autologin' }), args);
    case 'nbrowser_cursor': {
      const tabId = await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_cursor' });
      await cdp.cursor(tabId, args.action || 'move', args.x, args.y, args.label || args.intent);
      return { ok: true, tabId: String(tabId), action: args.action || 'move', label: args.label || args.intent || 'Adom' };
    }

    // --- errors / lifecycle events ---------------------------------------
    case 'nbrowser_errors':
      return cdp.getErrors(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_errors' }), args.clear !== false);
    case 'nbrowser_events': {
      // window/tab closes (incl. byUser) + spawned_window/focus_violation since last drain — so the AI
      // learns the user closed things, a page spawned a window, or a native JS dialog fired.
      const e = await native.drainEvents();
      const dlgs = cdp.drainDialogs();
      if (dlgs.length) { e.events = (e.events || []).concat(dlgs); e.count = (e.events || []).length; }
      return e;
    }

    // --- data (M4: authed fetch via the user's cookies, downloads) --------
    case 'nbrowser_fetch_url':     return native.fetchUrl(args);
    case 'nbrowser_download':      return native.download(args);

    // --- recording (tab → WebM via offscreen MediaRecorder) ---------------
    case 'nbrowser_record_start':  return record.recordStart(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_record_start' }), args);
    case 'nbrowser_record_stop':   return record.recordStop(args);
    case 'nbrowser_record_status': return record.recordStatus(args);
    case 'nbrowser_record_list':   return record.recordList();

    // --- CDP: screenshot / eval ------------------------------------------
    case 'nbrowser_screenshot':
      return cdp.screenshot(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_screenshot' }), { fullPage: !!args.fullPage, maxWidth: args.maxWidth || 1568, resize: true });
    case 'nbrowser_screenshot_full_res':
      return cdp.screenshot(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_screenshot_full_res' }), { fullPage: !!args.fullPage, resize: false });
    case 'nbrowser_eval':
    case 'nbrowser_evaluate': {
      const tabId = await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_eval' });
      const m = cdp.modalGuard(tabId); if (m) return m; // a native JS dialog is blocking the page
      const result = await cdp.evaluate(tabId, String(args.expr ?? args.expression ?? ''));
      return { ok: true, result, tabId: String(tabId), sessionId: args.sessionId };
    }
    case 'nbrowser_handle_dialog': // press OK/Cancel (or answer a prompt) on a native JS dialog blocking the page
      return cdp.handleDialog(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_handle_dialog' }), args.accept, args.text ?? args.promptText);

    default:
      return { ok: false, error: `verb not implemented yet: ${verb}`,
        _hint: 'Milestone 2 ships windows/tabs/navigate/reload/screenshot/eval; input_dispatch/errors/fetch_url/record land in M3–M4.' };
  }
}

function detectBrowser() {
  try { return navigator.userAgent.includes('Edg') ? 'edge' : 'chrome'; } catch { return 'unknown'; }
}

chrome.runtime.onStartup.addListener(connect);
chrome.runtime.onInstalled.addListener(connect);

// Popup-driven "Record this tab" (tabCapture, no picker). The user's icon-click grants activeTab.
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (!msg || !msg.type) return;
  if (msg.type === 'popup-record-start') {
    record.recordStart(msg.tabId, { method: 'tabcapture', fps: 30 }).then(sendResponse);
    return true;
  }
  if (msg.type === 'popup-record-stop') {
    (async () => {
      const r = await record.recordStop({});
      if (r.ok && r.base64) {
        try {
          await chrome.downloads.download({ url: `data:${r.mimeType};base64,${r.base64}`, filename: `adom-tab-recording-${Date.now()}.webm` });
          r.downloaded = true;
        } catch (e) { r.downloadError = String((e && e.message) || e); }
        delete r.base64; // don't ship the big payload back to the popup
      }
      sendResponse(r);
    })();
    return true;
  }
  if (msg.type === 'popup-record-state') { sendResponse(record.recordStatus({})); return true; }
});

// Keepalive + self-heal. The host's ping keeps the SW warm WHILE connected, but if the SW is ever
// terminated the host dies too and nothing restarts it — so an alarm (which wakes a terminated MV3
// service worker) re-opens the native port. This is what makes the bridge reliably reachable.
chrome.alarms.create('nb-keepalive', { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((a) => {
  if (a.name !== 'nb-keepalive') return;
  // If the fast reconnect loop gave up, this tick re-arms exactly ONE attempt (a 0.5-min heartbeat) so
  // we still recover when the bridge returns — bounded, never a spin.
  if (givenUp) { givenUp = false; swAttempts = 0; swBackoff = SW_BASE; }
  if (!port) { connect(); return; }
  try { port.postMessage({ keepalive: true }); } catch (e) { port = null; setStatus(false); scheduleConnect(); }
});

// dev_reload 2-pass completion (see nbrowser_dev_reload). chrome.runtime.reload() runs a SOFT reload
// first; arming _devReload2 makes the SW do ONE more HARD reload here on restart so a single dev_reload
// reliably re-reads disk. Guards: cleared BEFORE reloading (no loop), and a >15s-old flag is treated as
// stale and dropped (a normal SW wake never triggers a stray reload). Otherwise connect as usual.
(async () => {
  try {
    const { _devReload2 = 0 } = await chrome.storage.local.get('_devReload2');
    if (_devReload2) {
      await chrome.storage.local.remove('_devReload2');
      if (Date.now() - _devReload2 < 15000) { chrome.runtime.reload(); return; } // hard pass, then stop
    }
  } catch { /* fall through to a normal connect */ }
  connect();
})();