#!/usr/bin/env node
/**
 * AstroAgent console runner.
 *
 * Runs AS the confined `carlos-arias-agent` user (systemd User=), binds
 * 127.0.0.1 only, and is reached exclusively through nginx, which gates every
 * request with auth_request against the PHP admin session. If a request lands
 * here, the operator is already an authenticated admin — this process does not
 * re-check that; its job is orchestration.
 *
 * Contract (matches app/src/components/DevConsole.astro):
 *   GET  /devconsole/ping                         -> { authed, hasToken }
 *   POST /devconsole/run     {message,page,...}   -> { conversationId }   (async)
 *   GET  /devconsole/stream?conversationId=...    -> SSE: text|tool|preview|published|error|done
 *   POST /devconsole/publish {conversationId}     -> promotes preview, git commit
 *   POST /devconsole/discard {conversationId}     -> reverts working tree
 *   POST /devconsole/auth    {token}              -> update the Claude token
 *   POST /devconsole/logout                       -> (admin logout is PHP; no-op here)
 *
 * Safety model:
 *   - The AGENT only edits files (tools: Read/Write/Edit/Glob/Grep/WebSearch,
 *     NO Bash). This runner — not the agent — runs every build and git op.
 *   - Every /run edits the working tree, then builds an ISOLATED preview to
 *     public-preview/<jobId>. Nothing is live.
 *   - /publish builds to public/, verifies the build passed, then git-commits
 *     (a rollback point). /discard does `git checkout -- .`.
 *   - One job at a time (single admin); a lock serialises run/publish/discard.
 *
 * Node built-ins only — no dependencies.
 */

import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";

const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, "..", ".."); // /www/wwwroot/CarlosAriasPersonal
const APP = join(REPO, "app");
const PREVIEW_DIR = join(REPO, "public-preview");
const ENV_FILE = join(REPO, "agents", ".env");
const CLAUDE = "/usr/local/bin/claude";
const PHP = "/usr/bin/php";
const PORT = Number(process.env.ADMIN_PORT || 3011);
const AGENT_TOOLS = "Read Write Edit Glob Grep WebSearch";
const DEFAULT_MODEL = "claude-sonnet-4-6";

// ---- env (.env) --------------------------------------------------------------
function loadEnv() {
  const out = {};
  if (existsSync(ENV_FILE)) {
    for (const line of readFileSync(ENV_FILE, "utf8").split("\n")) {
      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
      if (m) out[m[1]] = m[2];
    }
  }
  return out;
}
function saveToken(token) {
  let s = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, "utf8") : "";
  if (/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m.test(s)) {
    s = s.replace(/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m, `CLAUDE_CODE_OAUTH_TOKEN=${token}`);
  } else {
    s += `\nCLAUDE_CODE_OAUTH_TOKEN=${token}\n`;
  }
  writeFileSync(ENV_FILE, s, { mode: 0o600 });
}

// ---- job state ---------------------------------------------------------------
/** conversationId -> job */
const jobs = new Map();
let busy = false; // single-flight lock

function newJob(page) {
  const conversationId = randomUUID();
  const job = {
    conversationId,
    jobId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
    sessionId: null, // claude session, for --resume follow-ups
    page: page || "/",
    events: [], // buffered for late SSE subscribers
    clients: new Set(), // active SSE responses
    done: false,
  };
  jobs.set(conversationId, job);
  return job;
}
function emit(job, ev) {
  job.events.push(ev);
  const line = `data: ${JSON.stringify(ev)}\n\n`;
  for (const res of job.clients) {
    try { res.write(line); } catch {}
  }
}

// ---- prompt wrapper ----------------------------------------------------------
function buildPrompt({ message, page, selections }) {
  const parts = [];
  parts.push(
    "You are the editing agent for the Carlos Arias website (an Astro + Tailwind v4 static site).",
    "Make ONLY the change the operator asks for. Keep everything on-brand:",
    "the brand guide is brand/BRAND.md and the design tokens are in app/src/styles.css",
    "(sumi-e — ink on washi paper, one vermillion seal, restraint, near-square radii).",
    "Do NOT run builds or git commands — the console builds and publishes for you.",
    "",
  );
  if (page && page !== "/") {
    parts.push(`The operator is on the page: ${page}`);
    parts.push(
      "Its source is almost certainly under app/src/pages (find it with Glob/Grep).",
      "",
    );
  } else if (page === "/") {
    parts.push("The operator is on the homepage (app/src/pages/index.astro).", "");
  }
  if (Array.isArray(selections) && selections.length) {
    parts.push("They selected these element(s) on the page:");
    for (const s of selections) {
      const tag = s.tag || s.selector || "element";
      parts.push(`- <${tag}>${s.text ? ` — "${String(s.text).slice(0, 80)}"` : ""}${s.comment ? ` — note: ${s.comment}` : ""}`);
    }
    parts.push("");
  }
  parts.push("Request:", message);
  return parts.join("\n");
}

// ---- run the agent, then build a preview -------------------------------------
function agentEnv() {
  const env = loadEnv();
  return {
    ...process.env,
    HOME: process.env.HOME || "/var/lib/carlos-arias-agent",
    PATH: "/usr/local/bin:/usr/bin:/bin",
    CLAUDE_CODE_OAUTH_TOKEN: env.CLAUDE_CODE_OAUTH_TOKEN || "",
  };
}

function runAgent(job, { message, page, selections, model }) {
  const prompt = buildPrompt({ message, page, selections });
  const args = [
    "-p", prompt,
    "--output-format", "stream-json",
    "--verbose",
    "--allowedTools", AGENT_TOOLS,
    "--model", model || DEFAULT_MODEL,
  ];
  if (job.sessionId) args.push("--resume", job.sessionId);

  const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() });

  let buf = "";
  child.stdout.on("data", (chunk) => {
    buf += chunk.toString();
    let nl;
    while ((nl = buf.indexOf("\n")) >= 0) {
      const line = buf.slice(0, nl).trim();
      buf = buf.slice(nl + 1);
      if (!line) continue;
      let msg;
      try { msg = JSON.parse(line); } catch { continue; }
      handleStreamMsg(job, msg);
    }
  });
  child.stderr.on("data", (d) => {
    const t = d.toString().trim();
    if (t) emit(job, { kind: "tool", text: t.slice(0, 200) });
  });
  child.on("close", async (code) => {
    if (code !== 0) {
      emit(job, { type: "error", text: "The agent stopped unexpectedly. Nothing was changed." });
      emit(job, { type: "done" });
      job.done = true;
      busy = false;
      return;
    }
    await buildPreview(job);
    busy = false;
  });
  child.on("error", (err) => {
    emit(job, { type: "error", text: `Could not start the agent: ${err.message}` });
    emit(job, { type: "done" });
    job.done = true;
    busy = false;
  });
}

function handleStreamMsg(job, msg) {
  // capture the resumable session id
  if (msg.session_id) job.sessionId = msg.session_id;
  const content = msg?.message?.content;
  if (Array.isArray(content)) {
    for (const block of content) {
      if (block.type === "text" && block.text) emit(job, { kind: "text", text: block.text });
      else if (block.type === "tool_use") {
        const label = block.name === "Edit" || block.name === "Write"
          ? `${block.name} ${shortPath(block.input?.file_path)}`
          : block.name;
        emit(job, { kind: "tool", text: label });
      }
    }
  }
  if (msg.type === "result" && typeof msg.result === "string" && msg.result.trim()) {
    emit(job, { kind: "text", text: msg.result.trim() });
  }
}
function shortPath(p) {
  if (!p) return "";
  return String(p).replace(REPO + "/", "");
}

// ---- builds ------------------------------------------------------------------
function build(extraEnv) {
  return new Promise((res) => {
    const child = spawn("npm", ["run", "build"], {
      cwd: APP,
      env: { ...agentEnv(), ...extraEnv },
    });
    let tail = "";
    const grab = (d) => { tail = (tail + d.toString()).slice(-4000); };
    child.stdout.on("data", grab);
    child.stderr.on("data", grab);
    child.on("close", (code) => res({ ok: code === 0, tail }));
    child.on("error", () => res({ ok: false, tail: "build failed to start" }));
  });
}

async function buildPreview(job) {
  emit(job, { kind: "tool", text: "building preview…" });
  const out = join("..", "public-preview", job.jobId);
  const base = `/_preview/${job.jobId}`;
  const { ok, tail } = await build({ PREVIEW_OUT: out, PREVIEW_BASE: base });
  if (!ok) {
    emit(job, { type: "error", text: "The change broke the build, so it was not applied. Try rephrasing." });
    // revert the agent's edits so the working tree stays clean
    await git(["checkout", "--", "app", "brand", "api/db", "api/cli"]);
    emit(job, { type: "done" });
    job.done = true;
    return;
  }
  const target = job.page && job.page !== "/" ? job.page.replace(/^\//, "") : "";
  emit(job, { type: "preview", url: `${base}/${target}` });
  emit(job, { type: "done" });
  job.done = true;
}

// ---- git ---------------------------------------------------------------------
function git(args) {
  return new Promise((res) => {
    const child = spawn("git", args, { cwd: REPO, env: agentEnv() });
    let tail = "";
    child.stdout.on("data", (d) => (tail += d));
    child.stderr.on("data", (d) => (tail += d));
    child.on("close", (code) => res({ ok: code === 0, tail: tail.toString() }));
    child.on("error", () => res({ ok: false, tail: "" }));
  });
}

// ---- one-shot copy drafting --------------------------------------------------
// Turn a rough draft + prompt into polished project copy. No tools, no file
// writes, no preview — just returns text for the admin to review before saving.
function stripFence(t) {
  const m = String(t).match(/```(?:json)?\s*([\s\S]*?)```/);
  return (m ? m[1] : t).trim();
}
function draftCopy({ name, category, draft, prompt }) {
  const ask = [
    "You are writing copy for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.",
    "Voice: precise, understated, confident. No hype, no buzzwords, no exclamation marks. Write a project case study.",
    "",
    `Project name: ${name}`,
    category ? `Category: ${category}` : "",
    draft ? `The author's rough draft / notes:\n${draft}` : "",
    prompt ? `The author's instructions:\n${prompt}` : "",
    "",
    "Return ONLY a JSON object (no markdown fence, no commentary) with these string keys:",
    '- "summary": one sentence (<=160 chars) for the projects list.',
    '- "lede": one punchy opening line for the top of the project page.',
    '- "body": the case study in Markdown. Use ## for section headings and - for bullets. Open with a short overview, and where it fits include a "## Highlights" bulleted section. 150-350 words.',
  ].filter(Boolean).join("\n");

  return new Promise((resolve) => {
    const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", ""];
    const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() });
    let out = "";
    child.stdout.on("data", (d) => (out += d));
    child.on("close", () => {
      try {
        const envelope = JSON.parse(out);
        const text = typeof envelope.result === "string" ? envelope.result : "";
        const obj = JSON.parse(stripFence(text));
        resolve({ ok: true, summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "" });
      } catch { resolve({ ok: false }); }
    });
    child.on("error", () => resolve({ ok: false }));
  });
}

// ---- Web Designer: build a full structured project from a brief --------------
// Reads the uploaded images and authors a COMPLETE cja_projects record (copy +
// skills + metrics + captions). Read-only (no Write/Edit/Bash) — it returns
// structured data the server validates; it never mutates the repo.
const MEDIA_RE = /^\/media\/[\w.-]+$/;
const MAX_BUILD_IMAGES = 8;

function buildProject({ name, category, draft, prompt, images, instagram }) {
  // keep only safe /media/ srcs, map to on-disk paths the agent can Read (cwd = REPO)
  const srcs = (Array.isArray(images) ? images : [])
    .map((im) => String(im?.src || ""))
    .filter((s) => MEDIA_RE.test(s));
  const useSrcs = srcs.slice(0, MAX_BUILD_IMAGES);
  if (srcs.length > MAX_BUILD_IMAGES) {
    console.log(`[build-project] capping images ${srcs.length} -> ${MAX_BUILD_IMAGES} (${srcs.length - MAX_BUILD_IMAGES} not captioned)`);
  }
  const paths = useSrcs.map((s) => "app/public" + s); // /media/x -> app/public/media/x
  const imageLines = paths.length ? paths.map((p, i) => `  ${i + 1}. ${p}`).join("\n") : "";

  const ask = [
    "You are the Web Designer for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.",
    "Your job: turn the brief below into a COMPLETE project case study for a fixed, on-brand page template. You author STRUCTURED CONTENT, not HTML or layout.",
    "",
    "Brand (from brand/BRAND.md — honor it): sumi-e, ink on washi paper, a single vermillion seal, restraint. Voice is precise, understated, confident — no hype, no buzzwords, no exclamation marks. Never describe colours or layout; the template owns all styling.",
    "",
    "The page auto-renders these sections from the fields you return — populate the ones the brief supports, leave the rest empty:",
    "- header: title + one-line lede",
    "- facts rail: kind (project type), period (timeline label), role",
    "- metrics: a few outcome stats, each {value, label, note?}",
    "- gallery: your caption for each image below",
    "- body: the case study",
    "- skills: grouped disciplines, each {group, items[]}",
    "- stack: technologies used, as plain strings",
    "- categories: 1-3 short tags",
    "",
    `Project name: ${name}`,
    category ? `Category hint: ${category}` : "",
    instagram ? "There is an Instagram reel for this project." : "",
    draft ? `The author's rough draft / notes:\n${draft}` : "",
    prompt ? `The author's special instructions (follow these):\n${prompt}` : "",
    "",
    paths.length
      ? `Uploaded images — READ each file and caption it from what is ACTUALLY shown. Keep captions short and specific; do not invent UI or content that isn't visible:\n${imageLines}`
      : "No images were uploaded.",
    "",
    "Body rules: Markdown only, using ONLY ## / ### headings, paragraphs, - bullet lists, and **bold**. No images, no HTML, no tables. Open with a short overview; where it fits include a '## Highlights' bulleted section. 150-350 words.",
    "",
    "Return ONLY a JSON object (no markdown fence, no commentary) with these keys:",
    '- "summary": one sentence (<=160 chars) for the projects list.',
    '- "lede": one punchy opening line.',
    '- "body": the Markdown case study.',
    '- "kind": short project type, e.g. "Website" or "SaaS / Media" (or "").',
    '- "period": a timeline label, e.g. "2024 — present" (or "").',
    '- "role": Carlos\'s role on the project (or "").',
    '- "categories": array of 1-3 short strings.',
    '- "stack": array of technology strings.',
    '- "skills": array of {"group": string, "items": [string, ...]}.',
    '- "metrics": array of {"value": string, "label": string, "note"?: string}. Use an empty array if the brief has no real numbers — do NOT invent metrics.',
    `- "captions": array of exactly ${paths.length} strings, one per uploaded image IN ORDER.`,
    `- "alts": array of exactly ${paths.length} short alt-text strings, one per image IN ORDER.`,
  ].filter(Boolean).join("\n");

  return new Promise((resolve) => {
    const args = ["-p", ask, "--output-format", "json", "--model", DEFAULT_MODEL, "--allowedTools", "Read"];
    const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() });
    let out = "";
    child.stdout.on("data", (d) => (out += d));
    child.on("close", () => {
      try {
        const envelope = JSON.parse(out);
        const text = typeof envelope.result === "string" ? envelope.result : "";
        const obj = JSON.parse(stripFence(text));
        // Re-zip the gallery server-side: trust OUR srcs, take the model's
        // caption/alt by index. The model never dictates a file path.
        const caps = Array.isArray(obj.captions) ? obj.captions : [];
        const alts = Array.isArray(obj.alts) ? obj.alts : [];
        const gallery = useSrcs.map((src, i) => ({
          src,
          alt: String(alts[i] || "").slice(0, 200),
          caption: String(caps[i] || "").slice(0, 120),
        }));
        const strArr = (a) => (Array.isArray(a) ? a.map((x) => String(x)).filter(Boolean) : []);
        resolve({
          ok: true,
          summary: obj.summary || "",
          lede: obj.lede || "",
          body: obj.body || "",
          kind: obj.kind || "",
          period: obj.period || "",
          role: obj.role || "",
          categories: strArr(obj.categories),
          stack: strArr(obj.stack),
          skills: Array.isArray(obj.skills) ? obj.skills : [],
          metrics: Array.isArray(obj.metrics) ? obj.metrics : [],
          gallery,
        });
      } catch { resolve({ ok: false }); }
    });
    child.on("error", () => resolve({ ok: false }));
  });
}

// ---- Web Designer task queue -------------------------------------------------
// A durable design queue (cja_tasks) drained one task at a time. Each task runs
// the full-builder agent on any page (skills + brief), then auto-publishes:
// build must pass, then a scoped git commit. A failing task reverts itself and
// the queue moves on. The runner has no DB driver, so it claims/finishes rows
// via the api/cli/tasks-*.php helpers.
const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"];
let draining = false;

function phpCli(args) {
  return new Promise((resolve) => {
    const child = spawn(PHP, args, { cwd: REPO, env: agentEnv() });
    let out = "", err = "";
    child.stdout.on("data", (d) => (out += d));
    child.stderr.on("data", (d) => (err += d));
    child.on("close", () => resolve({ out, err }));
    child.on("error", () => resolve({ out: "", err: "php spawn failed" }));
  });
}

async function claimNextTask() {
  const { out } = await phpCli(["api/cli/tasks-next.php"]);
  try { const t = JSON.parse((out || "{}").trim() || "{}"); return t && t.task_id ? t : null; }
  catch { return null; }
}
async function finishTask(id, status, result) {
  await phpCli(["api/cli/tasks-finish.php", `--id=${id}`, `--status=${status}`, `--result=${JSON.stringify(result)}`]);
}

function liveUrl(target) {
  if (!target) return "/";
  if (target.startsWith("new:")) return "/" + target.slice(4).replace(/^\/+/, "");
  return target;
}

function nowStamp() {
  const d = new Date();
  const p = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}

function buildDesignPrompt(task) {
  let assets = { images: [], videos: [] };
  try { assets = JSON.parse(task.assets || "{}") || {}; } catch {}
  const images = Array.isArray(assets.images) ? assets.images : [];
  const videos = Array.isArray(assets.videos) ? assets.videos : [];
  const target = task.target_page || "/";
  const isNew = target.startsWith("new:");
  const slug = isNew ? target.slice(4).replace(/^\/+/, "") : "";

  const p = [];
  p.push(
    "You are the Web Designer for the Carlos Arias website (carlosarias.co) — an Astro + Tailwind v4 static site.",
    "You are a real designer: make considered, on-brand design decisions, not just literal edits.",
    "",
    "Before you start, consult your design skills and APPLY them: read .claude/skills/brand/SKILL.md and .claude/skills/ui-ux/SKILL.md.",
    "The brand is sumi-e — ink on washi paper, a single vermillion seal, restraint, near-square 2px radii. Reuse the existing .ca-* classes and --ca-* tokens in app/src/styles.css and match nearby components; do not invent new one-off styles or add a second accent colour.",
    "Do NOT run builds or git — the console builds and publishes for you.",
    "",
  );
  if (isNew) {
    p.push(
      `TASK: create a NEW page at /${slug}.`,
      `- Create app/src/pages/${slug}.astro using BaseLayout and existing .ca-* section patterns (study app/src/pages/about.astro and services.astro for structure).`,
      "- Register it in the nav: add it to the nav array in app/src/components/Header.astro AND the footer links in app/src/components/Footer.astro.",
      "",
    );
  } else {
    p.push(
      `TASK: work on the existing page ${target}.`,
      "- Find its source under app/src/pages (Glob/Grep). Edit that file and any components it uses.",
      "",
    );
  }
  p.push("What to do:", task.prompt || "(no instructions given)", "");
  if (task.draft && String(task.draft).trim()) {
    p.push("Draft content to work from (polish it, don't paste it verbatim):", task.draft, "");
  }
  if (images.length) {
    p.push("Images you may use — Read each to see what it shows, then place it with its /media/... src and a real alt:");
    for (const im of images) {
      p.push(`- ${im.url}  (on disk: app/public${im.url})${im.alt ? ` — hint: ${im.alt}` : ""}`);
    }
    p.push("");
  }
  if (videos.length) {
    p.push("Short video links to embed where they fit (e.g. an Instagram reel — responsive 9:16, no autoplay sound):");
    for (const v of videos) p.push(`- ${v}`);
    p.push("");
  }
  p.push(
    "",
    "After you finish the change, log it to the public changelog using your `changelog` skill:",
    "- Add ONE entry to the $entries array in api/cli/seed-changelog.php, in the site's visitor-facing voice.",
    `- Use the timestamp '${nowStamp()}' and attribute it to 'Website Designer Agent' (the 5th array element).`,
    "- Choose the right type (added / updated / fixed / removed).",
    "- Do NOT run the reseed or the build — the console does that for you.",
    "- If you ended up making no change to the site, do not add a changelog entry.",
    "",
    "Keep the change scoped to what's asked and leave the working tree with only your intended edits.",
  );
  return p.join("\n");
}

async function runDesignTask(task) {
  const prompt = buildDesignPrompt(task);

  // Pre-flight: the auto-publish commit is scoped to GIT_SCOPE, so a dirty tree
  // would get swept into this task's commit. Refuse rather than clobber WIP.
  // Uploaded assets under app/public/media/ are expected (they're this task's
  // images) and get committed with it, so they don't count as "dirty".
  const pre = await git(["status", "--porcelain", "--", ...GIT_SCOPE]);
  const dirty = pre.tail
    .split("\n")
    .map((l) => l.slice(3).trim()) // strip the "XY " status prefix
    .filter(Boolean)
    .filter((p) => !p.startsWith("app/public/media/"));
  if (dirty.length) {
    return { ok: false, error: "Working tree wasn't clean — commit or discard pending changes before running the queue." };
  }

  return new Promise((resolve) => {
    const args = [
      "-p", prompt,
      "--output-format", "json",
      "--allowedTools", "Read Write Edit Glob Grep WebSearch Skill",
      "--model", DEFAULT_MODEL,
    ];
    const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() });
    let out = "";
    child.stdout.on("data", (d) => (out += d));
    child.on("close", async () => {
      let summary = "";
      try {
        const env = JSON.parse(out);
        if (typeof env.result === "string") summary = env.result.trim().slice(0, 500);
      } catch {}

      // Any source changes to publish?
      const status = await git(["status", "--porcelain", "--", ...GIT_SCOPE]);
      const changed = status.tail.trim() !== "";
      if (!changed) {
        return resolve({ ok: true, commit: "", summary: summary || "No changes were needed.", live: liveUrl(task.target_page) });
      }

      // The agent logs to the changelog by editing api/cli/seed-changelog.php
      // (it has no shell). If it did, reseed cja_changelog so the build picks up
      // the new entry — the changelog page reads the DB at build time.
      if (/seed-changelog\.php/.test(status.tail)) {
        await phpCli(["api/cli/seed-changelog.php"]);
      }

      // Build must pass before anything is committed.
      const built = await build({});
      if (!built.ok) {
        await git(["checkout", "--", ...GIT_SCOPE]);
        await git(["clean", "-fd", ...GIT_SCOPE]); // drop any new files the agent added
        return resolve({ ok: false, error: "The change broke the build, so it was reverted." });
      }
      await git(["add", "--", ...GIT_SCOPE]);
      const title = (task.title || "task").toString().slice(0, 120);
      await git(["commit", "-q", "-m", `web designer: ${title}\n\n[published via task queue]`]);
      const head = await git(["rev-parse", "--short", "HEAD"]);
      resolve({ ok: true, commit: head.tail.trim(), summary, live: liveUrl(task.target_page) });
    });
    child.on("error", () => resolve({ ok: false, error: "Could not start the agent." }));
  });
}

// Drain the queue sequentially. Shares the global `busy` lock so it never runs
// concurrently with the in-page console. Re-entrant-safe via `draining`.
async function drainTasks() {
  if (draining) return;
  draining = true;
  try {
    for (;;) {
      if (busy) break; // in-page console is mid-edit; a later kick resumes the queue
      const task = await claimNextTask();
      if (!task) break;
      busy = true;
      let res;
      try { res = await runDesignTask(task); }
      catch (e) { res = { ok: false, error: String((e && e.message) || e) }; }
      busy = false;
      await finishTask(
        task.task_id,
        res.ok ? "published" : "failed",
        res.ok ? { commit: res.commit, summary: res.summary, live: res.live } : { error: res.error || "failed" },
      );
    }
  } finally {
    draining = false;
  }
}

// ---- QA agent ----------------------------------------------------------------
// A deterministic HTTP crawler tests the live static site (links, images, forms,
// API, SEO/meta), stores findings via the qa-*.php CLI, and a thin LLM step
// writes a plain-English summary. Read-only against the site — it never changes
// anything; fixes only happen when the admin clicks "Create fix task".
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",
];
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 qaImgsNoAlt(html) {
  return [...String(html).matchAll(/<img\b[^>]*>/gi)].map((m) => m[0])
    .filter((tag) => !/\balt\s*=/i.test(tag))
    .map((tag) => (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1])
    .filter(Boolean);
}

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

  // ---- route set: static + DB-driven slugs -------------------------------
  let dynamic = [];
  try { dynamic = JSON.parse((await phpCli(["api/cli/qa-routes.php"])).out || "[]"); } catch {}
  const routes = [...new Set([...QA_STATIC_ROUTES, ...dynamic].map(qaNorm))];

  // ---- fetch every page, parse HTML --------------------------------------
  const pages = await pMap(routes, 6, async (path) => ({ path, r: await probe(QA_BASE + path, { readBody: true }) }));

  const fetched = new Map();               // normPath -> ok
  const titles = new Map();                // title -> [paths]
  const internal = new Set();              // internal target URLs
  const images = new Set();                // image URLs
  const external = new Map();              // external URL -> page it was found on

  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 src of qaImgsNoAlt(html)) add("a11y", "warning", path, `Image without alt: ${src}`, `On ${path}, the image "${src}" has no alt text. Add descriptive alt text.`);
  }

  // duplicate titles across pages
  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.`);
  }

  // ---- internal links not already fetched --------------------------------
  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.`);
  });

  // ---- images ------------------------------------------------------------
  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.`);
  });

  // ---- external links (tolerant: only clear 404/410/DNS failures) ---------
  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.`);
  });

  // ---- contact form: two non-polluting probes ----------------------------
  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.`);

  // ---- API health --------------------------------------------------------
  const health = await probe(QA_BASE + "/api/health", { readBody: true });
  // The API wraps responses in a {ok, data, error} envelope, so db is at data.db.
  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.`);

  // ---- sitemap coverage --------------------------------------------------
  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 };
}

// Thin LLM step: a plain-English run summary via the qa skill (best-effort).
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");
  return new Promise((resolve) => {
    const child = spawn(CLAUDE, ["-p", ask, "--output-format", "json", "--allowedTools", "Read Skill", "--model", DEFAULT_MODEL], { cwd: REPO, env: agentEnv() });
    let out = "";
    child.stdout.on("data", (d) => (out += d));
    child.on("close", () => {
      let s = "";
      try { const e = JSON.parse(out); if (typeof e.result === "string") s = e.result.trim().slice(0, 800); } catch {}
      resolve(s || fallback);
    });
    child.on("error", () => resolve(fallback));
  });
}

async function runQaFlow(trigger) {
  if (qaRunning) return;
  qaRunning = true;
  let runId = 0;
  try {
    const start = await 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 = await qaTriage(findings, counts);
    const tmp = `/tmp/qa-${runId}.json`;
    writeFileSync(tmp, JSON.stringify(findings));
    await phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]);
    rmSync(tmp, { force: true });
  } catch (e) {
    if (runId) await 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;
  }
}

// ---- request helpers ---------------------------------------------------------
function json(res, status, obj) {
  const body = JSON.stringify(obj);
  res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
  res.end(body);
}
function readBody(req) {
  return new Promise((res) => {
    let b = "";
    req.on("data", (c) => (b += c));
    req.on("end", () => {
      try { res(b ? JSON.parse(b) : {}); } catch { res({}); }
    });
  });
}

// ---- server ------------------------------------------------------------------
const server = createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  const path = url.pathname.replace(/^\/devconsole/, "") || "/";
  const method = req.method || "GET";

  try {
    if (path === "/ping") {
      const env = loadEnv();
      return json(res, 200, { authed: true, hasToken: Boolean(env.CLAUDE_CODE_OAUTH_TOKEN) });
    }

    if (path === "/auth" && method === "POST") {
      const { token } = await readBody(req);
      if (!token || !/^sk-ant-/.test(token)) {
        return json(res, 400, { error: "That doesn't look like a Claude token (expected sk-ant-…)." });
      }
      saveToken(token.trim());
      return json(res, 200, { ok: true });
    }

    if (path === "/logout" && method === "POST") {
      return json(res, 200, { ok: true }); // admin session is cleared by PHP
    }

    if (path === "/run" && method === "POST") {
      if (busy) return json(res, 429, { error: "A change is already in progress — let it finish." });
      const body = await readBody(req);
      const message = String(body.message || "").trim();
      if (!message) return json(res, 400, { error: "Say what you'd like changed." });

      const env = loadEnv();
      if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
        return json(res, 401, { error: "No Claude token set. Add one with the 🔑 button." });
      }

      // Continue an existing conversation, or start a new one.
      let job = body.conversationId && jobs.get(body.conversationId);
      if (job) { job.done = false; job.events = []; job.jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; }
      else job = newJob(body.page);

      busy = true;
      runAgent(job, { message, page: body.page, selections: body.selections, model: body.model });
      return json(res, 200, { conversationId: job.conversationId });
    }

    // Draft project copy from a rough note + prompt (used by the New Project form).
    if (path === "/draft" && method === "POST") {
      if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
      const body = await readBody(req);
      const name = String(body.name || "").trim();
      if (!name) return json(res, 400, { error: "Add a project name first." });
      const env = loadEnv();
      if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." });

      busy = true;
      const d = await draftCopy(body);
      busy = false;
      if (!d.ok) return json(res, 500, { error: "Couldn't generate a draft — try again." });
      return json(res, 200, d);
    }

    // Web Designer: build a full structured project from a brief + images.
    if (path === "/build-project" && method === "POST") {
      if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
      const body = await readBody(req);
      const name = String(body.name || "").trim();
      if (!name) return json(res, 400, { error: "Add a project name first." });
      const env = loadEnv();
      if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." });

      busy = true;
      const d = await buildProject(body);
      busy = false;
      if (!d.ok) return json(res, 500, { error: "The Web Designer couldn't finish — try again." });
      return json(res, 200, d);
    }

    // Kick the Web Designer task queue: drain any queued design tasks. Returns
    // immediately; the admin UI polls /api/admintasks/list for status.
    if (path === "/tasks/run" && method === "POST") {
      const env = loadEnv();
      if (!env.CLAUDE_CODE_OAUTH_TOKEN) return json(res, 401, { error: "No Claude token set." });
      drainTasks(); // fire-and-forget
      return json(res, 200, { ok: true, draining, busy });
    }

    // Kick a QA run: crawl the live site, store findings. Read-only; returns
    // immediately. The admin polls /api/adminqa/runs for status.
    if (path === "/qa/run" && method === "POST") {
      if (qaRunning) return json(res, 429, { error: "A QA run is already in progress." });
      const body = await readBody(req);
      const trigger = body.trigger === "scheduled" ? "scheduled" : "manual";
      runQaFlow(trigger); // fire-and-forget
      return json(res, 200, { ok: true });
    }

    if (path === "/stream") {
      const id = url.searchParams.get("conversationId");
      const job = id && jobs.get(id);
      if (!job) return json(res, 404, { error: "unknown conversation" });
      res.writeHead(200, {
        "content-type": "text/event-stream",
        "cache-control": "no-cache",
        connection: "keep-alive",
      });
      // replay buffered, then stream live
      for (const ev of job.events) res.write(`data: ${JSON.stringify(ev)}\n\n`);
      if (job.done) return res.end();
      job.clients.add(res);
      req.on("close", () => job.clients.delete(res));
      return;
    }

    if (path === "/publish" && method === "POST") {
      if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
      const { conversationId } = await readBody(req);
      const job = conversationId && jobs.get(conversationId);
      if (!job) return json(res, 404, { error: "Nothing to publish." });

      busy = true;
      const built = await build({}); // real build to public/
      if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed — not published." }); }
      await git(["add", "--", "app", "brand", "api/db", "api/cli"]);
      const summary = (job.page && job.page !== "/" ? job.page : "homepage");
      await git(["commit", "-q", "-m", `console: edit ${summary}\n\n[published via astroagent console]`]);
      rmSync(join(PREVIEW_DIR, job.jobId), { recursive: true, force: true });
      busy = false;
      return json(res, 200, { ok: true, live: job.page || "/" });
    }

    // Rebuild the live site — used after structured content edits (projects
    // gallery, etc.) that changed the DB rather than files. Builds public/ and
    // commits any new media files (a no-op commit is fine).
    if (path === "/rebuild" && method === "POST") {
      if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
      busy = true;
      const built = await build({});
      if (!built.ok) { busy = false; return json(res, 500, { error: "Build failed." }); }
      await git(["add", "--", "app/public/media", "app", "brand", "api/db", "api/cli"]);
      await git(["commit", "-q", "-m", "console: content update"]); // ok if nothing to commit
      busy = false;
      return json(res, 200, { ok: true });
    }

    if (path === "/discard" && method === "POST") {
      if (busy) return json(res, 429, { error: "Busy — try again in a moment." });
      const { conversationId } = await readBody(req);
      const job = conversationId && jobs.get(conversationId);
      busy = true;
      const CONTENT = ["app", "brand", "api/db", "api/cli"];
      await git(["checkout", "--", ...CONTENT]);
      await git(["clean", "-fd", ...CONTENT]);
      if (job) rmSync(join(PREVIEW_DIR, job.jobId), { recursive: true, force: true });
      busy = false;
      if (job) jobs.delete(job.conversationId);
      return json(res, 200, { ok: true });
    }

    return json(res, 404, { error: "not found" });
  } catch (err) {
    return json(res, 500, { error: err.message });
  }
});

if (!existsSync(PREVIEW_DIR)) mkdirSync(PREVIEW_DIR, { recursive: true });
server.listen(PORT, "127.0.0.1", () => {
  console.log(`[console] runner on 127.0.0.1:${PORT}, repo ${REPO}`);
});
