// Attach to a running packaged OpenSwarm over CDP and measure what the renderer actually painted.
//
// This exists because every other check in the packaged smoke is about the installer. Signature,
// install path, bundle contents, backend port: all can pass while the window shows a blank route
// or text nobody can read. Those are the failures users report and the smoke could not see.
//
// Node 22's global WebSocket, so the runner installs nothing.
//
// Exit 0 = every check passed. Exit 1 = at least one failed, with the numbers printed.

const HOST = process.env.SMOKE_CDP || '127.0.0.1:9222';
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

async function pickTarget() {
  for (let attempt = 0; attempt < 20; attempt += 1) {
    const targets = await (await fetch(`http://${HOST}/json`)).json();
    const page = targets.find((t) => t.type === 'page' && !t.url.startsWith('devtools://'));
    if (page && page.webSocketDebuggerUrl) return page;
    await wait(3000);
  }
  throw new Error('no page target exposed by the app');
}

function connect(url) {
  const ws = new WebSocket(url);
  let id = 0;
  const pending = new Map();
  ws.addEventListener('message', (e) => {
    const msg = JSON.parse(e.data);
    const resolve = pending.get(msg.id);
    if (resolve) { pending.delete(msg.id); resolve(msg); }
  });
  const ready = new Promise((r) => ws.addEventListener('open', r));
  const send = (method, params) => new Promise((resolve) => {
    id += 1;
    pending.set(id, resolve);
    ws.send(JSON.stringify({ id, method, params }));
  });
  return { ws, ready, send };
}

// One expression, evaluated in the page: everything we want to know about the painted result.
const PROBE = `(() => {
  const lum = (c) => {
    const [r, g, b] = c.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); });
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
  };
  const parse = (s) => {
    const m = (s || '').match(/rgba?\\(([^)]+)\\)/);
    if (!m) return null;
    const a = m[1].split(',').map(Number);
    return { rgb: [a[0], a[1], a[2]], a: a.length > 3 ? a[3] : 1 };
  };
  const bgOf = (el) => {
    let n = el;
    while (n && n !== document.documentElement) {
      const c = parse(getComputedStyle(n).backgroundColor);
      if (c && c.a > 0.5) return c.rgb;
      n = n.parentElement;
    }
    const b = parse(getComputedStyle(document.body).backgroundColor);
    return b ? b.rgb : [255, 255, 255];
  };
  const fields = [];
  document.querySelectorAll('input:not([type=checkbox]):not([type=radio]),textarea').forEach((el) => {
    const r = el.getBoundingClientRect();
    if (r.width < 8 || r.height < 6) return;
    const cs = getComputedStyle(el);
    // MUI parks an invisible native <input> behind every Select for form submission and a11y. It has
    // a real box and white-on-white text, so a naive contrast sweep fails a perfectly good build on
    // something no human can see. Judge only what is actually painted.
    if (cs.opacity === '0' || cs.visibility === 'hidden' || cs.display === 'none') return;
    if (el.getAttribute('aria-hidden') === 'true' || cs.pointerEvents === 'none') return;
    const fg = parse(cs.color);
    if (!fg) return;
    const bg = bgOf(el);
    const l1 = lum(fg.rgb);
    const l2 = lum(bg);
    fields.push({
      name: (el.placeholder || el.getAttribute('aria-label') || el.name || el.type || '?').slice(0, 30),
      ratio: Math.round(((Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05)) * 100) / 100,
    });
  });
  return JSON.stringify({
    route: location.hash.slice(0, 60),
    // A blank render is the ENG-207 boot wedge: chrome-less, on an empty route, one reload heals it.
    painted: document.body ? document.body.innerText.trim().length : 0,
    nodes: document.querySelectorAll('*').length,
    fields,
  });
})()`;

const main = async () => {
  const target = await pickTarget();
  const { ws, ready, send } = connect(target.webSocketDebuggerUrl);
  await ready;

  const evaluate = async (expression) => {
    const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
    if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.exception?.description || 'eval threw');
    return r.result?.result?.value;
  };

  // Open Settings first. Without it the dashboard has no form fields, the contrast check runs over
  // an empty list, and the step reports PASS having measured nothing. A check that cannot fail is
  // worse than no check, so the field count is asserted below.
  const key = async (k, code, modifiers) => {
    for (const type of ['keyDown', 'keyUp']) {
      await send('Input.dispatchKeyEvent', { type, key: k, code, modifiers, windowsVirtualKeyCode: 75 });
    }
  };
  const fieldCount = async () => JSON.parse(await evaluate(PROBE)).fields.length;
  await evaluate('window.focus()');
  for (const mod of [2, 4]) {              // Ctrl on Windows, Meta on macOS
    await key('k', 'KeyK', mod);
    await wait(1000);
    await send('Input.insertText', { text: 'settings' });
    await wait(1000);
    for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: 'Enter', code: 'Enter', modifiers: 0 });
    await wait(3000);
    // Settings remembers its last tab, and several tabs have no inputs at all, so opening it is
    // not enough: land on General, which owns the system prompt, working directory and max turns.
    await evaluate(`(() => {
      const hit = [...document.querySelectorAll('button,[role=button],[role=tab],li')]
        .filter((e) => (e.textContent || '').trim() === 'General' && e.getBoundingClientRect().width > 0);
      if (hit.length) hit[0].click();
      return hit.length;
    })()`);
    await wait(2000);
    // Whether the palette opened is not something to detect with a guessed selector; the only
    // question that matters is whether form fields are now on screen, so just look.
    if (await fieldCount() > 0) break;
  }

  const data = JSON.parse(await evaluate(PROBE));
  const failures = [];

  console.log(`  route            ${data.route || '(none)'}`);
  console.log(`  DOM nodes        ${data.nodes}`);
  console.log(`  visible text     ${data.painted} chars`);
  console.log(`  fields measured  ${data.fields.length}`);

  // A booted app paints a real tree. The boot wedge lands at a handful of nodes and no text.
  if (data.nodes < 120) failures.push(`only ${data.nodes} DOM nodes: renderer never painted a real tree`);
  if (data.painted < 20) failures.push(`only ${data.painted} chars of visible text: blank render`);

  // ENG-281 on Windows. Black-on-black scores 1.0; WCAG AA wants 4.5.
  // The count assertion is the point: zero fields means the contrast loop measured nothing and
  // "no field failed" was true only because no field existed.
  const MIN_FIELDS = 3;
  if (data.fields.length < MIN_FIELDS) {
    failures.push(`only ${data.fields.length} field(s) rendered, need >= ${MIN_FIELDS}: the contrast check would pass vacuously`);
  }
  const unreadable = data.fields.filter((f) => f.ratio < 4.5);
  for (const f of data.fields) console.log(`    ${String(f.ratio).padStart(7)}:1  ${f.name}`);
  if (unreadable.length) {
    failures.push(`${unreadable.length} field(s) below AA contrast: ${unreadable.map((f) => `${f.name}=${f.ratio}:1`).join(', ')}`);
  }

  // An exception during boot is not visible in any other step of this smoke.
  const errors = await evaluate(`JSON.stringify((window.__smokeErrors || []).slice(0, 5))`);
  const parsedErrors = JSON.parse(errors || '[]');
  if (parsedErrors.length) failures.push(`${parsedErrors.length} renderer error(s): ${parsedErrors.join(' | ')}`);

  ws.close();

  if (failures.length) {
    console.log('\nFAIL');
    for (const f of failures) console.log(`  - ${f}`);
    process.exit(1);
  }
  console.log('\nPASS  renderer painted a usable window and every field is readable');
};

main().catch((e) => { console.log(`FAIL  ${e.message}`); process.exit(1); });
