// ffpup — Firefox (WebDriver BiDi) bridge for Adom Desktop.
//
// The BiDi sibling of the puppeteer (Chrome) bridge. Drives the user's REAL
// Firefox over WebDriver BiDi: trusted input (isTrusted=true on clicks AND
// keystrokes — the thing a WebExtension content script can't fake), persistent
// logged-in profiles, and NO "controlled by automation" infobar (unlike Chrome's
// --remote-debugging-port). Uses the system Firefox — no browser download.
//
// Protocol (matches the pup bridge so AD routes it identically):
//   GET  /status            → health JSON
//   POST /command {command, args} → dispatch a verb, returns {success, output|error}
// Verbs are the ffbrowser_* surface mirroring pup's browser_*.
//
// Launched by Adom Desktop as:  node server.js --port <os-assigned>

const http = require('http');
const fs = require('fs');
const path = require('path');

const { execSync } = require('child_process');

// puppeteer-core is loaded LAZILY (on the first browser verb), and auto-installed
// into this bridge dir if missing. This keeps the published bridge zip tiny — the
// ~50MB node_modules is fetched once, on first use, on the user's machine. /status
// never touches it, so the bridge passes its health check instantly.
let _puppeteer = null;
function getPuppeteer() {
  if (_puppeteer) return _puppeteer;
  try { _puppeteer = require('puppeteer-core'); return _puppeteer; } catch { /* not installed */ }
  try { _puppeteer = require('puppeteer'); return _puppeteer; } catch { /* dev fallback */ }
  console.log('[ffpup] first run — installing puppeteer-core (one-time)…');
  execSync('npm install puppeteer-core@^24 --no-audit --no-fund --loglevel=error',
           { cwd: __dirname, stdio: 'inherit' });
  _puppeteer = require('puppeteer-core');
  return _puppeteer;
}

const PORT = (() => {
  const i = process.argv.indexOf('--port');
  return i !== -1 && process.argv[i + 1] ? parseInt(process.argv[i + 1], 10) : 8861;
})();

// Headful by default (this drives the user's real browser). FFPUP_HEADLESS=1 for
// container/CI testing where there's no display.
const HEADLESS = process.env.FFPUP_HEADLESS === '1';

// Firefox profile location. Snap-packaged Firefox (the Ubuntu 24.04 default) is
// confined: it can ONLY read profiles under ~/snap/firefox/… or NON-hidden home
// dirs — NOT ~/.local, where AD's bridges-cache lives. A profile it can't read
// gives the user a "profile could not be loaded" dialog and Firefox exits. So when
// snap Firefox is present, put profiles inside its own accessible data dir.
function resolveStateDir() {
  if (process.env.FFPUP_STATE_DIR) return process.env.FFPUP_STATE_DIR;
  const home = process.env.HOME || require('os').homedir();
  if (process.platform === 'linux') {
    try {
      const snapCommon = path.join(home, 'snap', 'firefox', 'common');
      if (fs.existsSync(snapCommon)) return path.join(snapCommon, 'ffpup');
    } catch { /* ignore */ }
  }
  return path.join(__dirname, '.ffpup');
}
const STATE_DIR = resolveStateDir();
const PROFILES_DIR = path.join(STATE_DIR, 'profiles');
fs.mkdirSync(PROFILES_DIR, { recursive: true });

// Resolve a Firefox executable: env override → system install → puppeteer-managed.
function resolveFirefox() {
  if (process.env.FFPUP_FIREFOX_PATH && fs.existsSync(process.env.FFPUP_FIREFOX_PATH)) {
    return process.env.FFPUP_FIREFOX_PATH;
  }
  const candidates = process.platform === 'win32'
    ? ['C:/Program Files/Mozilla Firefox/firefox.exe', 'C:/Program Files (x86)/Mozilla Firefox/firefox.exe']
    : ['/usr/bin/firefox', '/usr/lib/firefox/firefox', '/snap/bin/firefox',
       '/Applications/Firefox.app/Contents/MacOS/firefox'];
  for (const p of candidates) { try { if (fs.existsSync(p)) return p; } catch { /* ignore */ } }
  return null; // null → puppeteer uses its own downloaded Firefox (dev only)
}

// Firefox prefs mirroring the Chrome launch-flag suppressions: no first-run, no
// default-browser nag, no telemetry, no session-restore (this surface is
// AI-driven — it knows what it wants open).
const FIREFOX_PREFS = {
  'browser.shell.checkDefaultBrowser': false,
  'browser.startup.page': 0,
  'browser.startup.homepage_override.mstone': 'ignore',
  'datareporting.policy.dataSubmissionEnabled': false,
  'toolkit.telemetry.enabled': false,
  'app.update.auto': false,
  'app.update.enabled': false,
  'browser.tabs.warnOnClose': false,
  'browser.sessionstore.resume_from_crash': false,
  'browser.aboutwelcome.enabled': false,
};

// ── State ────────────────────────────────────────────────────────────
// browsers: profileName → { browser, refCount }   (one Firefox per profile)
// sessions: sessionId   → { tabs:[{tabId,page,errors}], activeTabId, profileName, tabCounter }
const browsers = new Map();
const sessions = new Map();
let activeSessionId = null;

function createSession(profileName) {
  return { tabs: [], activeTabId: null, profileName, tabCounter: 0 };
}
function nextTabId(s) { return `tab-${++s.tabCounter}`; }
function getTab(session, tabId) {
  if (tabId) return session.tabs.find(t => t.tabId === tabId) || null;
  return session.tabs.find(t => t.tabId === session.activeTabId) || session.tabs[session.tabs.length - 1] || null;
}

async function getOrLaunchBrowser(profileName, freshProfile) {
  const puppeteer = getPuppeteer();
  const existing = browsers.get(profileName);
  if (existing && existing.browser.isConnected()) {
    try {
      await Promise.race([
        existing.browser.pages(),
        new Promise((_, rej) => setTimeout(() => rej(new Error('health check timeout')), 2500)),
      ]);
      return existing.browser;
    } catch {
      try { await existing.browser.close(); } catch { /* ignore */ }
      browsers.delete(profileName);
    }
  }
  const launchOpts = {
    browser: 'firefox',                 // WebDriver BiDi
    headless: HEADLESS,
    defaultViewport: null,
    extraPrefsFirefox: FIREFOX_PREFS,
    handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false,
  };
  if (!freshProfile) {
    const userDataDir = path.join(PROFILES_DIR, profileName);
    fs.mkdirSync(userDataDir, { recursive: true });
    launchOpts.userDataDir = userDataDir;
  }
  const exe = resolveFirefox();
  if (exe) launchOpts.executablePath = exe;

  const browser = await puppeteer.launch(launchOpts);
  browser.on('disconnected', () => { browsers.delete(profileName); });
  browsers.set(profileName, { browser, refCount: 0 });
  return browser;
}

function attachTab(session, page) {
  const tabId = nextTabId(session);
  const errors = [];
  page.on('pageerror', e => errors.push({ type: 'page', text: String(e && e.message || e), ts: Date.now() }));
  page.on('console', msg => { if (msg.type() === 'error') errors.push({ type: 'console', text: msg.text(), ts: Date.now() }); });
  page.on('close', () => {
    const i = session.tabs.findIndex(t => t.tabId === tabId);
    if (i !== -1) {
      session.tabs.splice(i, 1);
      if (session.activeTabId === tabId) {
        session.activeTabId = session.tabs.length ? session.tabs[session.tabs.length - 1].tabId : null;
      }
    }
  });
  session.tabs.push({ tabId, page, errors });
  session.activeTabId = tabId;
  return tabId;
}

async function sessionInfo(sessionId) {
  const s = sessions.get(sessionId);
  if (!s) return null;
  const tabs = [];
  for (const t of s.tabs) {
    let url = null, title = null;
    try { url = t.page.url(); } catch { /* closed */ }
    try { title = await t.page.title(); } catch { /* ignore */ }
    tabs.push({ tabId: t.tabId, url, title, active: t.tabId === s.activeTabId, errorCount: t.errors.length });
  }
  return { sessionId, profile: s.profileName, engine: 'firefox', activeTabId: s.activeTabId, tabs, tabCount: tabs.length };
}

// ── Verb handlers ────────────────────────────────────────────────────
const handlers = {
  async ffbrowser_open_window(args) {
    if (!args.sessionId) throw err('sessionId is required. Example: ffbrowser_open_window {"sessionId":"x","profile":"x","url":"https://…"}');
    if (!args.profile) throw err('profile is required (use the same value as sessionId for a dedicated profile, or share another session\'s profile to share cookies/logins).');
    const sessionId = args.sessionId;
    const profileName = args.profile;

    // Reuse a live session: just navigate.
    const existing = sessions.get(sessionId);
    if (existing) {
      const tab = getTab(existing);
      if (tab) {
        try {
          await tab.page.title();
          if (args.url) { await tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 }); tab.errors.length = 0; }
          activeSessionId = sessionId;
          return ok(await sessionInfo(sessionId));
        } catch { sessions.delete(sessionId); }
      } else { sessions.delete(sessionId); }
    }

    const browser = await getOrLaunchBrowser(profileName, !!args.freshProfile);
    // Reuse a blank startup tab if one is free, else open a new page.
    const claimed = new Set();
    for (const s of sessions.values()) for (const t of s.tabs) claimed.add(t.page);
    const pages = await browser.pages();
    let page = pages.find(p => { if (claimed.has(p)) return false; try { const u = p.url(); return !u || u === 'about:blank'; } catch { return false; } });
    if (!page) page = await browser.newPage();

    if (args.url) await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });

    const session = createSession(profileName);
    attachTab(session, page);
    sessions.set(sessionId, session);
    activeSessionId = sessionId;
    return ok(await sessionInfo(sessionId));
  },

  async ffbrowser_open_tab(args) {
    const s = requireSession(args.sessionId);
    const browser = browsers.get(s.profileName).browser;
    const page = await browser.newPage();
    if (args.url) await page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
    const tabId = attachTab(s, page);
    return ok({ ...(await sessionInfo(args.sessionId)), openedTabId: tabId });
  },

  async ffbrowser_close_tab(args) {
    const s = requireSession(args.sessionId);
    const tab = getTab(s, args.tabId);
    if (!tab) throw err(`tab not found: ${args.tabId || '(active)'}`);
    try { await tab.page.close(); } catch { /* ignore */ }
    return ok(await sessionInfo(args.sessionId));
  },

  async ffbrowser_switch_tab(args) {
    const s = requireSession(args.sessionId);
    const tab = getTab(s, args.tabId);
    if (!tab) throw err(`tab not found: ${args.tabId}`);
    s.activeTabId = tab.tabId;
    try { await tab.page.bringToFront(); } catch { /* ignore */ }
    return ok(await sessionInfo(args.sessionId));
  },

  async ffbrowser_list_tabs(args) {
    requireSession(args.sessionId);
    return ok(await sessionInfo(args.sessionId));
  },

  async ffbrowser_close_window(args) {
    const s = requireSession(args.sessionId);
    sessions.delete(args.sessionId);
    if (activeSessionId === args.sessionId) activeSessionId = sessions.keys().next().value || null;
    const entry = browsers.get(s.profileName);
    // Close the browser only if no other session shares this profile.
    const stillUsed = [...sessions.values()].some(o => o.profileName === s.profileName);
    if (entry && !stillUsed) { try { await entry.browser.close(); } catch { /* ignore */ } browsers.delete(s.profileName); }
    return ok({ closed: args.sessionId });
  },

  async ffbrowser_navigate(args) {
    const tab = activeTabOf(args);
    if (!args.url) throw err('url is required');
    await tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
    tab.errors.length = 0;
    return ok({ url: tab.page.url(), title: await tab.page.title().catch(() => null) });
  },

  async ffbrowser_reload(args) {
    const tab = activeTabOf(args);
    await tab.page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });
    tab.errors.length = 0;
    return ok({ url: tab.page.url() });
  },

  async ffbrowser_evaluate(args) {
    const tab = activeTabOf(args);
    if (!args.expr) throw err('expr is required. Usage: ffbrowser_evaluate {"sessionId":"…","expr":"document.title"}');
    const value = await tab.page.evaluate(expr => {
      // eslint-disable-next-line no-eval
      const r = eval(expr);
      return r;
    }, args.expr);
    return ok({ value });
  },

  async ffbrowser_screenshot(args) {
    const tab = activeTabOf(args);
    const buf = await tab.page.screenshot({ fullPage: !!args.fullPage, type: 'png' });
    const shotPath = path.join(STATE_DIR, `shot-${args.sessionId}-${Date.now()}.png`);
    try { fs.writeFileSync(shotPath, buf); } catch { /* ignore */ }
    return ok({ path: shotPath, base64: buf.toString('base64'), bytes: buf.length });
  },

  // Trusted input via puppeteer's BiDi-backed page.mouse / page.keyboard.
  // type: click | move | type | key
  async ffbrowser_input_dispatch(args) {
    const tab = activeTabOf(args);
    const page = tab.page;
    const t = (args.type || '').toLowerCase();
    if (t === 'click') {
      if (args.selector) {
        const elt = await page.$(args.selector);
        if (!elt) throw err(`selector not found: ${args.selector}`);
        await elt.click();
        return ok({ clicked: args.selector, isTrusted: true });
      }
      if (typeof args.x === 'number' && typeof args.y === 'number') {
        await page.mouse.click(args.x, args.y);
        return ok({ clicked: { x: args.x, y: args.y }, isTrusted: true });
      }
      throw err('click: provide {selector} OR {x,y}');
    }
    if (t === 'move') {
      if (typeof args.x !== 'number' || typeof args.y !== 'number') throw err('move: {x,y} required');
      await page.mouse.move(args.x, args.y);
      return ok({ moved: { x: args.x, y: args.y } });
    }
    if (t === 'type') {
      if (args.selector) { const e = await page.$(args.selector); if (e) await e.focus(); }
      if (typeof args.text !== 'string') throw err('type: {text} required');
      await page.keyboard.type(args.text, { delay: args.delay || 0 });
      return ok({ typed: args.text.length, isTrusted: true });
    }
    if (t === 'key') {
      if (!args.key) throw err('key: {key} required (e.g. Enter, Escape, Tab, ArrowDown)');
      await page.keyboard.press(args.key);
      return ok({ pressed: args.key, isTrusted: true });
    }
    throw err(`unknown input type "${args.type}". Supported: click | move | type | key`);
  },

  async ffbrowser_errors(args) {
    const tab = activeTabOf(args);
    return ok({ errors: tab.errors, count: tab.errors.length });
  },

  async ffbrowser_status() {
    const out = [];
    for (const id of sessions.keys()) out.push(await sessionInfo(id));
    return ok({ bridge: 'ffpup', engine: 'firefox', sessionCount: sessions.size, activeSession: activeSessionId, sessions: out });
  },
};

// ── helpers for handlers ─────────────────────────────────────────────
function err(message) { const e = new Error(message); e._ffpup = true; return e; }
function ok(obj) { return { success: true, output: JSON.stringify({ ok: true, ...obj }) }; }
function requireSession(sessionId) {
  if (!sessionId) throw err('sessionId is required');
  const s = sessions.get(sessionId);
  if (!s) throw err(`session "${sessionId}" not found. Open one first with ffbrowser_open_window. Available: [${[...sessions.keys()].join(', ')}]`);
  return s;
}
function activeTabOf(args) {
  const s = requireSession(args.sessionId);
  const tab = getTab(s, args.tabId);
  if (!tab) throw err(`no open tab in session "${args.sessionId}"`);
  return tab;
}

// ── HTTP server ──────────────────────────────────────────────────────
function readBody(req) {
  return new Promise((resolve, reject) => {
    let data = '';
    req.on('data', c => { data += c; });
    req.on('end', () => resolve(data));
    req.on('error', reject);
  });
}
function sendJSON(res, obj, status = 200) {
  const body = JSON.stringify(obj);
  res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
  res.end(body);
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
  const pathname = url.pathname;
  if (req.method === 'OPTIONS') {
    res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' });
    return res.end();
  }
  // Health — accept both /status (manifest) and /health (pup-compat).
  if ((pathname === '/status' || pathname === '/health') && req.method === 'GET') {
    return sendJSON(res, {
      status: 'ok', bridge: 'ffpup', engine: 'firefox', port: PORT,
      browserCount: browsers.size, sessionCount: sessions.size, activeSession: activeSessionId,
      firefox: resolveFirefox() || '(puppeteer-managed)', headless: HEADLESS,
    });
  }
  if (pathname === '/command' && req.method === 'POST') {
    let command;
    try {
      const body = JSON.parse(await readBody(req) || '{}');
      command = body.command;
      const args = body.args || {};
      const handler = handlers[command];
      if (!handler) return sendJSON(res, { success: false, error: `unknown command "${command}". Known: ${Object.keys(handlers).join(', ')}` });
      const result = await handler(args);
      return sendJSON(res, result);
    } catch (e) {
      return sendJSON(res, { success: false, error: e && e.message ? e.message : String(e), command });
    }
  }
  sendJSON(res, { success: false, error: `not found: ${req.method} ${pathname}` }, 404);
});

server.listen(PORT, '127.0.0.1', () => {
  console.log(`[ffpup] listening on 127.0.0.1:${PORT} — firefox=${resolveFirefox() || '(puppeteer-managed)'} headless=${HEADLESS}`);
});

process.on('SIGTERM', () => { server.close(); process.exit(0); });
process.on('SIGINT', () => { server.close(); process.exit(0); });