#!/usr/bin/env node
/**
 * ClinicPro admin page driver — screenshots and audits a page of the React admin
 * SPA from its URL, with no npm dependencies (Node 22's global WebSocket speaks
 * CDP directly, so there is no playwright/puppeteer install to babysit).
 *
 *   node .claude/skills/redesign-page/driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--full]
 *   node .claude/skills/redesign-page/driver.mjs inspect <url>
 *   node .claude/skills/redesign-page/driver.mjs audit <file.tsx>
 *
 * `shot`    logs in over the API, seeds localStorage['clinicpro-auth'], then
 *           navigates and captures. Needed because the admin is a client-side
 *           SPA: Chrome's plain `--screenshot` flag lands on the login form.
 * `inspect` maps a URL to the route entry in App.tsx, the page source file, and
 *           the design-system components it already imports.
 * `audit`   greps one source file for the anti-patterns this project keeps
 *           regrowing (native <select>, hardcoded hex, dead tokens, …).
 */
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const BASE = process.env.CLINICPRO_BASE ?? 'https://clinic-pro.ddev.site';
const USER = process.env.CLINICPRO_USER ?? '09390039833';
const PASS = process.env.CLINICPRO_PASS ?? '09390039833';
const CHROME = process.env.CHROME_BIN
  ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const PORT = Number(process.env.CDP_PORT ?? 9333);

// ddev serves a locally-signed cert that Node's fetch refuses. Only relax TLS for
// that local host — never for a real origin someone might point this at.
if (/^https:\/\/([\w-]+\.ddev\.site|localhost|127\.0\.0\.1)/.test(BASE)) {
  process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}

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

/** Chrome needs a moment before /json/version answers; poll instead of sleeping. */
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`);
}

/** Minimal CDP client: send(method, params) → Promise<result>. */
function cdp(ws) {
  let id = 0;
  const pending = new Map();
  ws.addEventListener('message', (ev) => {
    const msg = JSON.parse(ev.data);
    if (msg.id && pending.has(msg.id)) {
      const { resolve: res, reject } = pending.get(msg.id);
      pending.delete(msg.id);
      msg.error ? reject(new Error(JSON.stringify(msg.error))) : res(msg.result);
    }
  });
  return (method, params = {}, sessionId) =>
    new Promise((res, reject) => {
      const msgId = ++id;
      pending.set(msgId, { resolve: res, reject });
      ws.send(JSON.stringify({ id: msgId, method, params, sessionId }));
    });
}

async function login() {
  const r = await fetch(`${BASE}/api/v1/user/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ mobile_number: USER, password: PASS }),
  });
  const j = await r.json();
  if (!j.access_token) throw new Error(`login failed: ${JSON.stringify(j).slice(0, 200)}`);
  return j;
}

async function shot(url, opts) {
  const { access_token, refresh_token } = await login();

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

  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');

    // localStorage is origin-scoped, so the origin must be loaded before seeding.
    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');
      `,
    });

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

    const { data } = await S('Page.captureScreenshot', {
      format: 'png',
      captureBeyondViewport: opts.full,
    });
    writeFileSync(opts.out, Buffer.from(data, 'base64'));
    console.log(`✓ ${opts.out}`);

    // The SPA redirects silently: RoleRoute bounces a user whose role lacks access
    // straight to /dashboard, so you get a valid-looking screenshot of the WRONG
    // page. Compare the landed path against the requested one and say so loudly.
    const { result } = await S('Runtime.evaluate', {
      expression: 'location.pathname + "|" + (document.body.innerText||"").trim().length',
      returnByValue: true,
    });
    const [landed, len] = String(result.value).split('|');
    const wanted = new URL(url).pathname;
    if (landed.includes('/login')) {
      console.log('⚠ redirected to /login — token rejected or expired');
    } else if (landed.replace(/\/$/, '') !== wanted.replace(/\/$/, '')) {
      console.log(`⚠ WRONG PAGE: asked for ${wanted}, landed on ${landed}`);
      console.log('  → the test user\'s role probably lacks access (see RoleRoute in App.tsx).');
      console.log('  → set CLINICPRO_USER/CLINICPRO_PASS to a user with the right role.');
    }
    if (Number(len) < 40) console.log(`⚠ page text is only ${len} chars — may be blank`);
    ws.close();
  } finally {
    chrome.kill();
  }
}

// ── Static inspection ──────────────────────────────────────────────────────

/** URL path → the <Route> line in App.tsx → the page component file. */
function inspect(url) {
  const path = url.replace(/^https?:\/\/[^/]+/, '').replace(/^\/admin\/?/, '').split('?')[0];
  const app = readFileSync(`${REPO}/assets/admin/App.tsx`, 'utf8');
  const segs = path.split('/').filter(Boolean);

  const routes = [...app.matchAll(/<Route\s+path="([^"]+)"[\s\S]*?element=\{([\s\S]*?)\}\s*\/>/g)]
    .map(([, p, el]) => ({ p, comp: (el.match(/<(\w+)\s*\/>/g) ?? []).pop() ?? el.trim() }));

  const score = (rp) => {
    const rs = rp.split('/').filter(Boolean);
    if (rs.length !== segs.length) return -1;
    return rs.every((s, i) => s.startsWith(':') || s === segs[i]) ? rs.length : -1;
  };
  const hit = routes.map((r) => ({ ...r, s: score(r.p) })).filter((r) => r.s >= 0)
    .sort((a, b) => b.s - a.s)[0];

  if (!hit) {
    console.log(`no route matched "${path}". Routes:\n` + routes.map((r) => '  ' + r.p).join('\n'));
    return;
  }
  const comp = hit.comp.replace(/[<>/\s]/g, '');
  const imp = app.match(new RegExp(`import\\s+${comp}\\s+from\\s+'([^']+)'`));
  const file = imp ? `assets/admin/${imp[1].replace(/^\.\//, '')}.tsx` : '(inline element)';

  console.log(`route     ${hit.p}`);
  console.log(`component ${comp}`);
  console.log(`file      ${file}`);

  const abs = `${REPO}/${file}`;
  if (existsSync(abs)) {
    const src = readFileSync(abs, 'utf8');
    const ds = [...src.matchAll(/from\s+'\.\.\/components\/(ui\/)?([\w/]+)'/g)].map((m) => m[2]);
    console.log(`components ${[...new Set(ds)].join(', ') || '(none)'}`);
    console.log(`lines     ${src.split('\n').length}`);
    auditSource(file, src);
  }
}

/** The anti-patterns this codebase keeps regrowing. Each one is a real past bug. */
function auditSource(label, src) {
  const tokens = readFileSync(`${REPO}/assets/admin/styles.css`, 'utf8');
  const findings = [];
  const push = (re, msg) => {
    src.split('\n').forEach((line, i) => { if (re.test(line)) findings.push(`${label}:${i + 1}  ${msg}`); });
  };

  push(/<select\b/, 'native <select> — use SearchableSelect');
  push(/className="btn"(?!\s*\+)/, '.btn with no variant — renders borderless/invisible');
  push(/#[0-9a-fA-F]{6}\b/, 'hardcoded hex — use a var(--…) token');
  push(/className="overlay"/, 'hand-rolled overlay — use the shared <Modal>');
  push(/className="field"[\s\S]*?<label/, '<label> inside .field — .field is an inline box; use .field-block');

  // var(--x) references that styles.css never defines (e.g. the dead --error).
  for (const m of src.matchAll(/var\((--[\w-]+)/g)) {
    if (!tokens.includes(`${m[1]}:`)) findings.push(`${label}  undefined token ${m[1]}`);
  }

  console.log(findings.length ? '\nAUDIT\n' + [...new Set(findings)].map((f) => '  ' + f).join('\n')
                              : '\nAUDIT  clean');
}

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

const [cmd, arg, ...rest] = process.argv.slice(2);
const flag = (n, d) => { const i = rest.indexOf(`--${n}`); return i >= 0 ? rest[i + 1] : d; };

if (cmd === 'shot' && arg) {
  await shot(arg, {
    out: flag('out', 'page.png'),
    w: Number(flag('w', 1440)),
    h: Number(flag('h', 900)),
    wait: Number(flag('wait', 4000)),
    full: rest.includes('--full'),
  });
} else if (cmd === 'inspect' && arg) {
  inspect(arg);
} else if (cmd === 'audit' && arg) {
  auditSource(arg, readFileSync(resolve(REPO, arg), 'utf8'));
} else {
  console.log(`usage:
  driver.mjs shot <url> [--out f.png] [--w 1440] [--h 900] [--wait 4000] [--full]
  driver.mjs inspect <url>
  driver.mjs audit <path/to/File.tsx>`);
  process.exit(1);
}
