const { useState, useEffect, useRef } = React;

const LOGO = "https://assets.omniskills.ai/white-bg-96.svg";
const api = (path, opts) =>
  fetch(path, { headers: { "Content-Type": "application/json" }, ...opts }).then((r) => r.json());

// Brand palette
const C = { tii: "#2563eb", green: "#10b981", digital: "#8b5cf6", purple: "#7e22ce",
  slate: "#94a3b8", amber: "#f59e0b", red: "#ef4444" };
const LEVELS = { none: 0, basic: 2, intermediate: 4, advanced: 6 };
const lvl = (s) => LEVELS[String(s || "").trim().toLowerCase()] ?? (parseInt(s, 10) || 0);

// ── Field validators (return "" when valid, else an error string) ──
// Singapore UEN (Unique Entity Number), 3 ACRA formats:
//  • Businesses (ROB):        8 digits + 1 letter          e.g. 53333444A
//  • Local companies (ROC):   4-digit year + 5 digits + 1 letter   e.g. 201912345K
//  • Other entities:          [TSR] + yy + 2 letters + 4 digits + 1 letter  e.g. T08GB0001A
function validateUEN(raw) {
  const s = String(raw || "").trim().toUpperCase();
  if (!s) return "UEN Number is required.";
  const business = /^\d{8}[A-Z]$/;
  const localCo = /^(19|20)\d{7}[A-Z]$/;
  const other = /^[TSR]\d{2}[A-Z]{2}\d{4}[A-Z]$/;
  if (business.test(s) || localCo.test(s) || other.test(s)) return "";
  return "Enter a valid Singapore UEN (e.g. 201912345K, 53333444A or T08GB0001A).";
}
const FIELD_VALIDATORS = { uen: validateUEN };
const fieldError = (f, val) => {
  if (!f || !f.validate || !FIELD_VALIDATORS[f.validate]) return "";
  const has = Array.isArray(val) ? val.length : (val != null && String(val).trim() !== "");
  if (!has && !f.required) return "";
  return FIELD_VALIDATORS[f.validate](val || "");
};

/* ── Audio helpers for realtime voice (PCM16 @ 24kHz) ── */
function downsampleTo24k(buffer, srcRate) {
  const dst = 24000;
  if (srcRate === dst) return buffer;
  const ratio = srcRate / dst;
  const outLen = Math.floor(buffer.length / ratio);
  const out = new Float32Array(outLen);
  for (let i = 0; i < outLen; i++) {
    const start = Math.floor(i * ratio), end = Math.min(Math.floor((i + 1) * ratio), buffer.length);
    let sum = 0, n = 0;
    for (let j = start; j < end; j++) { sum += buffer[j]; n++; }
    out[i] = n ? sum / n : buffer[Math.min(start, buffer.length - 1)] || 0;
  }
  return out;
}
function pcm16ToBase64(float32) {
  const buf = new ArrayBuffer(float32.length * 2);
  const view = new DataView(buf);
  for (let i = 0; i < float32.length; i++) {
    let s = Math.max(-1, Math.min(1, float32[i]));
    view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
  }
  const bytes = new Uint8Array(buf);
  let bin = "";
  for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  return btoa(bin);
}
function base64ToF32(b64) {
  const bin = atob(b64), len = bin.length, bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i);
  const view = new DataView(bytes.buffer), n = Math.floor(len / 2), out = new Float32Array(n);
  for (let i = 0; i < n; i++) out[i] = view.getInt16(i * 2, true) / 0x8000;
  return out;
}

/* ────────────────────────────────────────────────────────────── Top bar */
function TopBar() {
  return (
    <div className="topbar">
      <div className="brand">
        <img src={LOGO} alt="OmniSkills" />
        <span className="divider" />
        <span className="tag">Agentic AI · SBF SDP</span>
      </div>
    </div>
  );
}

/* ─────────────────────────────────────────────────────── Confirm modal */
function ConfirmModal({ open, title, message, confirmLabel = "Confirm", danger, onConfirm, onCancel }) {
  useEffect(() => {
    if (!open) return;
    const h = (e) => { if (e.key === "Escape") onCancel && onCancel(); };
    window.addEventListener("keydown", h);
    return () => window.removeEventListener("keydown", h);
  }, [open]);
  if (!open) return null;
  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div className="modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
        <h3>{title}</h3>
        {message && <p>{message}</p>}
        <div className="modal-actions">
          <button className="btn ghost" onClick={onCancel}>Cancel</button>
          <button className={"btn " + (danger ? "danger-solid" : "primary")} onClick={onConfirm} autoFocus>{confirmLabel}</button>
        </div>
      </div>
    </div>
  );
}

/* ────────────────────────────────────────────────────────── Journey pick */
function JourneyPicker({ onPick, onOpenSaved }) {
  const [recent, setRecent] = useState([]);
  const [email, setEmail] = useState("");
  const [err, setErr] = useState("");
  const [confirmId, setConfirmId] = useState(null);
  useEffect(() => { api("/api/sessions").then((d) => setRecent(d.sessions || [])).catch(() => {}); }, []);
  const findByEmail = () => {
    if (!email.trim()) return;
    setErr("");
    fetch("/api/session-by-email?email=" + encodeURIComponent(email.trim()))
      .then((r) => (r.ok ? r.json() : null))
      .then((s) => { if (s && s.id) onOpenSaved(s.id); else setErr("No saved report found for that email."); })
      .catch(() => setErr("Could not look that up."));
  };
  const del = (id, e) => { e.stopPropagation(); setConfirmId(id); };
  const doDelete = () => {
    const id = confirmId; setConfirmId(null);
    fetch("/api/session/" + encodeURIComponent(id), { method: "DELETE" })
      .then(() => setRecent((r) => r.filter((x) => x.id !== id))).catch(() => {});
  };
  return (
    <div className="page">
      <div className="hero">
        <span className="hero-kicker">SBF Skills Development Partner · TII &amp; Green Skills</span>
        <h1>Agentic AI Digital Advisor</h1>
        <p>Your Workforce Transformation Guide. Pick a journey — the whole consultation, from diagnosis to a funded action plan, happens right here.</p>
      </div>
      <div className="journey-grid">
        <button className="journey-card org" onClick={() => onPick("organisation")}>
          <div className="icon">🏢</div>
          <h3>For Enterprises</h3>
          <p>Company profile, workforce gaps, job-role redesign and funded learning pathways for overseas expansion (TII) and the green transition.</p>
          <div className="go">Start Enterprise Journey →</div>
        </button>
        <button className="journey-card ind" onClick={() => onPick("individual")}>
          <div className="icon">🎯</div>
          <h3>For Individuals</h3>
          <p>Validate your skills, find gaps against your target TII or Green role, and get an upskilling pathway with courses, funding and how-to-apply guidance.</p>
          <div className="go">Start Individual Journey →</div>
        </button>
      </div>
      <div className="cap-row">
        {["Jobs-Skills Insights", "Job Redesign", "Learning Pathways", "SDP Report", "Survey Follow-Up"].map((c) => (
          <span key={c} className="cap">{c}</span>
        ))}
      </div>

      <div className="reopen-panel">
        <div className="reopen-row">
          <input value={email} placeholder="Reopen a saved report — enter the email used" onChange={(e) => setEmail(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") findByEmail(); }} />
          <button className="btn ghost" onClick={findByEmail}>Find my report</button>
        </div>
        {err && <div className="reopen-err">{err}</div>}
        {recent.length > 0 && (
          <div className="recent">
            <div className="recent-head">Recent reports</div>
            <div className="recent-list">
              {recent.slice(0, 6).map((s) => (
                <div key={s.id} className="recent-item">
                  <button className="ri-open" onClick={() => onOpenSaved(s.id)}>
                    <span className="ri-title">{s.title}</span>
                    <span className="ri-meta">{s.journey === "individual" ? "Individual" : "Enterprise"} · {new Date((s.updated || s.created || 0) * 1000).toLocaleDateString()}</span>
                  </button>
                  <button className="ri-del" title="Delete report" onClick={(e) => del(s.id, e)}>✕</button>
                </div>
              ))}
            </div>
          </div>
        )}
      </div>

      <ConfirmModal open={!!confirmId} title="Delete saved report?"
        message="This permanently removes the saved consultation and its report. This cannot be undone."
        confirmLabel="Delete" danger onConfirm={doDelete} onCancel={() => setConfirmId(null)} />
    </div>
  );
}

/* ─────────────────────────────────────────────────────── Saved artifacts */
function SavedNote({ id }) {
  const [copied, setCopied] = useState(false);
  const link = `${location.origin}/?session=${id}`;
  const copy = () => { navigator.clipboard && navigator.clipboard.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1800); }); };
  return (
    <div className="saved-note">
      <span>✓ Saved. Reopen this report anytime: <a href={`/?session=${id}`}>{link}</a></span>
      <button className="btn ghost" onClick={copy}>{copied ? "Copied ✓" : "Copy link"}</button>
    </div>
  );
}

function SavedReportView({ session, onExit, onDelete }) {
  const [showChat, setShowChat] = useState(false);
  const [confirmDel, setConfirmDel] = useState(false);
  const [rep, setRep] = useState(session.report);
  const [regen, setRegen] = useState(false);
  const regenerate = async () => {
    setRegen(true);
    try {
      const r = await api("/api/report", { method: "POST", body: JSON.stringify({
        journey: session.journey, collected: session.collected || {}, transcript: session.transcript || [], provider: "azure" }) });
      setRep(r);
      try {
        await api("/api/session", { method: "POST", body: JSON.stringify({
          journey: session.journey, title: r.title, email: (session.collected && session.collected.email) || session.email || "",
          collected: session.collected, transcript: session.transcript, report: r }) });
      } catch (e) { /* non-fatal */ }
    } catch (e) { /* keep the existing report on failure */ }
    setRegen(false);
  };
  return (
    <div className="page wide">
      <div className="saved-bar">
        <button className="back" onClick={onExit}>← Start a new consultation</button>
        <div className="saved-bar-actions">
          <button className="btn ghost" onClick={() => setShowChat((s) => !s)}>{showChat ? "Hide" : "View"} consultation transcript</button>
          <button className="btn danger" onClick={() => setConfirmDel(true)}>Delete report</button>
        </div>
      </div>
      <ConfirmModal open={confirmDel} title="Delete saved report?"
        message="This permanently removes the saved consultation and its report. This cannot be undone."
        confirmLabel="Delete" danger onConfirm={() => { setConfirmDel(false); onDelete(session.id); }} onCancel={() => setConfirmDel(false)} />
      {showChat && (
        <div className="card saved-chat">
          {(session.transcript || []).map((m, i) => <ChatRow key={i} role={m.role} content={m.content} />)}
        </div>
      )}
      <Report report={rep} journeyId={session.journey} sessionId={session.id}
        onRegenerate={regenerate} regenerating={regen} />
    </div>
  );
}

/* ─────────────────────────────────────────────────────────── Field input */
function FileField({ field, value, onChange }) {
  const [status, setStatus] = useState(value ? { name: value } : null);
  const [busy, setBusy] = useState(false);
  const inputRef = useRef(null);
  const pick = async (e) => {
    const file = e.target.files[0];
    if (!file) return;
    setBusy(true); setStatus({ name: file.name });
    const fd = new FormData();
    fd.append("file", file);
    try {
      const r = await fetch("/api/upload", { method: "POST", body: fd }).then((x) => x.json());
      setStatus({ name: r.filename, chars: r.chars, ok: r.ok });
      onChange({ filename: r.filename, text: r.text || "" });
    } catch (_) {
      setStatus({ name: file.name, error: true });
    }
    setBusy(false);
  };
  return (
    <div className="field full">
      <label>{field.label}</label>
      <div className={"dropzone" + (status ? " has-file" : "")} onClick={() => inputRef.current?.click()}>
        <input ref={inputRef} type="file" accept={field.accept || ""} onChange={pick} hidden />
        {!status && <span className="dz-hint">📎 Click to upload — parsed on device, used only with your consent</span>}
        {status && (
          <span className="dz-file">
            <strong>{status.name}</strong>
            {busy ? <em> · parsing…</em> : status.ok ? <em className="ok"> · parsed {status.chars} chars ✓</em> : status.error ? <em className="err"> · not parsed</em> : <em> · attached</em>}
          </span>
        )}
      </div>
    </div>
  );
}

// Company-name input with ACRA type-ahead. Selecting a suggestion fills the name and
// (via onPick) the UEN too.
function CompanyAutocomplete({ field, value, onChange, onPick }) {
  const [open, setOpen] = useState(false);
  const [items, setItems] = useState([]);
  const [hi, setHi] = useState(-1);
  const boxRef = useRef(null), tRef = useRef(null);
  useEffect(() => {
    const onDoc = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, []);
  const runQuery = (q) => {
    clearTimeout(tRef.current);
    if (!q || q.trim().length < 2) { setItems([]); setOpen(false); return; }
    tRef.current = setTimeout(() => {
      api(`/api/uen-suggest?q=${encodeURIComponent(q.trim())}`).then((res) => {
        const list = (res && res.suggestions) || [];
        setItems(list); setOpen(list.length > 0); setHi(-1);
      }).catch(() => { setItems([]); setOpen(false); });
    }, 220);
  };
  const pick = (it) => { onChange(it.name); if (onPick) onPick(it.name, it.uen); setOpen(false); setItems([]); };
  return (
    <div className={"field autocomplete" + (field.full ? " full" : "")} ref={boxRef}>
      <label>{field.label}{field.required ? " *" : ""}</label>
      <input value={value ?? ""} placeholder={field.placeholder || "Start typing the company name…"}
        onChange={(e) => { onChange(e.target.value); runQuery(e.target.value); }}
        onFocus={() => { if (items.length) setOpen(true); }}
        onKeyDown={(e) => {
          if (!open) return;
          if (e.key === "ArrowDown") { e.preventDefault(); setHi((h) => Math.min(h + 1, items.length - 1)); }
          else if (e.key === "ArrowUp") { e.preventDefault(); setHi((h) => Math.max(h - 1, 0)); }
          else if (e.key === "Enter") { if (hi >= 0 && items[hi]) { e.preventDefault(); pick(items[hi]); } }
          else if (e.key === "Escape") { setOpen(false); }
        }} />
      {open && (
        <div className="ac-menu">
          {items.map((it, i) => (
            <button type="button" key={it.uen + i} className={"ac-item" + (i === hi ? " hi" : "")}
              onMouseEnter={() => setHi(i)} onMouseDown={(e) => { e.preventDefault(); pick(it); }}>
              <span className="ac-name">{it.name}</span>
              <span className="ac-uen">{it.uen}{it.status ? ` · ${it.status}` : ""}</span>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function Field({ field, value, onChange, onPick }) {
  const cls = "field" + (field.full ? " full" : "");
  if (field.autocomplete === "company") return <CompanyAutocomplete field={field} value={value} onChange={onChange} onPick={onPick} />;
  if (field.type === "file") return <FileField field={field} value={value?.filename} onChange={onChange} />;
  if (field.type === "consent") {
    return (
      <label className="field full consent">
        <input type="checkbox" checked={value !== false} onChange={(e) => onChange(e.target.checked)} />
        <span>{field.label}</span>
      </label>
    );
  }
  if (field.type === "select") {
    return (
      <div className={cls}>
        <label>{field.label}{field.required ? " *" : ""}</label>
        <select value={value ?? ""} onChange={(e) => onChange(e.target.value)}>
          <option value="" disabled>Select…</option>
          {field.options.map((o) => <option key={o}>{o}</option>)}
        </select>
      </div>
    );
  }
  if (field.type === "textarea") {
    return (
      <div className="field full">
        <label>{field.label}{field.required ? " *" : ""}</label>
        <textarea value={value ?? ""} onChange={(e) => onChange(e.target.value)} />
      </div>
    );
  }
  if (field.type === "multiselect") {
    const sel = value || [];
    const toggle = (o) => onChange(sel.includes(o) ? sel.filter((x) => x !== o) : [...sel, o]);
    return (
      <div className="field full">
        <label>{field.label}{field.required ? " *" : ""}</label>
        <div className="tiles">
          {field.options.map((o) => (
            <button type="button" key={o} className={"tile" + (sel.includes(o) ? " sel" : "")} onClick={() => toggle(o)}>
              <strong>{o}</strong>
            </button>
          ))}
        </div>
      </div>
    );
  }
  const err = value && String(value).trim() ? fieldError(field, value) : "";
  return (
    <div className={cls}>
      <label>{field.label}{field.required ? " *" : ""}</label>
      <input type={field.type === "email" ? "email" : "text"} value={value ?? ""} placeholder={field.placeholder || ""}
        className={err ? "invalid" : ""} onChange={(e) => onChange(e.target.value)} />
      {err && <span className="field-err">{err}</span>}
    </div>
  );
}

function UenNote({ status }) {
  if (!status) return null;
  if (status === "looking") return <div className="field full uen-note looking">🔍 Looking up UEN in the ACRA registry…</div>;
  if (status === "indexing") return <div className="field full uen-note looking">⏳ Preparing the registry index — this resolves shortly.</div>;
  if (status === "notfound") return <div className="field full uen-note miss">No ACRA match for that UEN — please enter the company details manually.</div>;
  return null;  // no green "found" confirmation — the filled fields speak for themselves
}

function NameNote({ status }) {
  if (!status) return null;
  if (status === "looking") return <div className="field full uen-note looking">🔍 Searching the ACRA registry by name…</div>;
  if (status === "indexing") return <div className="field full uen-note looking">⏳ Preparing the registry index — this resolves shortly.</div>;
  return null;  // no green "matched" confirmation — the filled UEN speaks for itself
}

function StepForm({ step, values, setValues, onSubmit, busy, extra, onPick }) {
  const set = (name, v) => setValues({ ...values, [name]: v });
  return (
    <div className="card">
      <div className="card-head">
        <div>
          <h2>{step.label}</h2>
          <p className="micro">{step.brief}</p>
        </div>
        <span className="chip purple">Step Form</span>
      </div>
      <div className="form">
        {step.fields.map((f) => (
          <React.Fragment key={f.name}>
            <Field field={f} value={values[f.name]} onChange={(v) => set(f.name, v)} onPick={onPick} />
            {extra && extra(f)}
          </React.Fragment>
        ))}
      </div>
      <div className="actions">
        <button className="btn primary" onClick={onSubmit} disabled={busy}>
          {busy ? "Advisor is thinking…" : "Submit & Continue"}
        </button>
      </div>
    </div>
  );
}

/* ───────────────────────────────────────────────────────────────── Chat */
function ChatRow({ role, content }) {
  const raw = String(content || "");
  const lines = raw.split("\n").filter((x) => x.trim().length);
  const isSubmission = role === "user" && lines[0] && lines[0].startsWith("Submitted");
  return (
    <div className={"chat-row " + (role === "user" ? "user" : "")}>
      <div className="avatar">{role === "user" ? "YOU" : "AI"}</div>
      <div className="msg">
        {isSubmission && lines.length > 1 ? (
          <div className="msg-struct">
            <div className="msg-title">{lines[0]}</div>
            <div className="msg-fields">
              {lines.slice(1).map((ln, i) => {
                const k = ln.indexOf(": ");
                return k > 0
                  ? <div className="mf" key={i}><span className="mf-k">{ln.slice(0, k)}</span><span className="mf-v">{ln.slice(k + 2)}</span></div>
                  : <div key={i}>{ln}</div>;
              })}
            </div>
          </div>
        ) : lines.length > 1 ? (
          lines.map((ln, i) => <p className="msg-p" key={i}>{ln}</p>)
        ) : raw}
      </div>
    </div>
  );
}
function Typing() {
  return (
    <div className="chat-row">
      <div className="avatar">AI</div>
      <div className="msg typing"><span></span><span></span><span></span></div>
    </div>
  );
}

/* ─────────────────────────────────────────────────────── Chart primitive */
function ChartBox({ type, data, options, title, sub }) {
  const ref = useRef(null);
  const inst = useRef(null);
  useEffect(() => {
    if (!ref.current || !window.Chart) return;
    inst.current = new window.Chart(ref.current, { type, data, options });
    return () => inst.current && inst.current.destroy();
  }, [JSON.stringify(data), type]);
  return (
    <div className="chart-card">
      <div className="chart-title">{title}{sub && <span>{sub}</span>}</div>
      <div className="chart-wrap"><canvas ref={ref}></canvas></div>
    </div>
  );
}

const baseOpts = (extra = {}) => ({
  responsive: true, maintainAspectRatio: false,
  plugins: { legend: { labels: { font: { family: "Inter", size: 11 }, boxWidth: 12 } } },
  ...extra,
});

/* ─────────────────────────────────────────────────────── Report visuals */
function ReadinessRing({ score }) {
  const s = Math.max(0, Math.min(100, Math.round(score || 0)));
  const R = 62, C = 2 * Math.PI * R, dash = (s / 100) * C;
  // SVG donut (renders reliably in html2canvas/PDF, unlike conic-gradient).
  return (
    <svg className="readiness-ring" viewBox="0 0 150 150" width="150" height="150">
      <circle cx="75" cy="75" r={R} fill="none" stroke="rgba(255,255,255,0.22)" strokeWidth="13" />
      <circle cx="75" cy="75" r={R} fill="none" stroke="#a855f7" strokeWidth="13" strokeLinecap="round"
        strokeDasharray={`${dash} ${C}`} transform="rotate(-90 75 75)" />
      <text x="75" y="70" textAnchor="middle" fontSize="36" fontWeight="800" fill="#ffffff">{s}</text>
      <text x="75" y="92" textAnchor="middle" fontSize="11" fontWeight="700" fill="#cbd5e1">Readiness</text>
    </svg>
  );
}

const shortLabel = (s, n = 22) => { s = String(s || ""); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
// Build a MySkillsFuture course search using a concise skill keyword (the leading
// concept of the course title), e.g. "Carbon Accounting & GHG…" -> q=Carbon Accounting.
const courseSearchUrl = (title) => {
  let q = String(title || "").replace(/\(.*?\)/g, "");   // drop parentheticals
  q = q.split(/[:—–&,/]/)[0];                            // take the leading concept
  q = q.trim().split(/\s+/).slice(0, 4).join(" ");       // cap to ~4 words
  return "https://courses.myskillsfuture.gov.sg/search?q=" + encodeURIComponent(q || String(title || "").trim());
};

function ReportCharts({ report, journeyId }) {
  const gaps = (report.competency_gaps || []).map((g) => ({ ...g, mag: lvl(g.required_level) - lvl(g.current_level) }));
  const topGaps = [...gaps].sort((a, b) => b.mag - a.mag).slice(0, 6);

  // 1. Competency gap radar (current vs required)
  const radarData = {
    labels: topGaps.map((g) => shortLabel(g.skill, 18)),
    datasets: [
      { label: "Required", data: topGaps.map((g) => lvl(g.required_level)), backgroundColor: "rgba(148,163,184,.25)", borderColor: C.slate, borderWidth: 1.5, pointRadius: 2 },
      { label: "Current", data: topGaps.map((g) => lvl(g.current_level)), backgroundColor: "rgba(139,92,246,.30)", borderColor: C.digital, borderWidth: 2, pointRadius: 2 },
    ],
  };
  const radarOpts = baseOpts({ scales: { r: { min: 0, max: 6, ticks: { stepSize: 2, font: { size: 9 }, backdropColor: "transparent" }, pointLabels: { font: { size: 9.5, family: "Inter" } } } } });

  // 2. Biggest gaps (magnitude), horizontal, coloured by priority
  const prColor = { High: C.red, Medium: C.amber, Low: C.green };
  const magData = {
    labels: topGaps.map((g) => shortLabel(g.skill, 26)),
    datasets: [{ label: "Gap Size", data: topGaps.map((g) => g.mag), backgroundColor: topGaps.map((g) => prColor[g.priority] || C.amber), borderRadius: 4 }],
  };
  const magOpts = baseOpts({ indexAxis: "y", plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true, max: 6, ticks: { stepSize: 2, font: { size: 10 } } }, y: { ticks: { font: { size: 9.5 } } } } });

  // 3. Priority mix
  const pr = { High: 0, Medium: 0, Low: 0 };
  gaps.forEach((g) => { const k = (g.priority || "Medium"); if (pr[k] != null) pr[k]++; });
  const priData = { labels: ["High", "Medium", "Low"], datasets: [{ data: [pr.High, pr.Medium, pr.Low], backgroundColor: [C.red, C.amber, C.green], borderWidth: 0 }] };

  // 4. Domain mix
  const mix = report.skill_domain_mix || {};
  const mixVals = [mix.TII || 0, mix.Green || 0, mix.Digital || 0];
  const domData = { labels: ["TII", "Green", "Digital"], datasets: [{ data: mixVals.some((v) => v) ? mixVals : [1, 1, 1], backgroundColor: [C.tii, C.green, C.digital], borderWidth: 0 }] };

  // 5. Funding coverage of the pathway (how many steps per funding route)
  const lp = report.learning_pathway || [];
  const fundBuckets = {};
  lp.forEach((p) => { const key = /ccp/i.test(p.funding || "") ? "CCP" : /ssg|skillsfuture/i.test(p.funding || "") ? "SkillsFuture/SSG" : /esp|edg|grant/i.test(p.funding || "") ? "Grants" : "Other"; fundBuckets[key] = (fundBuckets[key] || 0) + 1; });
  const fundLabels = Object.keys(fundBuckets).length ? Object.keys(fundBuckets) : ["SkillsFuture/SSG"];
  const fundData = { labels: fundLabels, datasets: [{ data: fundLabels.map((k) => fundBuckets[k] || 1), backgroundColor: [C.green, C.tii, C.purple, C.slate], borderWidth: 0 }] };

  // 6. Sixth: org → role impact; individual → skills profile levels
  let sixth;
  if (journeyId === "organisation") {
    const ri = report.role_impact || [];
    const counts = {}; ri.forEach((r) => { counts[r.decision] = (counts[r.decision] || 0) + 1; });
    const labels = Object.keys(counts).length ? Object.keys(counts) : ["Transform"];
    const palette = { Transform: C.tii, Add: C.green, Reduce: C.amber, Retain: C.digital, Eliminate: C.red };
    sixth = { title: "Role Impact Mix", sub: "redesign decisions", type: "doughnut",
      data: { labels, datasets: [{ data: labels.map((l) => counts[l] || 1), backgroundColor: labels.map((l) => palette[l] || C.slate), borderWidth: 0 }] } };
  } else {
    const sp = (report.skills_profile || []).slice(0, 6);
    sixth = { title: "Validated Skill Levels", sub: "0–6 scale", type: "bar",
      data: { labels: sp.map((s) => shortLabel(s.skill, 16)), datasets: [{ label: "Level", data: sp.map((s) => lvl(s.level)), backgroundColor: C.green, borderRadius: 4 }] },
      opts: baseOpts({ plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, max: 6, ticks: { stepSize: 2, font: { size: 10 } } }, x: { ticks: { font: { size: 9 }, maxRotation: 35 } } } }) };
  }

  return (
    <div className="charts-grid">
      <ChartBox title="Competency Gap Radar" sub="current vs required" type="radar" data={radarData} options={radarOpts} />
      <ChartBox title="Biggest Gaps to Close" sub="required − current, by priority" type="bar" data={magData} options={magOpts} />
      <ChartBox title="Priority Mix" sub="gaps by urgency" type="doughnut" data={priData} options={baseOpts()} />
      <ChartBox title="Skill Domain Mix" sub="TII · Green · Digital" type="doughnut" data={domData} options={baseOpts()} />
      <ChartBox title="Funding Coverage" sub="pathway steps by route" type="doughnut" data={fundData} options={baseOpts()} />
      <ChartBox title={sixth.title} sub={sixth.sub} type={sixth.type} data={sixth.data} options={sixth.opts || baseOpts()} />
    </div>
  );
}

function RoadmapTimeline({ roadmap }) {
  if (!roadmap || !roadmap.length) return null;
  const cols = ["blue", "green", "cyan", "amber", "purple"];
  return (
    <div className="card">
      <div className="card-head"><div><h2>Implementation Timeline</h2><p className="micro">Phased plan from the interaction start date.</p></div><span className="chip purple">Roadmap</span></div>
      <div className="timeline">
        {roadmap.map((ph, i) => (
          <div className={"tl-node " + cols[i % cols.length]} key={i}>
            <span className="chip">{ph.phase}</span>
            <ul>{(ph.actions || []).slice(0, 3).map((a, j) => <li key={j}>{a}</li>)}</ul>
          </div>
        ))}
      </div>
    </div>
  );
}

/* ───────────────────────────────────────────────────── Feedback survey */
function SurveyCard({ journeyId }) {
  const isOrg = journeyId === "organisation";
  const scale = ["Strongly Disagree", "Disagree", "Neutral", "Agree", "Strongly Agree"];
  const questions = [
    { id: "sdp_network", text: "I agree to be part of the SkillsFuture Skills Development Partner (SDP) network.", type: "likert" },
    { id: "relevance", text: `I found the jobs-skills insights and recommendations relevant to my ${isOrg ? "business" : "career"} needs.`, type: "likert" },
    { id: "effectiveness", text: "The Agentic AI Digital Advisor has been effective in helping me understand my skills needs.", type: "likert" },
    { id: "satisfaction", text: "Overall, how satisfied are you with this consultation?", type: "stars" },
  ];
  const [ans, setAns] = useState({});
  const [comment, setComment] = useState("");
  const [done, setDone] = useState(false);
  const answered = questions.filter((q) => ans[q.id] != null).length;

  const submit = () => {
    fetch("/api/survey", { method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ journey: journeyId, answers: ans, comment }) }).catch(() => {});
    setDone(true);
  };

  if (done) return (
    <div className="card survey">
      <div className="survey-done">✓ Thank you — your feedback has been recorded. It contributes to the SDP satisfaction &amp; effectiveness metrics.</div>
    </div>
  );

  return (
    <div className="card survey">
      <div className="card-head"><div><h2>Feedback Survey</h2><p className="micro">Your responses feed the SDP satisfaction &amp; effectiveness metrics (SkillsFuture &amp; SBF).</p></div><span className="chip cyan">Survey</span></div>
      {questions.map((q) => (
        <div className="survey-q" key={q.id}>
          <div className="sq-text">{q.text}</div>
          {q.type === "likert"
            ? <div className="sq-opts">{scale.map((o) => <button key={o} type="button" className={"sq-opt" + (ans[q.id] === o ? " sel" : "")} onClick={() => setAns({ ...ans, [q.id]: o })}>{o}</button>)}</div>
            : <div className="sq-stars">{[1, 2, 3, 4, 5].map((n) => <button key={n} type="button" className={"sq-star" + ((ans[q.id] || 0) >= n ? " on" : "")} onClick={() => setAns({ ...ans, [q.id]: n })}>★</button>)}</div>}
        </div>
      ))}
      <div className="survey-q">
        <div className="sq-text">Any comments or suggestions? <span className="opt">(optional)</span></div>
        <textarea value={comment} onChange={(e) => setComment(e.target.value)} placeholder="Your feedback…" />
      </div>
      <div className="actions">
        <button className="btn primary" onClick={submit} disabled={answered < questions.length}>Submit Feedback</button>
      </div>
    </div>
  );
}

/* ─────────────────────────────────────────────────────────────── Report */
function Report({ report, journeyId, sessionId, onRegenerate, regenerating }) {
  const isOrg = journeyId === "organisation";
  const gaps = report.competency_gaps || [];
  const highN = gaps.filter((g) => (g.priority || "") === "High").length;
  const kpis = [
    { v: (report.readiness_score ?? "–") + "%", l: "Readiness" },
    { v: gaps.length, l: "Skill Gaps" },
    { v: highN, l: "High Priority" },
    { v: (report.learning_pathway || []).length, l: "Pathway Steps" },
    { v: isOrg ? (report.job_redesign || report.role_impact || []).length : (report.skills_profile || []).length, l: isOrg ? "Roles Redesigned" : "Skills Validated" },
  ];
  const [pdfBusy, setPdfBusy] = useState(false);
  const [pdfErr, setPdfErr] = useState("");
  // Server-side render: a headless Chromium on the server renders this saved report
  // and returns a real PDF for download (high fidelity, no client-side hacks).
  const downloadPdf = async () => {
    if (!sessionId || pdfBusy) return;
    setPdfBusy(true); setPdfErr("");
    try {
      const r = await fetch("/api/report/pdf?session=" + encodeURIComponent(sessionId));
      if (!r.ok) throw new Error("render failed");
      const cd = r.headers.get("Content-Disposition") || "";
      const m = cd.match(/filename="?([^";]+)"?/);
      const stamp = new Date().toISOString().slice(0, 16).replace("T", "_").replace(":", "");
      const fname = m ? m[1] : `OmniSkills_${String(report.title || "Report").replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 60)}_${stamp}.pdf`;
      const blob = await r.blob();
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url; a.download = fname;
      document.body.appendChild(a); a.click(); a.remove();
      URL.revokeObjectURL(url);
    } catch (e) {
      setPdfErr("Couldn't generate the PDF. Ensure Chromium is installed (run.sh handles it) and try again.");
    }
    setPdfBusy(false);
  };

  const today = new Date().toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
  const [copied, setCopied] = useState(false);
  const copyLink = () => {
    if (!sessionId) return;
    const link = `${location.origin}/?session=${sessionId}`;
    if (navigator.clipboard) navigator.clipboard.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1800); });
  };

  return (
    <div className="report">
      <div className="report-brandbar">
        <div className="rb-left">
          <img className="rb-logo" src="/api/logo" alt="OmniSkills"
            onError={(e) => { const n = e.target.nextElementSibling; if (n) n.style.display = "inline"; e.target.style.display = "none"; }} />
          <span className="rb-name" style={{ display: "none" }}>OmniSkills</span>
          <span className="rb-sub">Agentic AI Digital Advisor · SBF Skills Development Partner</span>
        </div>
        <div className="rb-right">
          <span className="rb-date">{today}</span>
          {onRegenerate && (
            <button className="btn ghost rb-dl no-print" onClick={onRegenerate} disabled={regenerating}
              title="Generate a fresh version of this report from the same inputs">
              {regenerating ? "Regenerating…" : "↻ Regenerate report"}
            </button>
          )}
          {sessionId && (
            <button className="btn ghost rb-dl no-print" onClick={copyLink} title="Copy a link to reopen this report">
              {copied ? "Copied ✓" : "🔗 Copy report link"}
            </button>
          )}
          <button className="btn ghost rb-dl" onClick={downloadPdf} disabled={pdfBusy || !sessionId}
            title={sessionId ? "Download a PDF of this report" : "Saving report…"}>
            {pdfBusy ? "Generating PDF…" : "⭳ Download PDF"}
          </button>
        </div>
      </div>
      {pdfErr && <div className="pdf-err">{pdfErr}</div>}
      <div className="r-hero">
        <div className="r-hero-main">
          <span className="sample-chip">Workforce Transformation Report</span>
          <h2>{report.title}</h2>
          <p>{report.executive_summary}</p>
          {report.readiness_note && <div className="r-prepared">{report.readiness_note}</div>}
        </div>
        <ReadinessRing score={report.readiness_score} />
      </div>

      <div className="kpi-row">
        {kpis.map((k, i) => <div className="kpi" key={i}><strong>{k.v}</strong><span>{k.l}</span></div>)}
      </div>

      <ReportCharts report={report} journeyId={journeyId} />

      {isOrg && report.job_redesign?.length > 0 && (
        <div className="card">
          <div className="card-head"><div><h2>Job-Redesign Recommendations</h2><p className="micro">How each role shifts after transformation, and the new skills it needs.</p></div><span className="chip blue">Job Redesign</span></div>
          <div className="redesign">
            {report.job_redesign.map((r, i) => (
              <div className="rd" key={i}>
                <strong>{r.role}</strong>
                <div className="rd-flow"><span className="rd-from">{r.from_tasks}</span><span className="rd-arrow">→</span><span className="rd-to">{r.to_tasks}</span></div>
                <div className="meta">{(r.new_skills || []).map((s, j) => <span key={j} className="chip green">{s}</span>)}</div>
              </div>
            ))}
          </div>
        </div>
      )}

      {isOrg && report.role_impact?.length > 0 && (
        <div className="card">
          <div className="card-head"><div><h2>Recommended Job-Role Redesign</h2><p className="micro">Roles to transform, add, reduce, eliminate or retain based on the evidence.</p></div><span className="chip blue">Role Impact</span></div>
          <table className="grid">
            <thead><tr><th>Role</th><th>Decision</th><th>Rationale</th></tr></thead>
            <tbody>{report.role_impact.map((r, i) => (
              <tr key={i}><td><strong>{r.role}</strong></td><td><span className={"pill " + r.decision}>{r.decision}</span></td><td>{r.rationale}</td></tr>
            ))}</tbody>
          </table>
        </div>
      )}

      {isOrg && report.employee_gaps?.length > 0 && (
        <div className="card">
          <div className="card-head"><div><h2>Affected Employees — Skills Validation</h2><p className="micro">Employees identified from the uploaded CVs, validated against the redesigned roles, with priority development skills.</p></div><span className="chip green">Employees</span></div>
          <table className="grid">
            <thead><tr><th>Employee</th><th>Role</th><th>Evidence</th><th>Priority Focus Skills</th></tr></thead>
            <tbody>{report.employee_gaps.map((e, i) => (
              <tr key={i}><td><strong>{e.employee}</strong></td><td>{e.role}</td><td className="micro">{e.evidence}</td>
                <td><div className="skill-chips">{(e.focus_skills || []).map((s, j) => <span key={j} className="chip blue">{s}</span>)}</div></td></tr>
            ))}</tbody>
          </table>
        </div>
      )}

      {!isOrg && report.skills_profile?.length > 0 && (
        <div className="card">
          <div className="card-head"><div><h2>Skills Profile</h2><p className="micro">Validated skills mapped to your target role.</p></div><span className="chip purple">Profile</span></div>
          <table className="grid">
            <thead><tr><th>Skill</th><th>Level</th><th>Relevance</th></tr></thead>
            <tbody>{report.skills_profile.map((s, i) => (
              <tr key={i}><td><strong>{s.skill}</strong></td><td>{s.level}</td><td>{s.relevance}</td></tr>
            ))}</tbody>
          </table>
        </div>
      )}

      {gaps.length > 0 && (
        <div className="card">
          <div className="card-head"><div><h2>Competency Gap Analysis</h2><p className="micro">Current levels compared with the role requirement.</p></div><span className="chip green">Gaps</span></div>
          <table className="grid">
            <thead><tr><th>Skill</th><th>Domain</th><th>Current</th><th>Required</th><th>Priority</th></tr></thead>
            <tbody>{gaps.map((g, i) => (
              <tr key={i}><td><strong>{g.skill}</strong></td><td>{g.domain ? <span className={"dtag " + g.domain}>{g.domain}</span> : "—"}</td><td>{g.current_level}</td><td>{g.required_level}</td><td><span className={"pill " + g.priority}>{g.priority}</span></td></tr>
            ))}</tbody>
          </table>
        </div>
      )}

      {report.learning_pathway?.length > 0 && (
        <div className="card">
          <div className="card-head"><div><h2>Learning Pathway</h2><p className="micro">Prioritised, sequenced courses with funding routes and how to apply.</p></div><span className="chip amber">Pathway</span></div>
          <div className="path">
            {report.learning_pathway.map((p, i) => (
              <div className="p" key={i}>
                <div className="p-num">{i + 1}</div>
                <div className="p-body">
                  <strong>{p.title}</strong>
                  {p.closes_gap && <div className="closes"><span className="chip amber">Closes gap</span> {p.closes_gap}</div>}
                  {p.focus && <div className="micro">{p.focus}</div>}
                  {p.how_to_apply && <div className="how">↳ {p.how_to_apply}</div>}
                  <div className="meta">
                    {p.funding && <span className="chip green"><b>Funding:</b> {p.funding}</span>}
                    {p.duration && <span className="chip blue">{p.duration}</span>}
                    <a className="course-link" href={courseSearchUrl(p.title)} target="_blank" rel="noopener noreferrer">Find courses ↗</a>
                  </div>
                </div>
              </div>
            ))}
          </div>
        </div>
      )}

      <RoadmapTimeline roadmap={report.roadmap} />
    </div>
  );
}

/* ─────────────────────────────────────────────────────── Upload step */
function SourceCard({ field, value, onChange }) {
  const [status, setStatus] = useState(value ? { name: value } : null);
  const [busy, setBusy] = useState(false);
  const filesRef = useRef(null);
  const statusChip = { Required: "red", Requested: "amber", Optional: "purple", Supporting: "blue" }[field.status] || "purple";

  const pick = async (e) => {
    const files = Array.from(e.target.files || []).slice(0, field.single ? 1 : 25);
    if (!files.length) return;
    setBusy(true); setStatus({ name: files.length + " file(s)" });
    // Cap EACH file's text (not a global running total) so every CV in a large
    // batch is represented — the analysis can then see all people and pick the top 5.
    const perFile = files.length > 6 ? 900 : 2500;
    let combined = "", parsed = 0, names = [];
    for (const file of files) {
      const fd = new FormData(); fd.append("file", file);
      try {
        const r = await fetch("/api/upload", { method: "POST", body: fd }).then((x) => x.json());
        if (r.text) combined += (combined ? "\n\n" : "") + `[${r.filename}]\n` + r.text.slice(0, perFile);
        if (r.ok) parsed++;
        names.push(r.filename);
      } catch (_) { /* skip */ }
    }
    const label = files.length === 1 ? names[0] : `${files.length} files`;
    setStatus({ name: label, chars: combined.length, ok: parsed > 0, count: files.length });
    onChange({ filename: label, text: combined, count: files.length });
    setBusy(false);
  };

  return (
    <div className={"source-card" + (status ? " has" : "")}>
      {/* Multi-file selection only (no webkitdirectory) so Chrome's native
          "Upload N files to this site?" folder prompt never appears. */}
      <input ref={filesRef} type="file" accept={field.accept || ""} multiple={!field.single} onChange={pick} hidden />
      <div className="src-icon" onClick={() => filesRef.current?.click()}>{field.code || "DOC"}</div>
      <div className="src-body">
        <div className="src-name">{field.label}</div>
        <div className="micro">{field.desc}</div>
        {status
          ? <div className="src-file">{busy ? "parsing…" : status.ok ? `${status.name} · parsed ${status.chars} chars ✓` : status.error ? `${status.name} · not parsed` : status.name}</div>
          : <div className="src-actions">
              <button type="button" onClick={() => filesRef.current?.click()}>{field.single ? "Choose file" : "Choose files"}</button>
              {!field.single && <span className="src-hint">— select one or several</span>}
            </div>}
      </div>
      <span className={"chip " + statusChip}>{status ? "Uploaded" : (field.status || "Optional")}</span>
    </div>
  );
}

function UploadStep({ step, values, setValues, onSubmit, busy }) {
  const set = (name, v) => setValues({ ...values, [name]: v });
  const fileFields = step.fields.filter((f) => f.type === "file");
  const otherFields = step.fields.filter((f) => f.type !== "file");
  return (
    <div className="card">
      <div className="card-head">
        <div><h2>{step.label}</h2><p className="micro">{step.brief}</p></div>
        <span className="chip cyan">Upload Field</span>
      </div>
      <div className="sources">
        {fileFields.map((f) => <SourceCard key={f.name} field={f} value={values[f.name]?.filename} onChange={(v) => set(f.name, v)} />)}
      </div>
      {otherFields.length > 0 && (
        <div className="form" style={{ marginTop: 12 }}>
          {otherFields.map((f) => <Field key={f.name} field={f} value={values[f.name]} onChange={(v) => set(f.name, v)} />)}
        </div>
      )}
      <div className="actions">
        <button className="btn primary" onClick={onSubmit} disabled={busy}>{busy ? "Advisor is thinking…" : "Submit & Continue"}</button>
      </div>
    </div>
  );
}

/* ──────────────────────────────────────────────────── Analysis output */
const DEC = { Transform: "blue", Add: "green", Reduce: "amber", Retain: "purple", Eliminate: "red" };
const TAG = { "In-Demand": "blue", Build: "green", "Close Gap": "amber", Course: "green", Funding: "purple", Survey: "cyan" };

function Tiles({ items, kind }) {
  return (
    <div className="matrix">
      {items.map((t, i) => {
        const key = kind === "tag" ? t.tag : t.decision;
        const color = (kind === "tag" ? TAG[key] : DEC[key]) || "purple";
        return (
          <div className={"matrix-card " + color} key={i}>
            <span className={"chip " + color}>{key}</span>
            <strong>{t.title}</strong>
            <p className="micro">{t.note}</p>
          </div>
        );
      })}
    </div>
  );
}

function AnalysisPanel({ journeyId, step, provider, collected, transcript, onContinue, onData }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [refine, setRefine] = useState("");
  const [regenerating, setRegenerating] = useState(false);

  const run = (instruction) => {
    setLoading(!instruction); setRegenerating(!!instruction);
    return api("/api/analysis", { method: "POST", body: JSON.stringify({ journey: journeyId, analysis: step.analysis, collected: collected.current, transcript, provider, instruction: instruction || null }) })
      .then((d) => { setData(d); setLoading(false); setRegenerating(false); if (onData) onData(step.analysis, d); })
      .catch(() => { setData({}); setLoading(false); setRegenerating(false); });
  };

  useEffect(() => { run(null); }, [step.id]);

  const doRefine = () => { if (refine.trim()) run(refine.trim()); };

  if (loading) return <div className="center-load"><div className="spinner" /> Generating {step.label.replace(/^\d+\.\s*/, "")}…</div>;
  const d = data || {};
  const a = step.analysis;
  return (
    <div className="card analysis">
      <div className="card-head">
        <div><h2>{step.label}</h2><p className="micro">{step.brief}</p></div>
        <span className="chip green">AI Output</span>
      </div>

      {a === "redesign" && d.rows?.length > 0 && (
        <table className="grid"><thead><tr><th>Role / Capability</th><th>Current Issue</th><th>Evidence Used</th><th>Recommended Redesign</th></tr></thead>
          <tbody>{d.rows.map((r, i) => <tr key={i}><td><strong>{r.role}</strong></td><td>{r.current_issue}</td><td className="micro">{r.evidence_used}</td><td><span className={"pill " + r.recommendation}>{r.recommendation}</span></td></tr>)}</tbody></table>
      )}
      {a === "validation" && d.rows?.length > 0 && (
        <table className="grid"><thead><tr><th>Employee</th><th>Role</th><th>Evidence</th><th>Priority Focus Skills</th></tr></thead>
          <tbody>{d.rows.map((r, i) => {
            const skills = Array.isArray(r.focus_skills) ? r.focus_skills : (r.focus_skill ? [r.focus_skill] : []);
            return <tr key={i}><td><strong>{r.employee}</strong></td><td>{r.role}</td><td className="micro">{r.evidence}</td>
              <td><div className="skill-chips">{skills.map((s, j) => <span key={j} className="chip blue">{s}</span>)}</div></td></tr>;
          })}</tbody></table>
      )}
      {a === "individual_skills" && d.rows?.length > 0 && (
        <table className="grid"><thead><tr><th>Skill</th><th>Source</th><th>Status</th><th>Assessment</th></tr></thead>
          <tbody>{d.rows.map((r, i) => <tr key={i}><td><strong>{r.skill}</strong></td><td>{r.source}</td><td><span className={"chip " + (r.status === "Confirmed" ? "green" : "red")}>{r.status}</span></td><td>{r.assessment}</td></tr>)}</tbody></table>
      )}

      {a === "redesign" && d.tiles?.length > 0 && <><h3 className="an-sub">Redesign Actions</h3><Tiles items={d.tiles} kind="dec" /></>}
      {a === "validation" && d.actions?.length > 0 && <><h3 className="an-sub">Validated Job-Role Actions</h3><Tiles items={d.actions} kind="dec" /></>}
      {a === "individual_pathway" && (d.pathway?.length > 0
        ? <div className="path">
            {d.pathway.map((p, i) => (
              <div className="p" key={i}>
                <div className="p-num">{i + 1}</div>
                <div className="p-body">
                  <strong>{p.title}</strong>
                  {p.closes_gap && <div className="closes"><span className="chip amber">Closes gap</span> {p.closes_gap}</div>}
                  {p.focus && <div className="micro">{p.focus}</div>}
                  {p.how_to_apply && <div className="how">↳ {p.how_to_apply}</div>}
                  <div className="meta">
                    {p.funding && <span className="chip green"><b>Funding:</b> {p.funding}</span>}
                    {p.duration && <span className="chip blue">{p.duration}</span>}
                    <a className="course-link" href={courseSearchUrl(p.title)} target="_blank" rel="noopener noreferrer">Find courses ↗</a>
                  </div>
                </div>
              </div>
            ))}
          </div>
        : d.tiles?.length > 0 && <Tiles items={d.tiles} kind="tag" />)}

      {d.summary && <p className="an-summary">{d.summary}</p>}

      <div className="refine-box">
        <label>Want changes? Tell the advisor how to adjust this analysis, then regenerate.</label>
        <div className="refine-row">
          <input value={refine} placeholder='e.g. "focus on green/sustainability roles and keep headcount flat"'
            onChange={(e) => setRefine(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Enter") doRefine(); }} disabled={regenerating} />
          <button className="btn ghost" onClick={doRefine} disabled={regenerating || !refine.trim()}>
            {regenerating ? "Regenerating…" : "↻ Regenerate"}
          </button>
        </div>
      </div>

      <div className="actions">
        <button className="btn primary" onClick={() => onContinue(typeof d.summary === "string" ? d.summary : "")} disabled={regenerating}>Continue</button>
      </div>
    </div>
  );
}

/* ───────────────────────────────────────────────────────────── Advisor */
function Advisor({ journeyId, provider, onExit, voiceEnabled }) {
  const [journey, setJourney] = useState(null);
  const [stepIndex, setStepIndex] = useState(0);
  const [values, setValues] = useState({});
  const [transcript, setTranscript] = useState([]);
  const [busy, setBusy] = useState(false);
  const [report, setReport] = useState(null);
  const [genReport, setGenReport] = useState(false);
  const [stepError, setStepError] = useState("");
  const [savedId, setSavedId] = useState(null);
  const [voiceOn, setVoiceOn] = useState(false);
  const [voicePaused, setVoicePaused] = useState(false);
  const [voiceReview, setVoiceReview] = useState(false);
  const [voiceStatus, setVoiceStatus] = useState("");
  const [uenStatus, setUenStatus] = useState("");
  const [nameStatus, setNameStatus] = useState("");
  const uenLast = useRef("");
  const nameLast = useRef("");
  const collected = useRef({});
  const analyses = useRef({});
  const bodyRef = useRef(null);
  const anchorRef = useRef(null);
  const voice = useRef({});
  const live = useRef({});  // fresh {step, values, setValues, submitStep, stepIndex} for voice callbacks

  // ── Realtime voice (talk to the advisor) ──
  // Update the in-flight assistant bubble by a stable id (not array index) so a
  // user transcript arriving mid-stream can't shift/overwrite the wrong row.
  const upsertAssistant = (text, done) => {
    setTranscript((t) => {
      const v = voice.current, copy = [...t];
      if (!v.asstId) { v.asstId = "a" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); copy.push({ role: "assistant", content: text, _id: v.asstId }); }
      else { const i = copy.findIndex((m) => m._id === v.asstId); if (i >= 0) copy[i] = { ...copy[i], content: text }; else copy.push({ role: "assistant", content: text, _id: v.asstId }); }
      return copy;
    });
    if (done) voice.current.asstId = null;
  };
  const playPCM = (b64) => {
    const v = voice.current; if (!v.playCtx) return;
    const f32 = base64ToF32(b64);
    const buf = v.playCtx.createBuffer(1, f32.length, 24000);
    buf.getChannelData(0).set(f32);
    const node = v.playCtx.createBufferSource();
    node.buffer = buf; node.connect(v.playGain || v.playCtx.destination);
    v.playTime = Math.max(v.playTime || 0, v.playCtx.currentTime);
    node.start(v.playTime); v.playTime += buf.duration;
  };

  const sendVoice = (obj) => {
    const v = voice.current;
    if (v.ws && v.ws.readyState === 1) { try { v.ws.send(JSON.stringify(obj)); } catch (e) {} }
  };

  // ── numeric-range helpers: map a spoken number onto a range option ──
  const parseRange = (opt) => {
    const s = String(opt);
    let m = s.match(/(\d[\d,]*)\s*(?:-|–|to)\s*(\d[\d,]*)/i);
    if (m) return { lo: +m[1].replace(/,/g, ""), hi: +m[2].replace(/,/g, "") };
    m = s.match(/(\d[\d,]*)\s*\+/); if (m) return { lo: +m[1].replace(/,/g, ""), hi: Infinity };
    m = s.match(/(?:under|below|less than|up to)\s*(\d[\d,]*)/i); if (m) return { lo: 0, hi: +m[1].replace(/,/g, "") };
    m = s.match(/(?:over|above|more than)\s*(\d[\d,]*)/i); if (m) return { lo: +m[1].replace(/,/g, ""), hi: Infinity };
    return null;
  };
  const isRangeField = (f) => f.type === "select" && (f.options || []).length > 0 && (f.options || []).every((o) => parseRange(o));
  const numberIn = (raw) => { const m = String(raw).replace(/,/g, "").match(/\d+(?:\.\d+)?/); return m ? +m[0] : null; };

  // Human-readable current value of a field (null = treat as not yet answered).
  const fieldValueLabel = (f, val) => {
    if (val == null || val === "") return null;
    if (f.type === "consent") return val === true ? "consent given" : null;  // default-false = unanswered
    if (Array.isArray(val)) return val.length ? val.join(", ") : null;
    if (f.type === "file") return val && val.filename ? val.filename : null;
    return String(val);
  };
  const describeField = (f, i) => {
    const opts = (f.options || []).join(", ");
    if (isRangeField(f)) return `${i + 1}. ${f.label} (id: ${f.name}) — ask for the ACTUAL number; do NOT read the ranges aloud, I map the number to a band.`;
    if (f.type === "select") return `${i + 1}. ${f.label} (id: ${f.name}) — one choice from: ${opts}. Offer a couple as examples; if the user says something off-list, briefly clarify or pick the nearest option and confirm it.`;
    if (f.type === "multiselect") return `${i + 1}. ${f.label} (id: ${f.name}) — one or more of: ${opts}.`;
    if (f.type === "consent") return `${i + 1}. ${f.label} (id: ${f.name}) — a yes/no consent.`;
    return `${i + 1}. ${f.label} (id: ${f.name}).`;
  };

  // Build a per-step realtime session: instructions + tools so the advisor holds a
  // natural conversation and writes answers into the form (shown at review time).
  // Reads the CURRENT form values so it never re-asks fields the user already filled.
  const voiceSession = (step) => {
    const vals = (live.current && live.current.values) || {};
    const fillable = (step.fields || []).filter((f) => f.type !== "file");
    const files = (step.fields || []).filter((f) => f.type === "file");
    const already = [], remaining = [];
    fillable.forEach((f) => {
      const disp = fieldValueLabel(f, vals[f.name]);
      if (disp != null) already.push(`- ${f.label}: ${disp}`);
      else remaining.push(f);
    });
    const remLines = remaining.map((f, i) => describeField(f, i)).join("\n");
    const fileMissing = files.filter((f) => !fieldValueLabel(f, vals[f.name]));
    const fileLines = fileMissing.map((f) => `- ${f.label}: the user must upload it with the on-screen button; you cannot capture a file by voice.`).join("\n");
    const instructions =
      `You are the OmniSkills Agentic AI Digital Advisor for the Singapore Business Federation's Skills Development Partner programme (Trade, Investment & Internationalisation and Green Skills). Speak in warm, concise Singapore/British English.\n\n` +
      `You are helping the user complete the step "${step.label.replace(/^\d+\.\s*/, "")}". ${step.brief || ""}\n\n` +
      `The user may have ALREADY FILLED PART OF THE FORM by hand before starting the voice chat. Continue from where they left off — never ask again for anything already captured.\n\n` +
      `HOW TO CONVERSE — optimise for the user's time and be intelligent:\n` +
      `- Open by briefly acknowledging what's already captured (one short sentence), then ask only for the first STILL-NEEDED field.\n` +
      `- Keep spoken turns short (one or two sentences).\n` +
      `- Prefer one field per question, but you MAY combine two closely-related short still-needed fields into a single natural question when it saves time.\n` +
      `- If the user answers SEVERAL fields in one sentence, capture ALL of them — emit a separate set_field call for each — and do NOT ask those again.\n` +
      `- For a size/number field, just ask for the actual number (e.g. "How many staff?"). Never read out numeric ranges.\n` +
      `- For a choice field, you may suggest a couple of options; if the user says something not on the list, ask a short clarifying question or pick the nearest match and confirm.\n` +
      `- Never re-ask a field that is already filled; move to the next still-needed field. If the user asks to change something already captured, call set_field to update it.\n` +
      `- If the user says "pause", stop talking and wait quietly.\n\n` +
      `WHEN ALL STILL-NEEDED FIELDS ARE CAPTURED:\n` +
      `- Call the review tool (do NOT submit yet). The on-screen form then shows everything captured.\n` +
      `- Then ask the user, in one sentence, to confirm the details are correct.\n` +
      `- Only when the user confirms (e.g. "yes", "looks good", "submit"), call the submit tool. If they want a change, call set_field to correct it, then review again.\n\n` +
      (already.length ? `ALREADY CAPTURED (do NOT ask these again):\n${already.join("\n")}\n\n` : ``) +
      (remLines ? `STILL NEEDED — ask only these, in order:\n${remLines}\n` : (fillable.length ? `All spoken fields are already filled — briefly confirm and call review.\n` : `This step has no spoken fields.\n`)) +
      (fileLines ? `\nFiles the user must still upload:\n${fileLines}\n` : ``);
    const tools = [];
    if (fillable.length) {
      tools.push({
        type: "function", name: "set_field",
        description: "Record one answer into the on-screen form. Call once per field; call it several times in a turn if the user gave several answers at once.",
        parameters: {
          type: "object",
          properties: {
            field: { type: "string", enum: fillable.map((f) => f.name), description: "The field id to fill." },
            value: { type: "string", description: "The user's answer as plain text (for a size field, just the number)." },
          },
          required: ["field", "value"],
        },
      });
      tools.push({ type: "function", name: "review", description: "Show the filled form to the user for confirmation once all fields are captured.", parameters: { type: "object", properties: {} } });
    }
    tools.push({ type: "function", name: "submit", description: "Submit this step after the user confirms the reviewed details are correct.", parameters: { type: "object", properties: {} } });
    return { type: "session.update", session: { instructions, tools, tool_choice: "auto" } };
  };

  const matchOption = (opts, val) => {
    const s = String(val).toLowerCase().trim();
    return (opts || []).find((o) => o.toLowerCase() === s)
      || (opts || []).find((o) => o.toLowerCase().includes(s) || s.includes(o.toLowerCase()));
  };

  // Apply a spoken answer to the visible form. Returns a short result message for the model.
  const applyVoiceField = (name, raw) => {
    const L = live.current, step = L.step;
    if (!step) return "No active step.";
    const f = (step.fields || []).find((x) => x.name === name || x.label.toLowerCase() === String(name).toLowerCase());
    if (!f) return `There is no field called ${name}.`;
    if (f.type === "file") return `${f.label} needs a file upload; ask the user to use the upload button.`;
    let v;
    if (f.type === "consent") v = /^(yes|y|agree|consent|ok|sure|granted|i agree|of course|absolutely)/i.test(String(raw).trim());
    else if (f.type === "select") {
      if (isRangeField(f)) {
        const n = numberIn(raw);
        if (n != null) { const opt = (f.options || []).find((o) => { const r = parseRange(o); return r && n >= r.lo && n <= r.hi; }); v = opt || matchOption(f.options, raw); }
        else v = matchOption(f.options, raw);
      } else v = matchOption(f.options, raw);
      if (!v) return `"${raw}" doesn't match an option (${(f.options || []).join(", ")}); ask the user to clarify or pick the nearest.`;
    } else if (f.type === "multiselect") {
      const parts = String(raw).split(/,| and | or |;|\/|&/i).map((s) => s.trim()).filter(Boolean);
      v = Array.from(new Set(parts.map((p) => matchOption(f.options, p)).filter(Boolean)));
      if (!v.length) return `Please choose from: ${(f.options || []).join(", ")}.`;
    } else v = String(raw).trim();
    if (L.setValues) L.setValues((prev) => ({ ...prev, [f.name]: v }));
    const vErr = f.validate ? fieldError(f, v) : "";
    if (vErr) return `I noted ${f.label} as "${v}", but that doesn't look valid — ${vErr} Please ask the user to confirm or correct it.`;
    if (f.validate === "uen") return `UEN ${v} recorded and valid. I'm auto-filling the company name (and subsector where known) from the registry — do NOT ask for the company name; move to the next still-needed field.`;
    return `Recorded ${f.label}: ${Array.isArray(v) ? v.join(", ") : (v === true ? "consent given" : v === false ? "declined" : v)}.`;
  };

  const handleVoiceTool = (name, args, callId) => {
    const v = voice.current;
    let result = "Done.";
    if (name === "set_field") { result = applyVoiceField(args.field, args.value); v.needFollowup = true; }
    else if (name === "review") { setVoiceReview(true); v.needFollowup = true; result = "The filled form is now shown for the user to confirm."; }
    else if (name === "submit") {
      const r = live.current.advanceVoiceStep ? live.current.advanceVoiceStep() : { ok: false, msg: "Cannot submit right now." };
      if (r.ok) { setVoiceReview(false); v.submitting = true; v.needFollowup = false; }
      else { v.needFollowup = true; }  // stay on this step and ask the user for what's missing
      result = r.msg;
    } else { result = "Unknown action."; }
    // Return the tool result; the single follow-up question is issued on response.done
    // so multiple answers in one turn don't trigger multiple replies.
    if (callId) sendVoice({ type: "conversation.item.create", item: { type: "function_call_output", call_id: callId, output: JSON.stringify({ result }) } });
  };

  const pauseVoice = () => {
    const v = voice.current; v.paused = true;
    if (v.playGain) v.playGain.gain.value = 0;
    v.playTime = 0;
    if (v.responseActive) sendVoice({ type: "response.cancel" });  // only cancel a live response
    setVoicePaused(true); setVoiceStatus("Paused");
  };
  const resumeVoice = () => {
    const v = voice.current; v.paused = false;
    if (v.playGain) v.playGain.gain.value = 1;
    setVoicePaused(false); setVoiceStatus("Listening…");
  };

  const handleVoiceEvent = (msg) => {
    const v = voice.current;
    switch (msg.type) {
      case "response.created": v.responseActive = true; break;
      case "response.audio.delta": if (msg.delta && !v.paused) playPCM(msg.delta); break;
      case "response.audio_transcript.delta": v.asstBuf = (v.asstBuf || "") + (msg.delta || ""); upsertAssistant(v.asstBuf); break;
      case "response.audio_transcript.done": upsertAssistant(msg.transcript || v.asstBuf || "", true); v.asstBuf = ""; break;
      case "response.output_item.added":
        if (msg.item && msg.item.type === "function_call") { v.fcalls = v.fcalls || {}; v.fcalls[msg.item.call_id] = msg.item.name; }
        break;
      case "response.function_call_arguments.done": {
        let args = {}; try { args = JSON.parse(msg.arguments || "{}"); } catch (e) {}
        const nm = msg.name || (v.fcalls || {})[msg.call_id];
        handleVoiceTool(nm, args, msg.call_id);
        break;
      }
      case "conversation.item.input_audio_transcription.completed": {
        const txt = (msg.transcript || "").trim();
        if (txt) {
          setTranscript((t) => [...t, { role: "user", content: txt }]);
          if (/\bend (the |this )?(conversation|call|voice|chat|session)\b|\bstop (the |this )?conversation\b|\bhang up\b|\bwe'?re (all )?done\b|\bthat'?s all( for now)?\b|\bthat is all\b|\bfinish (the )?(conversation|call)\b/i.test(txt)) { stopVoice(); break; }
          else if (/\bpause\b|\bhold on\b|\bwait a\b/i.test(txt)) pauseVoice();
          else if (/\bresume\b|\bunpause\b|\bcarry on\b|\bgo ahead\b/i.test(txt)) resumeVoice();
        }
        break;
      }
      case "input_audio_buffer.speech_started": if (!v.paused) setVoiceStatus("Listening…"); break;
      case "input_audio_buffer.speech_stopped": if (!v.paused) setVoiceStatus("Thinking…"); break;
      case "response.done": {
        v.responseActive = false;
        // One consolidated follow-up after tool calls (set_field/review) — even if
        // several answers arrived in one turn — unless we just submitted.
        if (v.needFollowup && !v.submitting && !v.paused) { v.needFollowup = false; sendVoice({ type: "response.create" }); }
        v.submitting = false;
        if (!v.paused) setVoiceStatus("Listening…");
        break;
      }
      case "error": {
        const m = (msg.error && (msg.error.message || msg.error.code)) || "";
        if (/cancel|no active response|active response/i.test(m)) break;  // benign: pause with nothing to cancel
        console.warn("realtime error:", m);
        break;
      }
      default: break;
    }
  };
  const stopVoice = () => {
    const v = voice.current;
    try { v.ws && v.ws.close(); } catch (e) {}
    try { v.proc && v.proc.disconnect(); } catch (e) {}
    try { v.src && v.src.disconnect(); } catch (e) {}
    try { v.sink && v.sink.disconnect(); } catch (e) {}
    try { v.stream && v.stream.getTracks().forEach((t) => t.stop()); } catch (e) {}
    try { v.ctx && v.ctx.close(); } catch (e) {}
    try { v.playCtx && v.playCtx.close(); } catch (e) {}
    voice.current = {};
    setVoiceOn(false); setVoicePaused(false); setVoiceReview(false); setVoiceStatus("");
  };
  const startVoice = async () => {
    // Mic needs a secure context: https OR localhost/127.0.0.1 (NOT 0.0.0.0).
    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
      const onZero = location.hostname === "0.0.0.0";
      setVoiceStatus(onZero
        ? "Voice needs localhost — open http://localhost:8000 (not 0.0.0.0)"
        : "Voice needs a secure origin (https or localhost).");
      return;
    }
    try {
      setVoiceStatus("Connecting…");
      const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
      const AC = window.AudioContext || window.webkitAudioContext;
      const ctx = new AC();
      const playCtx = new AC({ sampleRate: 24000 });
      const playGain = playCtx.createGain(); playGain.gain.value = 1; playGain.connect(playCtx.destination);
      const proto = location.protocol === "https:" ? "wss:" : "ws:";
      const ws = new WebSocket(`${proto}//${location.host}/api/realtime`);
      voice.current = { ws, ctx, playCtx, playGain, stream, playTime: 0, asstBuf: "", asstId: null, paused: false, fcalls: {}, lastStep: null };
      ws.onopen = () => {
        setVoiceOn(true); setVoicePaused(false); setVoiceStatus("Listening…");
        const st = live.current.step;
        if (st) { sendVoice(voiceSession(st)); sendVoice({ type: "response.create" }); voice.current.lastStep = live.current.stepIndex; }
      };
      ws.onclose = () => stopVoice();
      ws.onerror = () => setVoiceStatus("Voice error");
      ws.onmessage = (e) => { try { handleVoiceEvent(JSON.parse(e.data)); } catch (x) {} };
      const src = ctx.createMediaStreamSource(stream);
      const proc = ctx.createScriptProcessor(4096, 1, 1);
      proc.onaudioprocess = (ev) => {
        if (!ws || ws.readyState !== 1 || voice.current.paused) return;
        const ds = downsampleTo24k(ev.inputBuffer.getChannelData(0), ctx.sampleRate);
        ws.send(JSON.stringify({ type: "input_audio_buffer.append", audio: pcm16ToBase64(ds) }));
      };
      const sink = ctx.createGain(); sink.gain.value = 0;
      src.connect(proc); proc.connect(sink); sink.connect(ctx.destination);
      Object.assign(voice.current, { src, proc, sink });
    } catch (err) {
      setVoiceStatus(err && err.name === "NotAllowedError" ? "Mic permission denied" : "Voice unavailable");
      setVoiceOn(false);
    }
  };
  useEffect(() => () => stopVoice(), []);  // cleanup on unmount

  // When the step changes while voice is on, re-point the advisor at the new
  // step's fields and have it ask the first question.
  useEffect(() => {
    const v = voice.current;
    if (voiceOn && v.ws && v.ws.readyState === 1 && v.lastStep !== stepIndex && live.current.step) {
      setVoiceReview(false);
      sendVoice(voiceSession(live.current.step));
      sendVoice({ type: "response.create" });
      v.lastStep = stepIndex;
    }
  }, [stepIndex, voiceOn]);

  // Stop voice once the report is shown.
  useEffect(() => { if (report) stopVoice(); }, [report]);

  // Auto-fill company details from the UEN. When the UEN field becomes valid, look it
  // up (debounced) and fill any EMPTY mapped fields (company name, subsector). Never
  // overwrites what the user already typed. Refreshes the voice session so the advisor
  // treats the filled fields as captured.
  useEffect(() => {
    if (!journey) return;
    const st = journey.steps[stepIndex];
    const uenField = (st && st.fields || []).find((f) => f.validate === "uen");
    if (!uenField) return;
    const val = String(values[uenField.name] || "").trim().toUpperCase();
    if (!val || fieldError(uenField, val)) {             // wait for a valid UEN
      if (uenStatus) setUenStatus(""); uenLast.current = ""; return;
    }
    if (val === uenLast.current) return;                 // already looked this one up
    const t = setTimeout(() => {
      uenLast.current = val;
      setUenStatus("looking");
      api(`/api/uen-lookup?uen=${encodeURIComponent(val)}`).then((res) => {
        if (res && res.found && res.fields) {
          setValues((prev) => {
            const nx = { ...prev };
            Object.entries(res.fields).forEach(([k, v]) => {
              const f = (st.fields || []).find((x) => x.name === k);
              if (!f) return;
              const cur = nx[k];
              const empty = f.type === "multiselect" ? !(cur && cur.length) : !(cur && String(cur).trim());
              if (!empty) return;
              if (f.type === "select") { if ((f.options || []).includes(v)) nx[k] = v; }
              else nx[k] = v;
            });
            return nx;
          });
          setUenStatus("found:" + (res.fields.company_name || ""));
          setTimeout(() => { if (voice.current.ws && voice.current.ws.readyState === 1 && live.current.step) sendVoice(voiceSession(live.current.step)); }, 80);
        } else if (res && res.building) { setUenStatus("indexing"); uenLast.current = ""; }
        else setUenStatus("notfound");
      }).catch(() => setUenStatus("notfound"));
    }, 500);
    return () => clearTimeout(t);
  }, [journey, stepIndex, values]);

  // Reverse lookup: when a company NAME is entered but the UEN is still empty, find
  // the UEN from the ACRA registry (exact name match) and fill it in.
  useEffect(() => {
    if (!journey) return;
    const st = journey.steps[stepIndex];
    const nameField = (st && st.fields || []).find((f) => f.name === "company_name");
    const uenField = (st && st.fields || []).find((f) => f.validate === "uen");
    if (!nameField || !uenField) return;
    const nm = String(values[nameField.name] || "").trim();
    const uenVal = String(values[uenField.name] || "").trim().toUpperCase();
    // Only search by name while the UEN is still empty/invalid.
    if (!nm || nm.length < 4 || (uenVal && !fieldError(uenField, uenVal))) { if (nameStatus) setNameStatus(""); nameLast.current = ""; return; }
    if (nm.toUpperCase() === nameLast.current) return;
    const t = setTimeout(() => {
      nameLast.current = nm.toUpperCase();
      setNameStatus("looking");
      api(`/api/uen-lookup?name=${encodeURIComponent(nm)}`).then((res) => {
        if (res && res.found && res.fields && res.fields.uen) {
          setValues((prev) => (prev[uenField.name] && String(prev[uenField.name]).trim()) ? prev : { ...prev, [uenField.name]: res.fields.uen });
          setNameStatus("found:" + res.fields.uen);
        } else if (res && res.building) { setNameStatus("indexing"); nameLast.current = ""; }
        else setNameStatus("");   // stay quiet if no exact match (don't nag mid-typing)
      }).catch(() => setNameStatus(""));
    }, 700);
    return () => clearTimeout(t);
  }, [journey, stepIndex, values]);

  useEffect(() => {
    api(`/api/journey/${journeyId}`).then((j) => {
      setJourney(j);
      setTranscript([{ role: "assistant", content: j.greeting }]);
      setValues(defaults(j.steps[0]));
    });
  }, [journeyId]);

  // While the advisor is thinking/generating, keep the latest chat in view (bottom).
  useEffect(() => {
    if ((busy || genReport) && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
  }, [busy, genReport]);

  // When a new step form / analysis / report appears, scroll it to the TOP so the
  // user starts at the first field instead of the middle of the form.
  useEffect(() => {
    const b = bodyRef.current, an = anchorRef.current;
    if (!b || !an) return;
    requestAnimationFrame(() => { b.scrollTop = Math.max(0, an.offsetTop - 6); });
  }, [stepIndex, report, genReport, journeyId]);

  if (!journey) return <div className="page"><div className="center-load"><div className="spinner" /> Loading advisor…</div></div>;

  const steps = journey.steps;
  const step = steps[stepIndex];
  const kind = step ? (step.kind || "form") : "form";
  const totalStops = steps.length + 1;
  const progress = Math.round(((report ? totalStops : stepIndex + 1) / totalStops) * 100);
  const stageLabel = report ? (journey.report_label || "Report") : step.label;

  const goNext = (afterTranscript) => {
    setStepError("");
    if (stepIndex + 1 < steps.length) {
      const ni = stepIndex + 1; setStepIndex(ni); setValues(defaults(steps[ni]));
    } else {
      runReport(afterTranscript || transcript);
    }
  };

  // Format checks (e.g. UEN) across the step's fields. Returns "" or an error string.
  const validationError = () => {
    for (const f of (step.fields || [])) {
      const e = fieldError(f, values[f.name]);
      if (e) return e;
    }
    return "";
  };

  const submitStep = async () => {
    // Gate: required file uploads must be present (with parsed text) before continuing.
    const missing = (step.fields || []).filter(
      (f) => f.type === "file" && f.required && !(values[f.name] && values[f.name].text && values[f.name].text.trim())
    );
    if (missing.length) {
      setStepError(`Please upload ${missing.map((f) => f.label).join(" and ")} (a readable CV) to continue.`);
      return;
    }
    const verr = validationError();
    if (verr) { setStepError(verr); return; }
    setStepError("");
    setBusy(true);
    collected.current = { ...collected.current, ...flatten(values) };
    const summary = summarize(step, values);
    const next = [...transcript, { role: "user", content: summary }];
    setTranscript(next);
    let res;
    try {
      res = await api("/api/chat", { method: "POST", body: JSON.stringify({ journey: journeyId, step: step.id, collected: collected.current, transcript: next, message: summary, provider }) });
    } catch (e) { res = { reply: "Recorded. Let's continue." }; }
    const after = [...next, { role: "assistant", content: res.reply }];
    setTranscript(after);
    setBusy(false);
    goNext(after);
  };

  // Voice advance: move to the next step WITHOUT injecting the typed-flow summary
  // message or a canned chat reply (the advisor already speaks). Keeps the
  // transcript clean and correctly ordered. Returns {ok, msg} for the model.
  const advanceVoiceStep = () => {
    const missing = (step.fields || []).filter(
      (f) => f.type === "file" && f.required && !(values[f.name] && values[f.name].text && values[f.name].text.trim())
    );
    if (missing.length) {
      setVoiceReview(false);
      return { ok: false, msg: `Ask the user to upload ${missing.map((f) => f.label).join(" and ")} before continuing.` };
    }
    const verr = validationError();
    if (verr) { setStepError(verr); return { ok: false, msg: `${verr} Ask the user to provide a valid value.` }; }
    setStepError("");
    collected.current = { ...collected.current, ...flatten(values) };
    goNext(transcript);
    return { ok: true, msg: "Advanced to the next step." };
  };

  // Keep a fresh snapshot for voice callbacks (avoids stale closures in ws handlers).
  live.current = { step, values, setValues, submitStep, advanceVoiceStep, stepIndex };

  const continueAnalysis = (summary) => {
    let after = transcript;
    const s = typeof summary === "string" ? summary.trim() : "";
    const label = step.label.replace(/^\d+\.\s*/, "");
    after = [...transcript, { role: "assistant", content: s ? `${label} generated. ${s}` : `${label} generated.` }];
    setTranscript(after);
    goNext(after);
  };

  const runReport = async (full) => {
    setGenReport(true);
    let rep;
    try {
      rep = await api("/api/report", { method: "POST", body: JSON.stringify({ journey: journeyId, collected: collected.current, transcript: full, provider, analyses: analyses.current }) });
    } catch (e) { rep = { title: "Report", executive_summary: "Could not generate the report.", surveys: [] }; }
    setReport(rep);
    setGenReport(false);
    // Persist the full consultation + report, keyed by the email from the first form.
    try {
      const email = collected.current.email || "";
      const s = await api("/api/session", { method: "POST", body: JSON.stringify({
        journey: journeyId, title: rep.title, email, collected: collected.current, transcript: full, report: rep }) });
      if (s && s.id) setSavedId(s.id);
    } catch (e) { /* non-fatal */ }
  };

  return (
    <div className="page wide">
      <div className="widget">
        <div className="widget-head">
          <div className="wh-top">
            <button className="wh-back" onClick={onExit} title="Choose a different journey">←</button>
            <div className="row1">
              <img src={LOGO} alt="OmniSkills" />
              <div className="title"><strong>{journey.title}</strong><span>{journey.subtitle}</span></div>
            </div>
          </div>
          <div className="progress">
            <div className="labels"><span>{stageLabel}</span><span>{progress}% complete</span></div>
            <div className="bar"><span style={{ width: progress + "%" }} /></div>
          </div>
        </div>

        <div className="widget-body" ref={bodyRef}>
          {transcript.map((m, i) => <ChatRow key={i} role={m.role} content={m.content} />)}
          {busy && !genReport && <Typing />}
          <div ref={anchorRef} className="scroll-anchor" />
          {!report && !busy && step && kind === "form" && (
            voiceOn && !voiceReview
              ? <div className="card voice-listening">
                  <span className="vl-mic" aria-hidden />
                  <div>
                    <strong>Talking with the advisor…</strong>
                    <p className="micro">Your answers are being captured by voice. I'll show the filled form here for you to review and confirm before anything is submitted.</p>
                  </div>
                </div>
              : <>
                  {voiceReview && (
                    <div className="review-banner">
                      <span>Please review the details I captured, then confirm.</span>
                      <span className="rb-actions">
                        <button className="btn primary sm" onClick={() => advanceVoiceStep()}>Confirm &amp; submit</button>
                        <button className="btn ghost sm" onClick={() => setVoiceReview(false)}>Keep editing</button>
                      </span>
                    </div>
                  )}
                  <StepForm step={step} values={values} setValues={setValues} onSubmit={submitStep} busy={busy}
                    onPick={(name, uen) => { nameLast.current = String(name || "").toUpperCase(); setNameStatus(""); setValues((prev) => ({ ...prev, company_name: name, uen: uen || prev.uen })); }}
                    extra={(f) => (f.validate === "uen" ? <UenNote status={uenStatus} /> : (f.name === "company_name" ? <NameNote status={nameStatus} /> : null))} />
                </>
          )}
          {!report && !busy && step && kind === "upload" && <UploadStep step={step} values={values} setValues={setValues} onSubmit={submitStep} busy={busy} />}
          {!report && !busy && step && kind === "analysis" && (
            <AnalysisPanel key={step.id} journeyId={journeyId} step={step} provider={provider} collected={collected} transcript={transcript} onContinue={continueAnalysis} onData={(id, d) => { analyses.current[id] = d; }} />
          )}
          {genReport && <div className="center-load"><div className="spinner" /> Generating Your Report…</div>}
          {report && <Report report={report} journeyId={journeyId} sessionId={savedId}
            onRegenerate={() => runReport(transcript)} regenerating={genReport} />}
          {report && <div className="feedback-sep"><span>Separate from the report — help us improve</span></div>}
          {report && <SurveyCard journeyId={journeyId} />}
        </div>

        {stepError && !report && <div className="step-error">{stepError}</div>}
        {voiceOn
          ? <div className={"voice-status" + (voicePaused ? " paused" : "")}>
              <span className="voice-dot" />
              <span className="voice-label">{voicePaused ? "Paused" : (voiceStatus || "Listening…")}</span>
              <span className="voice-hint">say “pause” or type anytime</span>
              <span className="voice-actions">
                {voicePaused
                  ? <button className="voice-mini go" onClick={resumeVoice}>▶ Resume</button>
                  : <button className="voice-mini" onClick={pauseVoice}>❚❚ Pause</button>}
                <button className="voice-mini end" onClick={stopVoice}>■ End</button>
              </span>
            </div>
          : (voiceStatus && <div className="voice-status err">{voiceStatus}</div>)}

        {!report && step && kind !== "analysis" && (
          <div className="input-bar">
            {voiceEnabled && (
              <button className={"mic-btn" + (voiceOn ? " on" : "")} onClick={() => (voiceOn ? stopVoice() : startVoice())}
                title={voiceOn ? "Stop voice" : "Talk to the advisor"} aria-label={voiceOn ? "Stop voice" : "Talk to the advisor"}>
                {voiceOn ? "■" : "🎤"}
              </button>
            )}
            <input placeholder={"Type a detail or ask the advisor about " + step.label.replace(/^\d+\.\s*/, "").toLowerCase() + "…"}
              onKeyDown={(e) => { if (e.key === "Enter" && e.target.value.trim()) { askInline(e.target.value.trim(), setTranscript, transcript, journeyId, step, collected, provider); e.target.value = ""; } }} />
            <button className="btn primary" onClick={() => (voiceOn ? advanceVoiceStep() : submitStep())} disabled={busy}>Continue</button>
          </div>
        )}
      </div>
    </div>
  );
}

async function askInline(text, setTranscript, transcript, journeyId, step, collected, provider) {
  const next = [...transcript, { role: "user", content: text }];
  setTranscript(next);
  try {
    const res = await api("/api/chat", { method: "POST", body: JSON.stringify({ journey: journeyId, step: step.id, collected: collected.current, transcript: next, message: text, provider }) });
    setTranscript((t) => [...t, { role: "assistant", content: res.reply }]);
  } catch (e) { setTranscript((t) => [...t, { role: "assistant", content: "Noted. Fill in the form above and continue when ready." }]); }
}

/* ─────────────────────────────────────────────────────────────── helpers */
function defaults(step) {
  const v = {};
  step.fields.forEach((f) => {
    if (f.type === "file") v[f.name] = null;
    else if (f.type === "consent") v[f.name] = f.value === true;   // starts unchecked
    else if (f.value !== undefined) v[f.name] = f.value;
    else if (f.type === "multiselect") v[f.name] = [];
    else if (f.type === "select") v[f.name] = "";                  // blank placeholder
    else v[f.name] = "";
  });
  return v;
}

// Flatten values for the backend: expand file uploads into name + parsed text.
function flatten(values) {
  const out = {};
  for (const [k, v] of Object.entries(values)) {
    if (v && typeof v === "object" && !Array.isArray(v) && "text" in v) {
      out[k] = v.filename;
      if (v.text) out[k + "_text"] = v.text;
    } else {
      out[k] = v;
    }
  }
  return out;
}

function summarize(step, values) {
  const parts = step.fields.map((f) => {
    const val = values[f.name];
    if (f.type === "file") return val && val.filename ? `${f.label}: ${val.filename}${val.text ? ` (parsed ${val.text.length} chars)` : ""}` : null;
    if (f.type === "consent") return `Consent: ${val !== false ? "Given" : "Declined"}`;
    if (val === undefined || val === "" || (Array.isArray(val) && val.length === 0)) return null;
    return `${f.label}: ${Array.isArray(val) ? val.join(", ") : val}`;
  }).filter(Boolean);
  // Multi-line: first line = title, then one "Label: value" per line (rendered structured).
  return `Submitted · ${step.label}\n` + parts.join("\n");
}

/* ────────────────────────────────────────────────────────────────── App */
function App() {
  const [config, setConfig] = useState(null);
  const provider = "azure";  // fixed to Azure OpenAI (model switch removed)
  const [journeyId, setJourneyId] = useState(null);
  const [saved, setSaved] = useState(null);

  const openSaved = (id) => {
    fetch("/api/session/" + encodeURIComponent(id))
      .then((r) => (r.ok ? r.json() : null))
      .then((s) => { if (s && s.report) { setSaved(s); setJourneyId(null); try { history.replaceState(null, "", "/?session=" + s.id); } catch (e) {} } })
      .catch(() => {});
  };
  const exitSaved = () => { setSaved(null); try { history.replaceState(null, "", "/"); } catch (e) {} };
  const deleteSaved = (id) => {
    fetch("/api/session/" + encodeURIComponent(id), { method: "DELETE" }).finally(exitSaved);
  };

  useEffect(() => {
    api("/api/config").then((c) => setConfig(c));
    const sid = new URLSearchParams(location.search).get("session");
    if (sid) openSaved(sid);
  }, []);

  return (
    <>
      <TopBar />
      {saved
        ? <SavedReportView session={saved} onExit={exitSaved} onDelete={deleteSaved} />
        : !journeyId
          ? <JourneyPicker onPick={setJourneyId} onOpenSaved={openSaved} />
          : <Advisor journeyId={journeyId} provider={provider} onExit={() => setJourneyId(null)} voiceEnabled={!!(config && config.voice_enabled)} />}
    </>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
