#!/usr/bin/env node
/**
 * ClinicPro QA driver — drives the running app the way a real user would, and
 * reports what broke. No npm dependencies: Node 22's global WebSocket speaks CDP
 * to a headless Chrome directly, so there is no playwright/puppeteer to install.
 *
 *   driver.mjs login  <role>
 *   driver.mjs visit  <url> [--as role] [--out f.png] [--w] [--h] [--wait] [--full]
 *   driver.mjs api    <METHOD> <path> [--as role] [--body '{...}']
 *   driver.mjs authz  <METHOD> <path> [--body '{...}']
 *   driver.mjs ux     <url> [--as role]
 *   driver.mjs perf   <url> [--as role]
 *   driver.mjs roles
 *
 * `visit` is the workhorse: it logs in over the API, seeds the SPA's auth store
 * into localStorage, navigates, then reports console errors, failed network
 * requests, the path it actually landed on, and a screenshot.
 */
import { spawn, execSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';

const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
const CHROME = process.env.CHROME_BIN
  ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PORT = Number(process.env.CDP_PORT ?? 9444);

/**
 * Local QA personas — one per distinct authorization identity in the product,
 * not merely one per ROLE_* constant: an independent doctor and a clinic-member
 * doctor carry the same role but see different data, so each gets its own row.
 *
 * The first five predate this list and are known to exist; the rest are
 * provisioned by SKILL.md § Phase 0 and report `✗` from `driver.mjs roles`
 * until they are. TEST_USERS.md is stale — its accounts do not exist.
 */
const ROLES = {
  admin: ['09120671756', 'QaTest@1234'],
  clinic: ['09127000000', 'QaTest@1234'],
  secretary: ['09123456778', 'QaTest@1234'],
  doctor: ['09390039833', 'QaTest@1234'],
  representation: ['09124000001', 'QaTest@1234'],

  // Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234.
  doctor_solo: ['09129000001', 'QaTest@1234'],       // own office, no clinic
  doctor_member: ['09129000002', 'QaTest@1234'],     // member of a clinic
  clinic_doctor: ['09129000003', 'QaTest@1234'],     // ROLE_CLINIC + ROLE_DOCTOR
  secretary_clinic: ['09129000004', 'QaTest@1234'],  // secretary of a clinic
  unclaimed_doctor: ['09129000005', 'QaTest@1234'],  // imported, unclaimed profile
  patient: ['09129000006', 'QaTest@1234'],           // ROLE_USER only
  importer: ['09129000007', 'QaTest@1234'],
};

// ddev serves a locally-signed cert Node's fetch refuses. Relax TLS only for it.
if (/^https:\/\/([\w-]+\.ddev\.site|localhost|127\.0\.0\.1)/.test(BASE)) {
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
  // …which Node then warns about on every run, drowning the actual QA output.
  process.removeAllListeners('warning');
  process.on('warning', () => {});
}

// ── auth ───────────────────────────────────────────────────────────────────

function creds(role) {
  if (ROLES[role]) return ROLES[role];
  if (role.includes(':')) return role.split(':');       // "0912...:password"
  throw new Error(`unknown role "${role}". Known: ${Object.keys(ROLES).join(', ')}`);
}

/**
 * Non-staff accounts (ROLE_USER, ROLE_UNCLAIMED_DOCTOR) are rejected by
 * PasswordAuthenticator with ERR_AUTH_006 by design — they are OTP-only.
 * In dev the OTP is the fixed '12345' (OtpService::sendCode), so the whole
 * send-code → verify-code → otp-login chain is scriptable.
 */
async function otpLogin(mobile) {
  const post = async (path, body) => {
    const r = await fetch(`${BASE}${path}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    return r.json();
  };

  const sent = await post('/api/v1/user/send-code', { mobile });
  if (!sent.uuid) throw new Error(`send-code failed: ${JSON.stringify(sent).slice(0, 200)}`);

  const ver = await post('/api/v1/user/verify-code', { uuid: sent.uuid, code: '12345' });
  const grant = ver?.data?.grant;
  if (!grant) throw new Error(`verify-code failed: ${JSON.stringify(ver).slice(0, 200)}`);

  return post('/api/v1/user/otp-login', { grant });
}

/** Same signing key and claims as a real login — only skips the rate limiter. */
function mintToken(mobile) {
  const out = execSync(
    `ddev exec 'php bin/console lexik:jwt:generate-token ${mobile} --user-class="App\\\\Auth\\\\Entity\\\\User"'`,
    { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], cwd: process.cwd() },
  );
  const tok = out.trim().split('\n').pop().trim();
  if (!tok.startsWith('ey')) throw new Error(`could not mint token for ${mobile}`);
  return tok;
}

async function login(role) {
  const [mobile_number, password] = creds(role);
  const r = await fetch(`${BASE}/api/v1/user/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ mobile_number, password }),
  });
  const j = await r.json();
  if (j.access_token) return j;

  // ERR_AUTH_006 means "staff-only endpoint", not "wrong password".
  // ERR_AUTH_005 IS "wrong password" — expected whenever the local DB was
  // replaced by a production dump, which brings its own hashes and drops the
  // QA accounts' passwords. OTP still authenticates those users, so fall
  // through to it rather than failing the whole run.
  if (j?.errors?.some((e) => e.code === 'ERR_AUTH_006' || e.code === 'ERR_AUTH_005')) {
    const o = await otpLogin(mobile_number).catch((e) => ({ _err: e.message }));
    if (o.access_token) return o;
    // send-code is capped at 5/hour/IP; once burned, mint the JWT with the app's
    // own command rather than loosening a real product limit for a test.
    return { access_token: mintToken(mobile_number) };
  }

  throw new Error(`login as ${role} failed: ${JSON.stringify(j).slice(0, 300)}`);
}

/** JWT is unsigned-read here purely to report which roles a token carries. */
function claims(token) {
  const s = token.split('.')[1];
  return JSON.parse(Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString());
}

// ── CDP plumbing ───────────────────────────────────────────────────────────

async function waitForCdp(timeoutMs = 15000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
      if (r.ok) return (await r.json()).webSocketDebuggerUrl;
    } catch { /* not up yet */ }
    await new Promise((r) => setTimeout(r, 200));
  }
  throw new Error(`Chrome did not expose CDP on :${PORT} within ${timeoutMs}ms`);
}

/** CDP client with both request/response and event subscription. */
function cdp(ws) {
  let id = 0;
  const pending = new Map();
  const listeners = [];
  ws.addEventListener('message', (ev) => {
    const msg = JSON.parse(ev.data);
    if (msg.id && pending.has(msg.id)) {
      const { resolve, reject } = pending.get(msg.id);
      pending.delete(msg.id);
      msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
    } else if (msg.method) {
      listeners.forEach((fn) => fn(msg.method, msg.params));
    }
  });
  const send = (method, params = {}, sessionId) =>
    new Promise((res, rej) => {
      const msgId = ++id;
      pending.set(msgId, { resolve: res, reject: rej });
      ws.send(JSON.stringify({ id: msgId, method, params, sessionId }));
    });
  send.on = (fn) => listeners.push(fn);
  return send;
}

/**
 * Boot Chrome, authenticate the SPA, navigate, and hand the page to `fn`.
 * Collects console errors and failed requests for the whole session.
 */
async function withPage(url, opts, fn) {
  const { access_token, refresh_token } = await login(opts.as);

  const chrome = spawn(CHROME, [
    '--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
    '--ignore-certificate-errors',            // ddev's local CA
    `--remote-debugging-port=${PORT}`,
    `--user-data-dir=/tmp/clinicpro-qa-${process.pid}`,
    `--window-size=${opts.w},${opts.h}`,
    'about:blank',
  ], { stdio: 'ignore' });

  const errors = [];
  const netFails = [];
  try {
    const ws = new WebSocket(await waitForCdp());
    await new Promise((res) => ws.addEventListener('open', res, { once: true }));
    const send = cdp(ws);

    const { targetId } = await send('Target.createTarget', { url: 'about:blank' });
    const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
    const S = (m, p) => send(m, p, sessionId);

    await S('Page.enable');
    await S('Runtime.enable');
    await S('Log.enable');
    await S('Network.enable');

    send.on((method, p) => {
      if (method === 'Runtime.exceptionThrown') {
        errors.push(`uncaught: ${p.exceptionDetails?.exception?.description ?? p.exceptionDetails?.text}`);
      } else if (method === 'Runtime.consoleAPICalled' && p.type === 'error') {
        errors.push('console.error: ' + p.args.map((a) => a.value ?? a.description ?? a.type).join(' '));
      } else if (method === 'Log.entryAdded' && p.entry.level === 'error') {
        errors.push(`log(${p.entry.source}): ${p.entry.text}`);
      } else if (method === 'Network.loadingFailed') {
        netFails.push(`request failed: ${p.errorText}`);
      } else if (method === 'Network.responseReceived' && p.response.status >= 400) {
        netFails.push(`HTTP ${p.response.status} ${p.response.url.replace(BASE, '')}`);
      }
    });

    // localStorage is origin-scoped: load the origin before seeding it.
    await S('Page.navigate', { url: `${BASE}/admin/login` });
    await new Promise((r) => setTimeout(r, 1500));
    const auth = {
      state: { token: access_token, refreshToken: refresh_token, isAuthenticated: true },
      version: 0,
    };
    await S('Runtime.evaluate', {
      expression: `localStorage.setItem('clinicpro-auth', ${JSON.stringify(JSON.stringify(auth))});
                   localStorage.setItem('pwa-dismissed','1');`,
    });

    // Errors before this point belong to the login page, not the page under test.
    errors.length = 0; netFails.length = 0;

    await S('Page.navigate', { url });
    await new Promise((r) => setTimeout(r, opts.wait));

    const evalJs = async (expression) => {
      const { result, exceptionDetails } = await S('Runtime.evaluate', {
        expression, returnByValue: true, awaitPromise: true,
      });
      if (exceptionDetails) throw new Error(exceptionDetails.text);
      return result.value;
    };

    await fn({ S, evalJs, errors, netFails, url, opts });
    ws.close();
  } finally {
    chrome.kill();
  }
}

/** Report the two failure modes a screenshot alone hides: wrong page, blank page. */
async function landingCheck(evalJs, url) {
  const v = await evalJs('JSON.stringify({p:location.pathname,t:(document.body.innerText||"").trim().length})');
  const { p: landed, t: len } = JSON.parse(v);
  const wanted = new URL(url).pathname;
  const out = [];
  if (landed.includes('/login')) out.push('⚠ redirected to /login — token rejected, expired, or route requires auth');
  else if (landed.replace(/\/$/, '') !== wanted.replace(/\/$/, '')) {
    out.push(`⚠ WRONG PAGE: asked ${wanted}, landed ${landed} — role likely lacks access (RoleRoute in App.tsx)`);
  }
  if (len < 40) out.push(`⚠ page text only ${len} chars — likely blank / crashed render`);
  return out;
}

function report(title, lines) {
  console.log(`\n${title}`);
  console.log(lines.length ? lines.map((l) => '  ' + l).join('\n') : '  (none)');
}

// ── commands ───────────────────────────────────────────────────────────────

async function cmdVisit(url, opts) {
  await withPage(url, opts, async ({ S, evalJs, errors, netFails }) => {
    const { data } = await S('Page.captureScreenshot', { format: 'png', captureBeyondViewport: opts.full });
    writeFileSync(opts.out, Buffer.from(data, 'base64'));
    console.log(`✓ screenshot ${opts.out}  (${opts.w}x${opts.h}, as ${opts.as})`);
    report('LANDING', await landingCheck(evalJs, url));
    report('CONSOLE ERRORS', [...new Set(errors)]);
    report('NETWORK FAILURES', [...new Set(netFails)]);
  });
}

/**
 * DOM heuristics for the recurring UX defects of an RTL Persian admin: layout
 * that overflows sideways, Latin digits leaking into Persian copy, tap targets
 * too small for the mobile viewport, tables with no empty state.
 */
const UX_PROBE = `(() => {
  const out = [];
  const de = document.documentElement;
  if (de.dir !== 'rtl' && getComputedStyle(de).direction !== 'rtl') out.push('root is not RTL');
  if (de.lang !== 'fa') out.push('html lang is "' + de.lang + '", expected "fa"');
  if (de.scrollWidth > de.clientWidth + 2)
    out.push('horizontal overflow: content ' + de.scrollWidth + 'px > viewport ' + de.clientWidth + 'px');

  // Latin digits inside Persian text read as untranslated to a Persian user.
  const fa = /[\\u0600-\\u06FF]/, latin = /[0-9]/;
  let mixed = 0;
  document.querySelectorAll('h1,h2,h3,label,th,button,a').forEach(el => {
    const t = (el.textContent||'').trim();
    if (t && fa.test(t) && latin.test(t)) mixed++;
  });
  if (mixed) out.push(mixed + ' element(s) mix Persian text with Latin digits (use Persian numerals)');

  // 44px is the usual minimum comfortable touch target.
  if (innerWidth < 600) {
    let small = 0;
    document.querySelectorAll('button,a,[role=button]').forEach(el => {
      const r = el.getBoundingClientRect();
      if (r.width > 0 && (r.height < 36 || r.width < 36)) small++;
    });
    if (small) out.push(small + ' tap target(s) under 36px on a mobile viewport');
  }

  document.querySelectorAll('img:not([alt])').forEach(() => {});
  const noAlt = document.querySelectorAll('img:not([alt])').length;
  if (noAlt) out.push(noAlt + ' <img> without alt');

  const noLabel = [...document.querySelectorAll('input,select,textarea')]
    .filter(el => !el.labels?.length && !el.getAttribute('aria-label') && !el.placeholder).length;
  if (noLabel) out.push(noLabel + ' form field(s) with no label, aria-label, or placeholder');

  const ids = {}; let dup = 0;
  document.querySelectorAll('[id]').forEach(el => { dup += (ids[el.id] = (ids[el.id]||0) + 1) > 1 ? 1 : 0; });
  if (dup) out.push(dup + ' duplicate DOM id(s)');

  // A table rendered with zero rows and no empty-state message is a dead end.
  document.querySelectorAll('table').forEach((t, i) => {
    const rows = t.querySelectorAll('tbody tr').length;
    if (rows === 0 && !/(هیچ|یافت نشد|خالی|موردی)/.test(t.parentElement?.textContent||''))
      out.push('table #' + (i+1) + ' has 0 rows and no empty-state message');
  });

  if (document.querySelector('select')) out.push('native <select> present — project standard is SearchableSelect');
  return JSON.stringify(out);
})()`;

async function cmdUx(url, opts) {
  await withPage(url, opts, async ({ evalJs, errors, netFails }) => {
    report('LANDING', await landingCheck(evalJs, url));
    report(`UX FINDINGS (${opts.w}x${opts.h}, as ${opts.as})`, JSON.parse(await evalJs(UX_PROBE)));
    report('CONSOLE ERRORS', [...new Set(errors)]);
    report('NETWORK FAILURES', [...new Set(netFails)]);
  });
}

async function cmdPerf(url, opts) {
  await withPage(url, opts, async ({ evalJs }) => {
    const t = JSON.parse(await evalJs(`JSON.stringify({
      nav: performance.getEntriesByType('navigation')[0],
      paint: performance.getEntriesByType('paint'),
      api: performance.getEntriesByType('resource')
        .filter(r => r.name.includes('/api/'))
        .map(r => ({ u: r.name.split('/api/')[1], ms: Math.round(r.duration), kb: Math.round(r.transferSize/1024) }))
        .sort((a,b) => b.ms - a.ms).slice(0, 12),
      res: performance.getEntriesByType('resource').length,
      dom: document.querySelectorAll('*').length,
    })`));
    console.log(`\nPERF ${url}  (as ${opts.as})`);
    if (t.nav) {
      console.log(`  ${'ttfb'.padEnd(24)}${Math.round(t.nav.responseStart)}ms`);
      console.log(`  ${'domContentLoaded'.padEnd(24)}${Math.round(t.nav.domContentLoadedEventEnd)}ms`);
      console.log(`  ${'load'.padEnd(24)}${Math.round(t.nav.loadEventEnd)}ms`);
    }
    t.paint.forEach((p) => console.log(`  ${p.name.padEnd(24)}${Math.round(p.startTime)}ms`));
    console.log(`  resources ${t.res} · DOM nodes ${t.dom}`);
    report('SLOWEST API CALLS', t.api.map((a) => `${String(a.ms).padStart(5)}ms  ${a.kb}kb  ${a.u}`));
  });
}

async function apiCall(method, path, role, body) {
  const { access_token } = await login(role);
  const t0 = Date.now();
  const r = await fetch(`${BASE}${path.startsWith('/') ? path : '/' + path}`, {
    method,
    headers: {
      Authorization: `Bearer ${access_token}`,
      'Content-Type': 'application/json',
    },
    body: body ?? undefined,
  });
  const text = await r.text();
  let json = null;
  try { json = JSON.parse(text); } catch { /* not json */ }
  return { status: r.status, ms: Date.now() - t0, json, text };
}

async function cmdApi(method, path, opts) {
  const { status, ms, json, text } = await apiCall(method, path, opts.as, opts.body);
  console.log(`${method} ${path}  →  ${status}  ${ms}ms  (as ${opts.as})`);

  // BaseController's envelope is the contract every client depends on.
  const problems = [];
  if (!json) problems.push('response is not JSON');
  else {
    if (!('success' in json)) problems.push('envelope missing "success"');
    if (status >= 400 && !json.errors) problems.push('error response has no "errors" array');
    if (json?.data?.data?.data) problems.push('triple-nested data — BaseController double-nesting pitfall');
    else if (json?.data?.data && !Array.isArray(json.data)) problems.push('double-nested data (client must read data.data.data)');
  }
  report('ENVELOPE', problems);
  console.log('\nBODY\n' + (json ? JSON.stringify(json, null, 2) : text).slice(0, 2000));
}

/** Same request as every role plus anonymous — the access-control matrix. */
async function cmdAuthz(method, path, opts) {
  console.log(`AUTHZ ${method} ${path}\n`);
  const rows = [];

  const anon = await fetch(`${BASE}${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: opts.body ?? undefined });
  rows.push(['anonymous', anon.status]);

  for (const role of Object.keys(ROLES)) {
    try {
      const { status } = await apiCall(method, path, role, opts.body);
      rows.push([role, status]);
    } catch (e) {
      rows.push([role, `login failed (${String(e.message).slice(0, 40)})`]);
    }
  }
  rows.forEach(([r, s]) => console.log(`  ${r.padEnd(16)} ${s}`));

  const leaks = rows.filter(([r, s]) => r === 'anonymous' && s === 200);
  if (leaks.length) console.log('\n  ⚠ anonymous got 200 — endpoint is public. Intended?');
  const allowed = rows.filter(([, s]) => s === 200).map(([r]) => r);
  console.log(`\n  200 for: ${allowed.join(', ') || '(nobody)'}`);
}

async function cmdRoles() {
  for (const role of Object.keys(ROLES)) {
    try {
      const j = await login(role);
      const c = claims(j.access_token);
      const mins = Math.round((c.exp - c.iat) / 60);
      console.log(`${role.padEnd(16)} ${creds(role)[0]}  ${c.roles.join(',')}  token ${mins}min`);
    } catch (e) {
      console.log(`${role.padEnd(16)} ✗ ${e.message.slice(0, 90)}`);
    }
  }
}

// ── CLI ────────────────────────────────────────────────────────────────────

const [cmd, ...argv] = process.argv.slice(2);
const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : d; };
const positional = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--') && argv[i - 1] !== '--full'));

const opts = {
  as: flag('as', 'admin'),
  out: flag('out', '/tmp/clinicpro-qa.png'),
  w: Number(flag('w', 1440)),
  h: Number(flag('h', 900)),
  wait: Number(flag('wait', 4000)),
  full: argv.includes('--full'),
  body: flag('body', null),
};

try {
  if (cmd === 'visit' && positional[0]) await cmdVisit(positional[0], opts);
  else if (cmd === 'ux' && positional[0]) await cmdUx(positional[0], opts);
  else if (cmd === 'perf' && positional[0]) await cmdPerf(positional[0], opts);
  else if (cmd === 'api' && positional[1]) await cmdApi(positional[0].toUpperCase(), positional[1], opts);
  else if (cmd === 'authz' && positional[1]) await cmdAuthz(positional[0].toUpperCase(), positional[1], opts);
  else if (cmd === 'login' && positional[0]) console.log(JSON.stringify(claims((await login(positional[0])).access_token), null, 2));
  else if (cmd === 'roles') await cmdRoles();
  else {
    console.log(`usage (roles: ${Object.keys(ROLES).join(', ')}, or "mobile:password")
  driver.mjs roles
  driver.mjs login  <role>
  driver.mjs visit  <url>  [--as admin] [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
  driver.mjs ux     <url>  [--as admin] [--w] [--h]
  driver.mjs perf   <url>  [--as admin]
  driver.mjs api    <METHOD> <path> [--as admin] [--body '{"k":1}']
  driver.mjs authz  <METHOD> <path> [--body '{"k":1}']`);
    process.exit(1);
  }
} catch (e) {
  console.error('✗ ' + e.message);
  process.exit(1);
}
