#!/usr/bin/env node
'use strict';
/*
 * adom-browser-extension :: native-messaging host
 * ------------------------------------------------
 * Chrome/Edge launch this process when the extension calls
 *   chrome.runtime.connectNative('inc.adom.native_browser')
 *
 * It is a thin pump between two transports:
 *   - stdio : Chrome native-messaging framing  (4-byte LE length prefix + JSON), bidirectional
 *   - TCP   : the bridge's host-loopback (newline-delimited JSON), discovered from host.json
 *
 *   ext  --stdio-->  host  --tcp-->  bridge   (results, events, pong)
 *   ext  <--stdio--  host  <--tcp--  bridge   (commands)
 *
 * The extension never learns a port — that's the whole point (PORTS.md: no port survives HNS, and an
 * MV3 extension can't read a discovery file anyway). The host CAN read disk, so it does the lookup.
 *
 * A ~20s keepalive ping to the extension keeps the MV3 service worker resident.
 */

const fs = require('fs');
const net = require('net');
const os = require('os');
const path = require('path');

const BRIDGE_DIR = path.join(os.homedir(), '.adom', 'bridges', 'native-browser');
const HOSTFILE = path.join(BRIDGE_DIR, 'host.json');

// ---- PER-PROFILE SINGLETON guard -------------------------------------------
// Native-messaging hosts must NOT accumulate. The 2026-06-19 machine-wide port exhaustion was caused
// by N hosts each retrying connect()/sec. The rule is now ONE host PER BROWSER PROFILE (not one per
// machine) so several profiles ([email protected], [email protected], …) can each drive concurrently — but a
// single profile still can't spawn a pile of hosts. The profile id arrives in the extension's `hello`
// handshake (see pumpStdin); until then no lock is held, and a fallback timer takes a 'default' lock if
// no hello ever comes (old extension), preserving the original single-host loop-safety.
// Stale-PID-aware (a dead PID's file is reclaimed). Must be alive AND actually a node process — a stale
// pidfile whose PID got reused by an unrelated app otherwise makes every new host defer forever.
let PIDFILE = null; // set once we know our profile
function isLiveHost(pid) {
  if (!pid || pid === process.pid) return false;
  try { process.kill(pid, 0); } catch (e) { if (e.code !== 'EPERM') return false; } // dead -> reclaim
  try {
    const { execSync } = require('child_process');
    if (process.platform === 'win32') {
      return /"node\.exe"/i.test(execSync(`tasklist /fi "pid eq ${pid}" /fo csv /nh`, { encoding: 'utf8', timeout: 4000, windowsHide: true }));
    }
    return /node/i.test(execSync(`ps -p ${pid} -o comm=`, { encoding: 'utf8', timeout: 4000 }));
  } catch { return false; } // can't verify -> assume reused -> take over
}
function profileSlug(key) {
  return String(key || 'default').replace(/[^a-z0-9._@-]+/gi, '_').slice(0, 64) || 'default';
}
let singletonDone = false;
function acquireSingleton(profileKey) {
  if (singletonDone) return; singletonDone = true;
  PIDFILE = path.join(BRIDGE_DIR, `host.${profileSlug(profileKey)}.pid`);
  try { fs.mkdirSync(BRIDGE_DIR, { recursive: true }); } catch {}
  try {
    const existing = parseInt(String(fs.readFileSync(PIDFILE, 'utf8')).trim(), 10);
    if (isLiveHost(existing)) {
      process.stderr.write(`[native-host] another host (pid ${existing}) already drives profile ${profileSlug(profileKey)} — exiting (singleton)\n`);
      process.exit(0);
    }
  } catch {} // no/garbage pidfile -> we take ownership
  try { fs.writeFileSync(PIDFILE, String(process.pid)); } catch {}
}
function releaseSingleton() {
  if (!PIDFILE) return;
  try {
    const cur = parseInt(String(fs.readFileSync(PIDFILE, 'utf8')).trim(), 10);
    if (cur === process.pid) fs.unlinkSync(PIDFILE); // only delete OUR pidfile, never a sibling's
  } catch {}
}
process.on('exit', releaseSingleton);
// Fallback: if the extension never sends a `hello` (old build mid-upgrade), take a UNIQUE per-process
// lock — NOT a shared one. A shared 'default' lock would let one no-hello profile BLOCK every other
// profile's host (they'd fight over it and churn). Unique-per-pid means no cross-profile conflict; the
// bridge's provisional registration + circuit breaker + this host's own backoff/give-up still bound any
// runaway. Once every profile is on the hello build, this path is never taken.
const helloFallback = setTimeout(() => acquireSingleton('nohello-' + process.pid), 4000);

// ---- reconnect policy: exponential backoff + give-up (NO fixed-rate spin) ---
const BASE_DELAY = 500;            // first retry
const MAX_DELAY = 30000;           // backoff cap
const MAX_ATTEMPTS = 60;           // give up after this many attempts WITHOUT ever connecting
const MAX_UNREACHABLE_MS = 10 * 60 * 1000; // ...or this long unreachable, whichever first
const STABLE_MS = 5000;            // a connection up >= this long is "proven good" -> reset budget
let backoff = BASE_DELAY;
let attempts = 0;
let everConnected = false;
let connectedAt = 0;
const startedAt = Date.now();

// ---- Chrome native-messaging framing (stdout to the extension) -------------
function writeToExtension(obj) {
  const json = Buffer.from(JSON.stringify(obj), 'utf8');
  const len = Buffer.alloc(4);
  len.writeUInt32LE(json.length, 0);
  process.stdout.write(len);
  process.stdout.write(json);
}

// Which browser launched THIS host? The extension's hello carries `browser` on new builds — prefer it.
// Otherwise (old extension mid-upgrade) detect it from our process ancestry: the host is
// node.exe <- (native-host.bat) cmd.exe <- chrome.exe | msedge.exe. Walk parents until we hit a browser.
// Result is cached; only runs at all for an extension that didn't report its browser.
let _hostBrowser = null;
function hostBrowser(msg) {
  if (msg && msg.browser) return msg.browser;
  if (_hostBrowser) return _hostBrowser;
  _hostBrowser = detectBrowserFromTree();
  return _hostBrowser;
}
function detectBrowserFromTree() {
  try {
    const out = require('child_process').execSync(
      'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Csv -NoTypeInformation"',
      { timeout: 5000, windowsHide: true }
    ).toString();
    const byPid = new Map();
    for (const line of out.split(/\r?\n/)) {
      const m = line.match(/^"?(\d+)"?,"?(\d+)"?,"?([^"]+)"?$/);
      if (m) byPid.set(Number(m[1]), { ppid: Number(m[2]), name: String(m[3]).toLowerCase() });
    }
    let pid = process.ppid, hops = 0;
    while (pid && hops++ < 8) {
      const p = byPid.get(pid);
      if (!p) break;
      if (p.name.includes('msedge')) return 'edge';
      if (p.name.includes('chrome')) return 'chrome';
      pid = p.ppid;
    }
  } catch { /* fall through to default */ }
  return 'chrome';
}

// ---- stdin (from the extension) -> bridge ----------------------------------
let bridge = null;
let inbuf = Buffer.alloc(0);
// ---- saved-password decryption (runs in CHROME's REAL context) --------------------------------------
// The AD-spawned bridge is sandboxed: its DPAPI master-key scope differs from John's real profile, so it
// gets "parameter is incorrect" on Chrome's blobs (verified 2026-06-23). THIS host is spawned by Chrome,
// so it shares Chrome's DPAPI scope and CAN decrypt. SECURITY: the plaintext only ever flows DOWNSTREAM
// to the extension via stdio (this same process → Chrome → extension); it never goes back to the bridge,
// AD, the relay, or the AI. Only {ok,loggedIn,...} returns upstream.
const crypto = require('crypto');
const { execFileSync } = require('child_process');
const LOCALAPP = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
const BROWSER_UD = { chrome: path.join(LOCALAPP, 'Google', 'Chrome', 'User Data'), edge: path.join(LOCALAPP, 'Microsoft', 'Edge', 'User Data') };
const PWSH = (() => { try { const p = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); return fs.existsSync(p) ? p : 'powershell'; } catch { return 'powershell'; } })();
const sleepSync = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch {} };
// Warm powershell + System.Security ONCE at host startup so the first real decryption isn't a cold call.
// Cold powershell does "Preparing modules for first use"; rapid fresh spawns race on the module-analysis
// cache and exit 1 until it settles (verified 2026-06-24). Building the cache up front fixes it. Blocking
// ~1-2s at startup is fine; the result is discarded — its only job is to trigger module analysis.
let _pwshWarm = false;
function warmPowershell() {
  if (_pwshWarm) return; _pwshWarm = true;
  try { execFileSync(PWSH, ['-NoProfile', '-NonInteractive', '-Command', 'Add-Type -AssemblyName System.Security; [void][System.Security.Cryptography.ProtectedData]'], { windowsHide: true, timeout: 25000, stdio: 'ignore' }); } catch {}
}
const _aesKeyCache = {};
let myBrowser = 'chrome', myEmail = ''; // captured from the hello; used to locate this profile's Login Data
// Run a powershell that prints base64. INHERIT the host's env (it's in Chrome's real DPAPI context).
// RETRY the cold-start: a fresh powershell does "Preparing modules for first use" and intermittently
// exits 1 until the module-analysis cache settles (verified 2026-06-24).
function runPsB64(ps) {
  warmPowershell();
  const enc = Buffer.from(ps, 'utf16le').toString('base64');
  let lastErr;
  for (let attempt = 0; attempt < 4; attempt++) {
    if (attempt) sleepSync(350);
    try { const out = execFileSync(PWSH, ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 }); const b = Buffer.from(out.trim(), 'base64'); if (b && b.length) return b; lastErr = new Error('empty'); }
    catch (e) { lastErr = e; }
  }
  throw new Error('ps[' + (lastErr && lastErr.status) + ']');
}
// os_crypt key: read Local State + DPAPI-decrypt the key ENTIRELY in powershell (passing a node-read key to
// a separate DPAPI call failed "parameter is incorrect"; doing it all in-PS works — verified 2026-06-24).
function chromeAesKey(browser) {
  if (_aesKeyCache[browser]) return _aesKeyCache[browser];
  const lsPath = path.join(BROWSER_UD[browser], 'Local State');
  const ps = "Add-Type -AssemblyName System.Security; "
    + "$ls = Get-Content -Raw '" + lsPath.replace(/'/g, "''") + "' | ConvertFrom-Json; "
    + "$b = [Convert]::FromBase64String($ls.os_crypt.encrypted_key); $b = $b[5..($b.Length-1)]; "
    + "$k = [System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser); "
    + "[Convert]::ToBase64String($k)";
  const k = runPsB64(ps);
  if (k.length !== 32) throw new Error('badkeylen=' + k.length);
  return (_aesKeyCache[browser] = k);
}
function decryptBlob(blob, browser) {
  if (!blob || !blob.length) return null;
  const prefix = blob.slice(0, 3).toString('latin1');
  if (prefix === 'v20') throw new Error('v20-app-bound'); // Chrome 127+ app-bound: no external process can decrypt — use the browser autofill flow
  if (prefix === 'v10' || prefix === 'v11') {
    const key = chromeAesKey(browser);
    const iv = blob.slice(3, 15), tag = blob.slice(blob.length - 16), ct = blob.slice(15, blob.length - 16);
    const d = crypto.createDecipheriv('aes-256-gcm', key, iv); d.setAuthTag(tag);
    return Buffer.concat([d.update(ct), d.final()]).toString('utf8');
  }
  // very old DPAPI-only blob
  return runPsB64("Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String('" + blob.toString('base64') + "'); [Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))").toString('utf8');
}
function profilesOf(browser) {
  try { const ls = JSON.parse(fs.readFileSync(path.join(BROWSER_UD[browser], 'Local State'), 'utf8')); return Object.entries(ls.profile.info_cache || {}).map(([dir, info]) => ({ dir, email: info.user_name || '' })); } catch { return []; }
}
function loginRowsWithBlob(file) {
  const buf = fs.readFileSync(file);
  let pageSize = buf.readUInt16BE(16); if (pageSize === 1) pageSize = 65536;
  const n = Math.floor(buf.length / pageSize), out = [];
  const varint = (off) => { let r = 0n, i = 0; for (; i < 9; i++) { const b = buf[off + i]; if (i === 8) { r = (r << 8n) | BigInt(b); i++; break; } r = (r << 7n) | BigInt(b & 0x7f); if (!(b & 0x80)) { i++; break; } } return [r, i]; };
  for (let p = 1; p <= n; p++) {
    const base = (p - 1) * pageSize, hdr = p === 1 ? base + 100 : base;
    if (buf[hdr] !== 0x0d) continue;
    const cells = buf.readUInt16BE(hdr + 3), ptrArr = hdr + 8;
    for (let c = 0; c < cells; c++) {
      let o = base + buf.readUInt16BE(ptrArr + c * 2);
      let [, l1] = varint(o); o += l1; let [, l2] = varint(o); o += l2;
      const recStart = o; let [hLen, hb] = varint(o); const hEnd = recStart + Number(hLen); o += hb;
      const serials = []; while (o < hEnd) { const [s, sb] = varint(o); o += sb; serials.push(Number(s)); }
      let d = hEnd; const cols = [];
      for (const s of serials) { let v = null, sz = 0; if (s === 0 || s === 8 || s === 9) sz = 0; else if (s >= 1 && s <= 4) sz = s; else if (s === 5) sz = 6; else if (s === 6 || s === 7) sz = 8; else if (s >= 12 && s % 2 === 0) { sz = (s - 12) / 2; v = buf.slice(d, d + sz); } else if (s >= 13) { sz = (s - 13) / 2; v = buf.toString('utf8', d, d + sz); } cols.push(v); d += sz; }
      if (cols[0] && /^https?:\/\//.test(cols[0])) out.push({ host: cols[0].split('/')[2], username: cols[3] || '', blob: cols[5] || null });
    }
  }
  return out;
}
function decryptSavedPassword({ browser, profileEmail, host, username }) {
  browser = browser || 'chrome';
  if (!BROWSER_UD[browser]) return { error: 'unknown browser' };
  const profs = profilesOf(browser);
  const pr = profileEmail ? profs.find((p) => p.email.toLowerCase() === String(profileEmail).toLowerCase()) : (profs.find((p) => p.dir === 'Default') || profs[0]);
  if (!pr) return { error: 'profile not found' };
  const src = path.join(BROWSER_UD[browser], pr.dir, 'Login Data');
  const tmp = path.join(os.tmpdir(), `nb-al-${browser}-${Date.now()}.db`);
  try {
    fs.copyFileSync(src, tmp);
    const rows = loginRowsWithBlob(tmp);
    const h = String(host).toLowerCase(), core = h.split('.').slice(-2).join('.');
    let cand = rows.filter((r) => r.host && r.blob && r.blob.length && (r.host.toLowerCase().includes(h) || r.host.toLowerCase().includes(core)));
    cand.sort((a, b) => { const um = (r) => (username && (r.username || '').toLowerCase() === String(username).toLowerCase()) ? 1 : 0; if (um(b) !== um(a)) return um(b) - um(a); return (/(^|\.)login\./.test(a.host) ? 1 : 0) - (/(^|\.)login\./.test(b.host) ? 1 : 0); });
    let v20 = false;
    for (const row of cand) { try { const pw = decryptBlob(row.blob, browser); if (pw && pw.length) return { username: row.username, password: pw }; } catch (e) { if (String((e && e.message) || '').includes('v20')) v20 = true; } }
    return { error: v20 ? 'v20-app-bound (Chrome 127+ app-bound encryption — no external process can decrypt; use the browser autofill flow)' : 'no decryptable saved password for ' + host };
  } catch (e) { return { error: String((e && e.message) || e).slice(0, 80) }; }
  finally { try { fs.unlinkSync(tmp); } catch {} }
}

function pumpStdin() {
  while (inbuf.length >= 4) {
    const len = inbuf.readUInt32LE(0);
    if (inbuf.length < 4 + len) break;
    const json = inbuf.slice(4, 4 + len);
    inbuf = inbuf.slice(4 + len);
    let msg;
    try { msg = JSON.parse(json.toString('utf8')); } catch { continue; }
    if (msg && msg.pong) continue; // keepalive ack — don't forward
    // First frame is the profile handshake → take this profile's singleton lock, then forward it on so
    // the bridge registers the connection under this profile.
    if (msg && msg.type === 'hello') {
      clearTimeout(helloFallback);
      // BROWSER + profile keying so the same account in Chrome AND Edge each get their own host (instead
      // of the 2nd browser's host exiting on a shared lock — the "Native host has exited" collision).
      // Prefer the browser the extension reported; else detect it from THIS host's process ancestry
      // (chrome.exe vs msedge.exe) so an OLD extension that doesn't send `browser` still tags correctly.
      // TAG the hello with it so the bridge keys the connection the SAME way (all coexist in parallel).
      const browser = hostBrowser(msg);
      msg.browser = browser;
      const _id = (msg.profile && (msg.profile.email || msg.profile.id)) || 'default';
      myBrowser = browser; myEmail = (msg.profile && msg.profile.email) || ''; // for autologin decryption
      acquireSingleton(`${browser}:${_id}`);
      // Cache the tagged hello so we can REPLAY it whenever we (re)connect to the bridge. The extension
      // only sends hello ONCE (when its connectNative port first opens); if the BRIDGE later restarts
      // (e.g. an update/refresh_bridges), the SW's port is unbroken so it never re-hellos - without a
      // replay the restarted bridge would leave this profile stuck as a provisional "pending-N" (unknown
      // version, not drivable by its real key) until the SW happens to restart minutes later.
      lastHello = msg;
    }
    sendToBridge(msg);
  }
}
process.stdin.on('data', (c) => { inbuf = Buffer.concat([inbuf, c]); pumpStdin(); });
process.stdin.on('end', () => process.exit(0));
process.stdin.on('error', () => process.exit(0));

// Outbound frames (ext -> bridge). If the bridge TCP isn't up yet, QUEUE them — otherwise an early
// frame like the `hello` handshake (the extension sends it the instant it connects) gets dropped and
// the bridge never learns this profile. Flushed on bridge 'connect'.
let outQueue = [];
let lastHello = null;   // last tagged hello, re-sent on every bridge (re)connect so a restarted bridge re-learns us
function sendToBridge(msg) {
  const line = JSON.stringify(msg) + '\n';
  if (bridge && !bridge.destroyed) bridge.write(line);
  else { outQueue.push(line); if (outQueue.length > 200) outQueue.shift(); } // bound it
}
function flushOut() { if (bridge && !bridge.destroyed) { const q = outQueue; outQueue = []; for (const l of q) bridge.write(l); } }

// ---- bridge connection (newline JSON) -> extension -------------------------
function readHostPort() {
  try { return JSON.parse(fs.readFileSync(HOSTFILE, 'utf8')).hostPort; } catch { return null; }
}
// Schedule the next attempt with exponential backoff + jitter, OR give up (exit) if the bridge has
// been unreachable past the caps. Giving up is SAFE: stdin stays open, so Chrome keeps the extension's
// port; the SW's own bounded keepalive will re-launch a fresh host later if the bridge comes back. The
// one thing we must NEVER do is spin at a fixed fast rate — that is what exhausted the machine's ports.
function scheduleReconnect() {
  if (!everConnected) {
    if (attempts >= MAX_ATTEMPTS) {
      process.stderr.write(`[native-host] bridge unreachable after ${attempts} attempts — exiting\n`);
      process.exit(0);
    }
    if (Date.now() - startedAt > MAX_UNREACHABLE_MS) {
      process.stderr.write(`[native-host] bridge unreachable for >${MAX_UNREACHABLE_MS}ms — exiting\n`);
      process.exit(0);
    }
  }
  const jitter = Math.floor(Math.random() * 250);
  const delay = Math.min(backoff, MAX_DELAY) + jitter;
  backoff = Math.min(backoff * 2, MAX_DELAY); // double every miss; reset only on a proven-good connect
  setTimeout(connectBridge, delay);
}

function connectBridge() {
  const port = readHostPort();
  if (!port) { attempts++; scheduleReconnect(); return; } // bridge not up yet — backoff, don't spin
  attempts++;
  const sock = net.connect(port, '127.0.0.1');
  let buf = '';
  sock.on('connect', () => {
    bridge = sock; everConnected = true; connectedAt = Date.now();
    // Re-announce ourselves FIRST so a freshly (re)started bridge immediately re-learns this profile +
    // version, instead of leaving us provisional until the SW next restarts. Idempotent on the bridge.
    if (lastHello) { try { sock.write(JSON.stringify(lastHello) + '\n'); } catch {} }
    flushOut();
  });
  sock.on('data', (chunk) => {
    buf += chunk.toString('utf8');
    let nl;
    while ((nl = buf.indexOf('\n')) >= 0) {
      const line = buf.slice(0, nl);
      buf = buf.slice(nl + 1);
      if (!line.trim()) continue;
      let msg;
      try { msg = JSON.parse(line); } catch { continue; }
      // AUTOLOGIN: the bridge (sandboxed, can't DPAPI) flags __decryptHere; decrypt in THIS host (Chrome's
      // real context) and attach __secret for the extension to inject. The secret never leaves this hop.
      if (msg && msg.verb === 'nbrowser_autologin' && msg.args && msg.args.__decryptHere) {
        delete msg.args.__decryptHere;
        try {
          const cred = decryptSavedPassword({ browser: myBrowser, profileEmail: myEmail, host: msg.args.host, username: msg.args.username });
          if (cred && cred.password) { msg.args.__secret = cred.password; if (!msg.args.username && cred.username) msg.args.username = cred.username; }
          else msg.args.__secretError = (cred && cred.error) || 'decrypt failed';
        } catch (e) { msg.args.__secretError = String((e && e.message) || e).slice(0, 120); }
      }
      writeToExtension(msg); // command -> extension
    }
  });
  let done = false;
  const onDone = () => {
    if (done) return; done = true;            // 'close' + 'error' both fire — schedule once
    if (bridge === sock) bridge = null;
    if (connectedAt && Date.now() - connectedAt >= STABLE_MS) { backoff = BASE_DELAY; attempts = 0; }
    connectedAt = 0;
    scheduleReconnect();
  };
  sock.on('close', onDone);
  sock.on('error', onDone);
}
connectBridge();

// ---- keepalive (keeps the MV3 service worker alive) ------------------------
setInterval(() => { try { writeToExtension({ ping: true }); } catch {} }, 15000);