/**
 * QA agent — a deterministic HTTP crawler that tests the live static site (links,
 * images, forms, API, SEO/meta, a11y), stores findings via the qa-*.php CLI, and
 * a thin LLM step writes a summary. Read-only against the site. Auto-fixes safe
 * findings by kicking the Web Designer queue (host.drainTasks). Owns: /qa/run +
 * an in-process heartbeat.
 */

import { writeFileSync, rmSync } from "node:fs";

let H;

const QA_BASE = "https://carlosarias.co";
const QA_UA = "Mozilla/5.0 (compatible; CarlosAriasQA/1.0; +https://carlosarias.co)";
const QA_STATIC_ROUTES = [
  "/", "/about", "/services", "/services/website-design",
  "/projects", "/blog", "/contact", "/changelog", "/resume", "/faq",
];
const QA_AUTOFIX = (process.env.QA_AUTOFIX ?? "1") !== "0";
const QA_HEARTBEAT_MIN = Number(process.env.QA_HEARTBEAT_MIN || 60);
let qaRunning = false;

async function probe(url, { method = "GET", readBody = false, timeout = 12000, headers = {}, body = null } = {}) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), timeout);
  try {
    const r = await fetch(url, { method, redirect: "follow", signal: ctrl.signal, headers: { "user-agent": QA_UA, ...headers }, body });
    let text = null;
    if (readBody) text = await r.text();
    else { try { await r.body?.cancel(); } catch {} }
    return { status: r.status, ok: r.ok, finalUrl: r.url, text };
  } catch (e) {
    return { status: 0, ok: false, error: e.name === "AbortError" ? "timeout" : (e.message || "network error") };
  } finally { clearTimeout(t); }
}

async function pMap(items, concurrency, fn) {
  const out = []; let i = 0;
  const workers = Array.from({ length: Math.min(concurrency, items.length || 1) }, async () => {
    while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); }
  });
  await Promise.all(workers);
  return out;
}

const qaNorm = (p) => { p = String(p).split("#")[0].split("?")[0]; if (p.length > 1) p = p.replace(/\/+$/, ""); return p || "/"; };
const qaAbs = (href, pagePath) => { try { return new URL(href, QA_BASE + pagePath).href; } catch { return null; } };
const qaGrabAll = (re, html) => [...String(html).matchAll(re)].map((m) => m[1]);
const qaFirst = (re, html) => { const m = String(html).match(re); return m ? m[1].trim() : ""; };
function qaAltIssues(html) {
  const out = [];
  for (const m of String(html).matchAll(/<img\b[^>]*>/gi)) {
    const tag = m[0];
    const src = (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1];
    if (!src) continue;
    const withVal = tag.match(/\salt\s*=\s*["']([^"']*)["']/i);
    if (withVal) { if (withVal[1].trim() === "") out.push({ src, kind: "empty" }); }
    else if (/\salt(\s|>|\/)/i.test(tag)) out.push({ src, kind: "empty" });
    else out.push({ src, kind: "missing" });
  }
  return out;
}

async function runQa() {
  const findings = [];
  const add = (check_type, severity, url, detail, fix_hint = null) => findings.push({ check_type, severity, url, detail, fix_hint });

  let dynamic = [];
  try { dynamic = JSON.parse((await H.phpCli(["api/cli/qa-routes.php"])).out || "[]"); } catch {}
  const routes = [...new Set([...QA_STATIC_ROUTES, ...dynamic].map(qaNorm))];

  const pages = await pMap(routes, 6, async (path) => ({ path, r: await probe(QA_BASE + path, { readBody: true }) }));

  const fetched = new Map();
  const titles = new Map();
  const internal = new Set();
  const images = new Set();
  const external = new Map();

  for (const { path, r } of pages) {
    fetched.set(path, r.ok);
    if (!r.ok) {
      add("page", "error", path, `Returns ${r.status || r.error} instead of 200.`,
        `${path} returns ${r.status || r.error} instead of 200. Investigate why the page fails to render and fix it.`);
      continue;
    }
    const html = r.text || "";
    const title = qaFirst(/<title[^>]*>([^<]*)<\/title>/i, html);
    const desc = qaFirst(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i, html);
    const canon = qaFirst(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i, html);
    if (!title) add("seo", "warning", path, "Missing <title>.", `${path} has no <title>. Add a page-specific title via its BaseLayout props.`);
    else { if (!titles.has(title)) titles.set(title, []); titles.get(title).push(path); }
    if (!desc) add("seo", "warning", path, "Missing meta description.", `${path} has no meta description. Add a page-specific description via its BaseLayout props.`);
    if (canon) { try { const h = new URL(canon).host; if (h && h !== "carlosarias.co") add("seo", "error", path, `Canonical points to ${h}.`, `${path} canonical points to ${h} instead of carlosarias.co. Fix the site URL / canonical.`); } catch {} }
    if (!/<html[^>]+lang=/i.test(html)) add("a11y", "warning", path, "<html> has no lang attribute.", `${path} <html> tag has no lang attribute. Add lang="en".`);

    for (const href of qaGrabAll(/<a\b[^>]*\bhref=["']([^"']+)["']/gi, html)) {
      if (/^(mailto:|tel:|javascript:|#|data:)/i.test(href)) continue;
      const u = qaAbs(href, path); if (!u) continue;
      const noHash = u.split("#")[0];
      if (noHash.startsWith(QA_BASE)) internal.add(noHash);
      else if (/^https?:\/\//i.test(noHash) && !external.has(noHash)) external.set(noHash, path);
    }
    for (const src of qaGrabAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi, html)) { const u = qaAbs(src, path); if (u && /^https?:/i.test(u)) images.add(u.split("#")[0]); }
    for (const a of qaAltIssues(html)) {
      if (a.kind === "empty") add("a11y", "warning", path, `Empty alt: ${a.src}`,
        `On ${path}, the image "${a.src}" has an empty alt attribute. If it conveys meaning (a photo, cover, or screenshot), add descriptive alt text that explains what it shows; leave it empty only if it is purely decorative.`);
      else add("a11y", "warning", path, `Missing alt: ${a.src}`,
        `On ${path}, the image "${a.src}" has no alt attribute. Add descriptive alt text that explains what it shows.`);
    }
  }

  for (const [title, paths] of titles) {
    if (paths.length > 1) add("seo", "warning", paths.join(", "), `${paths.length} pages share the title "${title}".`,
      `These pages share one <title> ("${title}"): ${paths.join(", ")}. Give each a distinct, page-specific title.`);
  }

  const internalPaths = [...new Set([...internal].map((u) => qaNorm(u.replace(QA_BASE, "") || "/")))];
  const toCheck = internalPaths.filter((p) => !fetched.has(p));
  await pMap(toCheck, 8, async (p) => {
    const r = await probe(QA_BASE + p, {});
    if (!r.ok) add("link", "error", p, `Broken internal link (${r.status || r.error}).`,
      `An internal link points to ${p}, which returns ${r.status || r.error}. Find that link in the page source and fix the URL or remove the link.`);
  });

  await pMap([...images], 8, async (u) => {
    const r = await probe(u, {});
    if (!r.ok) add("image", "error", u, `Image returns ${r.status || r.error}.`,
      `The image ${u} returns ${r.status || r.error}. Fix the image path or replace the image.`);
  });

  await pMap([...external.keys()], 6, async (u) => {
    const r = await probe(u, { method: "GET", timeout: 12000 });
    const clearlyBad = r.status === 404 || r.status === 410 || (r.status === 0 && r.error && r.error !== "timeout");
    if (clearlyBad) add("external", "warning", u, `External link may be broken (${r.status || r.error}); found on ${external.get(u)}.`,
      `The external link ${u} (on ${external.get(u)}) appears broken (${r.status || r.error}). Verify it and update or remove it.`);
  });

  const cHeaders = { "content-type": "application/json", origin: QA_BASE, referer: QA_BASE + "/contact" };
  const hp = await probe(QA_BASE + "/api/contact/submit", {
    method: "POST", readBody: true, headers: cHeaders,
    body: JSON.stringify({ name: "QA Bot", email: "qa@carlosarias.co", subject: "other", message: "QA honeypot probe — please ignore.", company: "qa-honeypot" }),
  });
  if (hp.status !== 200) add("form", "error", "/api/contact/submit", `Contact honeypot probe returned ${hp.status || hp.error} (expected 200).`,
    `POST /api/contact/submit returned ${hp.status || hp.error} instead of 200 for a probe. The contact form endpoint may be broken — check api/public/controllers/contact.php.`);
  const val = await probe(QA_BASE + "/api/contact/submit", {
    method: "POST", readBody: true, headers: cHeaders,
    body: JSON.stringify({ name: "QA", email: "qa@carlosarias.co", subject: "other", message: "hi" }),
  });
  if (val.status !== 422) add("form", "warning", "/api/contact/submit", `Validation probe returned ${val.status || val.error} (expected 422 for a too-short message).`,
    `POST /api/contact/submit did not reject an invalid submission (got ${val.status || val.error}, expected 422). Server-side validation may be off.`);

  const health = await probe(QA_BASE + "/api/health", { readBody: true });
  let dbOk = false; try { const j = JSON.parse(health.text || "{}"); dbOk = (j.data?.db ?? j.db) === "connected"; } catch {}
  if (health.status !== 200 || !dbOk) add("health", "error", "/api/health", `Status ${health.status || health.error}, db ${dbOk ? "connected" : "not connected"}.`,
    `/api/health returned ${health.status || health.error}${dbOk ? "" : " and the database is not connected"}. The API or database may be down.`);

  const sm = await probe(QA_BASE + "/sitemap.xml", { readBody: true });
  if (sm.ok) {
    const locs = new Set(qaGrabAll(/<loc>([^<]+)<\/loc>/gi, sm.text || "").map((l) => qaNorm(l.replace(QA_BASE, ""))));
    for (const p of routes) if (!locs.has(p)) add("sitemap", "warning", p, "Not listed in sitemap.xml.",
      `${p} is not in sitemap.xml. Add it in app/src/pages/sitemap.xml.js so search engines can find it.`);
  } else {
    add("sitemap", "warning", "/sitemap.xml", `sitemap.xml returned ${sm.status || sm.error}.`, `/sitemap.xml is unreachable (${sm.status || sm.error}). Check app/src/pages/sitemap.xml.js.`);
  }

  const counts = { error: 0, warning: 0, info: 0, pages: pages.length, links: toCheck.length, images: images.size, external: external.size };
  for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
  return { findings, counts };
}

async function qaTriage(findings, counts) {
  const top = [
    ...findings.filter((f) => f.severity === "error").slice(0, 12),
    ...findings.filter((f) => f.severity === "warning").slice(0, 12),
  ];
  const lines = top.map((f) => `- [${f.severity}] ${f.check_type} ${f.url}: ${f.detail}`).join("\n") || "(no issues found)";
  const fallback = `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`;
  const ask = [
    "You are the QA agent for carlosarias.co. A crawler just tested the live site. Consult your `qa` skill.",
    `Counts: ${counts.error} errors, ${counts.warning} warnings across ${counts.pages} pages.`,
    "Top findings:", lines,
    "",
    "Write a 2-4 sentence plain-English summary for the site owner: overall health, the most important things to fix first, and whether anything is urgent. No preamble — just the summary.",
  ].join("\n");
  const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "Read Skill" });
  const s = ok && typeof result === "string" ? result.trim().slice(0, 800) : "";
  return s || fallback;
}

async function runQaFlow(trigger) {
  if (qaRunning) return;
  qaRunning = true;
  let runId = 0;
  try {
    const start = await H.phpCli(["api/cli/qa-start.php", `--trigger=${trigger}`]);
    runId = JSON.parse(start.out || "{}").run_id || 0;
    if (!runId) throw new Error("could not open a QA run");
    const { findings, counts } = await runQa();
    const summary = (trigger === "manual" || counts.error > 0)
      ? await qaTriage(findings, counts)
      : `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`;
    const tmp = `/tmp/qa-${runId}.json`;
    writeFileSync(tmp, JSON.stringify(findings));
    await H.phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]);
    rmSync(tmp, { force: true });

    if (QA_AUTOFIX) {
      const af = await H.phpCli(["api/cli/qa-autofix.php", `--run=${runId}`]);
      let queued = 0; try { queued = JSON.parse(af.out || "{}").queued || 0; } catch {}
      if (queued > 0 && H.drainTasks) H.drainTasks(); // fire-and-forget
    }
  } catch (e) {
    if (runId) await H.phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=failed", `--summary=QA run failed: ${String((e && e.message) || e).slice(0, 180)}`]);
  } finally {
    qaRunning = false;
  }
}

export function register(host) {
  H = host;

  host.route("POST", "/qa/run", async (req, res) => {
    if (qaRunning) return H.json(res, 429, { error: "A QA run is already in progress." });
    const body = await H.readBody(req);
    const trigger = body.trigger === "scheduled" ? "scheduled" : "manual";
    runQaFlow(trigger); // fire-and-forget
    H.json(res, 200, { ok: true });
  });

  if (QA_HEARTBEAT_MIN > 0) {
    host.everyMinutes(QA_HEARTBEAT_MIN, () => runQaFlow("scheduled"), { bootDelayMs: 90_000 });
    console.log(`[qa] heartbeat every ${QA_HEARTBEAT_MIN} min`);
  }
}
