/* screens-capture.jsx - Voice/Type capture: the R98 job intake agent (a real conversation/
   checklist hybrid over on-device Whisper + T5 extraction) + note dictation.
   Launched anywhere via CaptureCtx.open({ target, entityLabel, prefill }). */
const DScap = window.Stand8DesignSystem_b5c975;
const CaptureCtx = React.createContext({ open: () => {} });

/* Fields the on-device LLM extracts from a freeform req brief (extractive QA, one per field).
   Order = priority: the agent's follow-up asks walk down this list. `question` is phrased for
   the T5 QA format and doubles as the conversational ask + the pending-row hint. Extraction
   missing a field is normal - the checklist stays hand-fillable and nothing blocks on the AI. */
const REQ_FIELDS = [
  { key: 'role', label: 'Role', question: 'What is the job title?' },
  { key: 'client', label: 'Client', question: 'What company is hiring?' },
  { key: 'openings', label: 'Openings', question: 'How many positions are open?' },
  { key: 'rate', label: 'Rate band', question: 'What is the hourly pay rate?' },
  { key: 'skills', label: 'Must-have skills', question: 'What skills or technologies are required?' },
  { key: 'workModel', label: 'Work model', question: 'What is the work arrangement (remote, hybrid, or onsite)?' },
  { key: 'location', label: 'Location', question: 'What city is the job located in?' },
  { key: 'start', label: 'Target start', question: 'When does the role start?' },
  { key: 'duration', label: 'Duration', question: 'How long is the contract?' },
  { key: 'priority', label: 'Priority', question: 'How urgent is this role?' },
  { key: 'workAuth', label: 'Work authorization', question: 'What work authorization is required?' },
  { key: 'hiringManager', label: 'Hiring manager', question: 'Who is the hiring manager?' },
];

/* Record the mic -> on-device Whisper transcript. Isolated as a hook so the req intake can chain
   transcription into LLM field extraction. Surfaces an 'error' phase (no mic / model can't run)
   so the caller can offer the Type fallback. */
function useDictation() {
  const [phase, setPhase] = React.useState('idle'); // idle | recording | transcribing | done | error
  const [secs, setSecs] = React.useState(0);
  const [detail, setDetail] = React.useState('');
  const [transcript, setTranscript] = React.useState('');
  const recRef = React.useRef(null);
  const chunksRef = React.useRef([]);
  const streamRef = React.useRef(null);
  const timerRef = React.useRef(null);
  const cancelledRef = React.useRef(false);
  const stopTracks = () => { try { if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; } } catch (e) {} };
  const stopTimer = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } };
  React.useEffect(() => () => { cancelledRef.current = true; stopTimer(); stopTracks(); try { if (recRef.current && recRef.current.state === 'recording') recRef.current.stop(); } catch (e) {} }, []);
  const reset = () => { stopTimer(); stopTracks(); setPhase('idle'); setSecs(0); setDetail(''); setTranscript(''); };
  /* Abandon an in-flight recording (e.g. the user switches Talk -> Type): kills the mic NOW
     and guarantees the pending onstop -> transcribe chain never fires its callback. */
  const cancel = () => { cancelledRef.current = true; stopTimer(); try { if (recRef.current && recRef.current.state === 'recording') recRef.current.stop(); } catch (e) {} stopTracks(); setPhase('idle'); setSecs(0); setDetail(''); setTranscript(''); };
  const start = async (onDone) => {
    cancelledRef.current = false;
    setTranscript(''); setDetail(''); setSecs(0);
    if (!E8AI.asrSupported()) { setDetail('Recording is not supported in this browser'); setPhase('error'); return; }
    let stream;
    try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
    catch (e) { setDetail('Microphone access was blocked'); setPhase('error'); return; }
    streamRef.current = stream; chunksRef.current = [];
    let rec;
    try { rec = new MediaRecorder(stream); } catch (e) { stopTracks(); setDetail('Recording is not supported in this browser'); setPhase('error'); return; }
    recRef.current = rec;
    rec.ondataavailable = (e) => { if (e.data && e.data.size) chunksRef.current.push(e.data); };
    rec.onstop = async () => {
      stopTracks();
      if (cancelledRef.current) return;
      setDetail('Preparing audio');
      const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
      if (!blob.size) { setDetail('No audio was captured'); setPhase('error'); return; }
      try {
        const txt = await E8AI.transcribe(blob, (s) => { if (!cancelledRef.current) setDetail(s.detail || ''); });
        if (cancelledRef.current) return;
        setTranscript(txt || ''); setPhase('done');
        if (onDone) onDone(txt || '');
      } catch (e) { if (!cancelledRef.current) { setDetail('On-device transcription could not run here'); setPhase('error'); } }
    };
    rec.start(); setPhase('recording');
    timerRef.current = setInterval(() => setSecs((s) => s + 1), 1000);
  };
  const stop = () => { stopTimer(); try { if (recRef.current && recRef.current.state === 'recording') { setPhase('transcribing'); recRef.current.stop(); } } catch (e) { setDetail('Could not stop the recorder'); setPhase('error'); } };
  return { phase: phase, secs: secs, detail: detail, transcript: transcript, start: start, stop: stop, reset: reset, cancel: cancel };
}

/* ---- Job intake helpers: fuzzy client match, dedup vs open jobs, row builders. ---- */
function capNorm(s) { return String(s || '').trim().toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' '); }

/* Value pipeline (a raw extractFields answer flows through, in order):
     1. tidyValue + echo/refusal regexes in ai.jsx (extractFields, ~:260) - first-pass clean
     2. capCleanValue here - strips the QA model's framing prefixes ("located in ...")
     3. capBadValue here - length floor, refusal prose, short-field dump cap, enum exemption,
        parrot-of-question, all-stopwords -> if bad, the field stays honestly pending.
   ai.jsx ALSO has its own refusal/echo regexes; the two sets overlap and can drift - keep
   them in sync if you tune one. A refusal is worse than a miss, so we err toward pending. */
const CAP_STOPWORDS = new Set(['and', 'or', 'the', 'a', 'an', 'of', 'in', 'on', 'to', 'is', 'are', 'was', 'be', 'what', 'who', 'when', 'where', 'how', 'it', 'that', 'this', 'with', 'for', 'at', 'as', 'by', 'from', 'not', 'yet', 'sure', 'unknown', 'tbd', 'na', 'none', 'no', 'required', 'details', 'information', 'regarding', 'unclear', 'soon']);
const CAP_ENUM_OK = {
  workModel: ['remote', 'hybrid', 'onsite', 'on site', 'in office'],
  priority: ['urgent', 'critical', 'asap', 'high', 'medium', 'normal', 'low'],
};
const CAP_SHORT_FIELDS = new Set(['openings', 'rate', 'priority', 'workModel', 'start', 'duration', 'workAuth', 'availability', 'title', 'lastRole']);
function capBadValue(v, f) {
  const t = String(v || '').trim();
  const key = (f && f.key) || '';
  if (t.length < 2 && !(key === 'openings' && /^\d$/.test(t))) return true;
  if (/(i'?m sorry|don'?t (have|know)|not enough (information|context)|does not (provide|specify|mention|say|state)|cannot answer|can'?t answer|please provide|unable to (answer|determine)|not (related|relevant|applicable)|not (in|within|part of) the context|no (relevant )?(information|answer|mention))/i.test(t)) return true;
  const norm = capNorm(t);
  if (!norm) return true;
  const words = norm.split(' ');
  if (CAP_SHORT_FIELDS.has(key) && words.length > 6) return true; // a rephrased-brief dump, not an answer
  const allow = CAP_ENUM_OK[key];
  // Exempt a short canonical enum answer ("hybrid") from the parrot check, but only when it is
  // genuinely short - a long value that merely contains one enum word ("hybrid, $110/hr, with
  // a priority for Python") is a dump, so let it fall through to the checks below.
  if (allow && words.length <= 4 && allow.filter((a) => norm.indexOf(a) !== -1).length === 1) return false;
  if (capNorm((f && f.question) || '').indexOf(norm) !== -1) return true; // parroted the question
  if (words.every((w) => CAP_STOPWORDS.has(w))) return true; // no content words
  return false;
}
/* Trim the QA model's verbose framing off a captured value ("located in Portland" ->
   "Portland", "role starts on August 3" -> "August 3"). Loops so stacked prefixes fall too. */
function capCleanValue(v, key) {
  let t = String(v || '').trim();
  for (let i = 0; i < 3; i++) {
    const next = t.replace(/^(?:located in|location is|based in|(?:the )?candidate (?:lives?|is (?:based|located)) in|(?:they )?live[sd]? in|(?:the )?(?:role|position|job) (?:is|starts?(?: in| on| at)?)|starts?(?: in| on| at)|work arrangement is|it is|it'?s|the candidate is)\s+/i, '');
    if (next === t) break;
    t = next;
  }
  // The QA model often answers "who is the client" with the location tacked on
  // ("Nordstrom in Seattle") - keep just the company name so it matches/creates cleanly.
  if (key === 'client') t = t.replace(/\s+in\s+[A-Z][a-zA-Z.\- ]+$/, '').trim();
  return t.trim();
}
function capMatchClient(name) {
  const n = capNorm(name);
  if (!n) return null;
  // Bidirectional contains - fuzzy enough for "FedEx" vs "FedEx Ground". At real scale this
  // could false-link short names (e.g. "GE" into "General Electric"); acceptable for the demo.
  return ((window.E8DATA || {}).clients || []).find((c) => {
    const cn = capNorm(c.name);
    return cn === n || cn.indexOf(n) !== -1 || n.indexOf(cn) !== -1;
  }) || null;
}
function capFindDupJob(role, clientName) {
  const r = capNorm(role); const c = capNorm(clientName);
  if (!r || !c) return null;
  return ((window.E8DATA || {}).jobs || []).find((j) => {
    const jt = capNorm(j.title);
    return capNorm(j.client) === c && (jt.indexOf(r) !== -1 || r.indexOf(jt) !== -1);
  }) || null;
}
/* The QA model answers "how many positions" in prose ("two open positions") - pull the
   count out of digits or number words, defaulting to 1. */
function capOpenings(s) {
  const t = String(s || '').toLowerCase();
  const d = t.match(/\d+/);
  if (d) return Math.max(1, parseInt(d[0], 10));
  const words = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10 };
  const w = t.match(/\b(one|two|three|four|five|six|seven|eight|nine|ten)\b/);
  return w ? words[w[1]] : 1;
}
/* Map the captured field strings onto the real jobs row shape (same one the client
   New-job modal writes) - extras like duration/workAuth ride along on the record. */
function capJobRow(f, clientName) {
  const D = window.E8DATA || {};
  let jn = 44900 + ((D.jobs || []).length);
  while ((D.jobs || []).some((r) => r.id === 'JO-' + jn)) jn++;
  const skills = String(f.skills || '').split(/,|;|\/|\band\b/i).map((s) => s.trim()).filter(Boolean);
  let rate = String(f.rate || '').trim();
  if (/^\d+(\s*[-–]\s*\d+)?$/.test(rate)) rate = '$' + rate.replace(/\s+/g, '') + '/hr';
  const wm = String(f.workModel || '').trim().toLowerCase();
  let location = String(f.location || '').trim();
  // Honesty rule: only derive a location we actually know. 'Remote' comes from a captured
  // work model; otherwise leave it TBD rather than inventing a city the brief never said.
  if (!location) location = wm === 'remote' ? 'Remote' : 'TBD';
  if (wm && wm !== 'remote' && location.toLowerCase().indexOf(wm) === -1) location += ' · ' + wm;
  const pr = String(f.priority || '').toLowerCase();
  const priority = /critical|urgent|asap|high|top/.test(pr) ? 1 : /low|when(ever)? possible/.test(pr) ? 3 : 2;
  const title = String(f.role || '').trim() || 'New requisition';
  const loc = capBuildLoc(f); // R99: a typed loc when the work model was captured
  const row = {
    id: 'JO-' + jn, title, client: clientName || 'New client', location, type: 'Contract',
    rate: rate || 'TBD', day: 0, days: 30, openings: capOpenings(f.openings), filled: 0,
    priority, owner: (D.user || {}).name || 'You', skills,
    start: String(f.start || '').trim(), duration: String(f.duration || '').trim(),
    workAuth: String(f.workAuth || '').trim(), hiringManager: String(f.hiringManager || '').trim(),
    pipeline: Object.fromEntries((D.pipelineStages || []).map((s) => [s.key, 0])),
    health: { label: 'New', tone: 'info', note: 'Captured with Req intake - sourcing not started' },
    next: { label: 'Kick off sourcing', toast: 'Matching queued for ' + title },
  };
  if (loc) row.loc = loc;
  return row;
}
/* A minimal new-client row matching the clients seed shape (created alongside the job
   when the captured client matches nothing). */
function capClientRow(name) {
  const D = window.E8DATA || {};
  let id = 'cl-' + capNorm(name).replace(/\s+/g, '-');
  while ((D.clients || []).some((c) => c.id === id)) id += '-x';
  return {
    id, name: String(name).trim(), industry: 'New', tier: 'Emerging', since: '2026',
    health: { label: 'New', tone: 'neutral', note: 'Created from req intake - first job order just opened' },
    openJobs: 1, consultants: 0, renewals: 0, ar: { label: 'Current', tone: 'success' },
    csm: (D.user || {}).name || 'You', lastTouch: 'Just now', terms: 'Net 30', msa: 'Not signed yet',
  };
}

/* ---- R99: conditional location follow-ups + JD draft + hand-off to the structured form. ---- */
const CAP_WEEK = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
/* Once the work model is captured, the agent asks ONE typed follow-up: hybrid -> which days on-site,
   remote -> which timezone/hours, onsite -> the office address. Stored as a hidden extra field. */
function capLocFollowup(workModel) {
  const wm = String(workModel || '').toLowerCase();
  if (!wm) return null;
  if (wm.indexOf('hybrid') !== -1) return { key: 'onsiteDays', label: 'On-site days', question: 'Which days should the person be on-site?' };
  if (wm.indexOf('remote') !== -1) return { key: 'timezone', label: 'Timezone or hours', question: 'What timezone or working hours should they keep?' };
  if (wm.indexOf('onsite') !== -1 || wm.indexOf('on site') !== -1 || wm.indexOf('in office') !== -1 || wm.indexOf('in-office') !== -1) return { key: 'address', label: 'Office address', question: 'What is the office address?' };
  return null;
}
function capWorkType(workModel) {
  const wm = String(workModel || '').toLowerCase();
  if (wm.indexOf('remote') !== -1) return 'remote';
  if (wm.indexOf('hybrid') !== -1) return 'hybrid';
  if (wm.indexOf('onsite') !== -1 || wm.indexOf('on site') !== -1 || wm.indexOf('in office') !== -1 || wm.indexOf('in-office') !== -1) return 'onsite';
  return '';
}
/* "Monday through Wednesday" / "Mon, Tue" / "Mon-Wed" -> ['Mon','Tue','Wed'] (week order kept).
   Handles an inclusive range ("through"/"to"/"thru"/"-"/"until") between two named days; otherwise
   picks out each day literally mentioned. */
function capParseDays(s) {
  const t = String(s || '').toLowerCase();
  const DAYS = [['mon', 'Mon'], ['tue', 'Tue'], ['wed', 'Wed'], ['thu', 'Thu'], ['fri', 'Fri']];
  const idxOf = (frag) => DAYS.findIndex(([f]) => f === frag);
  const range = t.match(/\b(mon|tue|wed|thu|fri)[a-z]*\s*(?:through|thru|to|until|[-–])\s*(mon|tue|wed|thu|fri)[a-z]*/);
  if (range) {
    const a = idxOf(range[1]), b = idxOf(range[2]);
    if (a !== -1 && b !== -1) { const lo = Math.min(a, b), hi = Math.max(a, b); return DAYS.slice(lo, hi + 1).map(([, l]) => l); }
  }
  const out = [];
  DAYS.forEach(([frag, label]) => { if (t.indexOf(frag) !== -1 && out.indexOf(label) === -1) out.push(label); });
  return out;
}
/* Build the typed loc from the captured work model + conditional follow-up. */
function capBuildLoc(f) {
  const type = capWorkType(f.workModel);
  if (!type) return null;
  const loc = { type: type };
  if (type === 'remote') {
    if (String(f.timezone || '').trim()) loc.timezone = String(f.timezone).trim();
    return loc;
  }
  const addr = String(f.address || '').trim();
  if (addr) loc.address = addr;
  const locStr = String(f.location || '').trim();
  const parts = (addr || locStr).split(',').map((s) => s.trim()).filter(Boolean);
  if (parts.length >= 2) { loc.city = parts[parts.length - 2]; loc.state = (parts[parts.length - 1].match(/\b([A-Z]{2})\b/i) || [])[1] || ''; }
  else if (locStr) { loc.city = locStr; }
  if (type === 'hybrid') { loc.onsiteDays = capParseDays(f.onsiteDays); loc.daysOnsite = loc.onsiteDays.length; }
  return loc;
}
/* An honest JD synthesized ONLY from captured fields + conditional location - never invents facts.
   Marked prov: drafted when it rides into the form so the badge follows it. */
function capDraftJD(f) {
  const role = String(f.role || '').trim() || 'this role';
  const client = String(f.client || '').trim();
  const wm = String(f.workModel || '').trim();
  const loc = String(f.location || '').trim();
  const skills = String(f.skills || '').trim();
  const rate = String(f.rate || '').trim();
  const start = String(f.start || '').trim();
  const duration = String(f.duration || '').trim();
  const paras = [];
  let p1 = (client ? client + ' is hiring ' : 'We are hiring ') + 'a ' + role + (loc && capWorkType(f.workModel) !== 'remote' ? ' in ' + loc : '') + '.';
  if (skills) p1 += ' The role centers on ' + skills + '.';
  paras.push(p1);
  const p2 = [];
  if (wm) {
    let arr = 'Work arrangement: ' + wm;
    if (String(f.onsiteDays || '').trim()) arr += ' (' + String(f.onsiteDays).trim() + ' on-site)';
    else if (String(f.timezone || '').trim()) arr += ' (' + String(f.timezone).trim() + ')';
    else if (String(f.address || '').trim()) arr += ' at ' + String(f.address).trim();
    p2.push(arr + '.');
  }
  if (duration) p2.push('Duration: ' + duration + '.');
  if (rate) p2.push('Pay rate: ' + rate + '.');
  if (start) p2.push('Target start: ' + start + '.');
  if (p2.length) paras.push(p2.join(' '));
  paras.push('Drafted from the intake conversation - review and refine before publishing.');
  return paras.join('\n\n');
}
/* Map the captured fields into the { job, req, loc } prefill the structured form expects. */
function capReqToPrefill(f, clientName, jd, jdDrafted) {
  const loc = capBuildLoc(f) || { type: 'onsite' };
  const skills = String(f.skills || '').split(/,|;|\/|\band\b/i).map((s) => s.trim()).filter(Boolean);
  const openings = capOpenings(f.openings);
  const pay = (String(f.rate || '').match(/\d+/) || [])[0];
  const req = {
    roleType: 'contract',
    payRate: pay ? parseInt(pay, 10) : null,
    duration: String(f.duration || '').trim(),
    mustHaves: skills,
    workAuth: String(f.workAuth || '').trim(),
    hiringManager: String(f.hiringManager || '').trim() ? { name: String(f.hiringManager).trim() } : null,
    totalOpenings: openings,
    startTarget: String(f.start || '').trim(),
    jobDescription: jd || '',
    prov: jdDrafted && jd ? { jobDescription: 'drafted' } : {},
    loc: loc,
  };
  return {
    job: { title: String(f.role || '').trim(), client: clientName, openings: openings, skills: skills, start: String(f.start || '').trim(), duration: String(f.duration || '').trim(), type: 'Contract' },
    req: req,
    loc: loc,
  };
}

/* ---- Candidate intake (5A): resume-document primary. Fields the on-device LLM extracts from a
   résumé (extractive QA, one per field). Order = priority; the checklist walks down it. Email +
   phone are also harvested by regex from the raw text (below) so dedup has reliable signals even
   when the QA model misses them. Extraction missing a field is normal - nothing blocks on the AI. */
const CAND_FIELDS = [
  { key: 'name', label: 'Name', question: "What is the candidate's full name?" },
  { key: 'email', label: 'Email', question: 'What is the email address?' },
  { key: 'phone', label: 'Phone', question: 'What is the phone number?' },
  { key: 'title', label: 'Current title', question: 'What is the current job title?' },
  { key: 'skills', label: 'Skills', question: 'What are the main skills or technologies?' },
  { key: 'rate', label: 'Rate', question: 'What is the pay rate or expected salary?' },
  { key: 'location', label: 'Location', question: 'What city does the candidate live in?' },
  { key: 'workAuth', label: 'Work authorization', question: 'What is the work authorization status?' },
  { key: 'availability', label: 'Availability', question: 'When is the candidate available to start?' },
  { key: 'lastRole', label: 'Most recent employer', question: 'What is the most recent employer?' },
  { key: 'education', label: 'Education', question: 'What is the highest degree or education?' },
];

/* Structured contacts are worth pulling deterministically, not just via the QA model - a regex over
   the raw résumé beats a 248M model at "find the email" and makes dedup dependable. */
function capHarvestEmail(t) { const m = String(t || '').match(/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/); return m ? m[0].trim() : ''; }
function capHarvestPhone(t) { const m = String(t || '').match(/(?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}/); return m ? m[0].trim().replace(/\s+/g, ' ') : ''; }
function capHarvestLinkedin(t) { const m = String(t || '').match(/linkedin\.com\/in\/([A-Za-z0-9\-_%]+)/i); return m ? m[1].toLowerCase() : ''; }
function capValidEmail(v) { return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(v || '').trim()); }
function capDigits(s) { return String(s || '').replace(/\D/g, ''); }
function capLast10(s) { const d = capDigits(s); return d.length >= 10 ? d.slice(-10) : d; }
/* Detect a binary blob masquerading as text (readAsText on a PDF): a control-char ratio over the
   threshold means we could not read words out of it, so we point the user at paste-the-text. */
function capLooksBinary(s) {
  if (!s) return true;
  const sample = String(s).slice(0, 4000);
  if (!sample.length) return true;
  let bad = 0;
  for (let i = 0; i < sample.length; i++) { const c = sample.charCodeAt(i); if (c === 0 || c === 0xFFFD || (c < 32 && c !== 9 && c !== 10 && c !== 13)) bad++; }
  return (bad / sample.length) > 0.05;
}

/* Duplicate search over existing candidate profiles (matches). Priority order = signal strength:
   email exact > phone last-10 > linkedin handle (all HARD - the plain Create is gated); then a
   name match, corroborated by employer/city when possible (SOFT - a warning that still allows
   Create). Returns { record, reason, hard } or null. Re-runs every render as name/email/phone edit. */
function capFindDupCandidate(f) {
  const D = window.E8DATA || {};
  const pool = (D.matches || []);
  const email = String(f.email || '').trim().toLowerCase();
  const phone10 = capLast10(f.phone);
  // Reduce a full URL or a bare handle to a bare handle so both sides compare cleanly.
  const liHandle = (x) => capHarvestLinkedin(x) || String(x || '').trim().toLowerCase().replace(/^@/, '');
  const li = liHandle(f.linkedin);
  const name = capNorm(f.name);
  const employer = capNorm(f.lastRole);
  const city = capNorm(String(f.location || '').split(',')[0]);
  if (email) { const m = pool.find((r) => String(r.email || '').trim().toLowerCase() === email); if (m) return { record: m, reason: 'same email · ' + m.email, hard: true }; }
  if (phone10 && phone10.length === 10) { const m = pool.find((r) => capLast10(r.phone) === phone10); if (m) return { record: m, reason: 'same phone · ' + (m.phone || phone10), hard: true }; }
  // LinkedIn dedup: seed rows carry only a boolean, so this matches other resume-captured
  // candidates that stored a handle in linkedinUrl. Both sides reduce to a bare handle.
  if (li) { const m = pool.find((r) => { const rli = liHandle(r.linkedinUrl || (typeof r.linkedin === 'string' ? r.linkedin : '')); return rli && rli === li; }); if (m) return { record: m, reason: 'same LinkedIn · ' + li, hard: true }; }
  if (name) {
    const named = pool.filter((r) => capNorm(r.name) === name);
    if (named.length) {
      const corro = named.find((r) => (employer && capNorm(r.company) === employer) || (city && capNorm(r.location).indexOf(city) !== -1));
      if (corro) return { record: corro, reason: 'same name at ' + (corro.company || corro.location), hard: false };
      return { record: named[0], reason: 'same name · ' + named[0].name, hard: false };
    }
  }
  return null;
}

/* A short, honest summary synthesized ONLY from captured fields (never invents facts). */
function capCandSummary(f) {
  const t = String(f.title || '').trim();
  const co = String(f.lastRole || '').trim();
  const loc = String(f.location || '').trim();
  const bits = [];
  if (t) bits.push(t + (co ? ' at ' + co : ''));
  if (loc) bits.push('based in ' + loc);
  let s = bits.join(', ');
  if (s) s = s.charAt(0).toUpperCase() + s.slice(1) + '.';
  const skills = String(f.skills || '').trim();
  if (skills) s += (s ? ' ' : '') + 'Skills: ' + skills + '.';
  return s || undefined;
}
/* Map captured field strings + the raw résumé onto the matches profile shape (mirrors
   qualifyApplicantRecord's profile row); a paired candidates status row is built alongside. */
function capCandRow(f, rawResume) {
  const D = window.E8DATA || {};
  const name = String(f.name || '').trim() || 'New candidate';
  let base = 'c-' + capNorm(name).replace(/\s+/g, '-').slice(0, 24);
  if (base === 'c-') base = 'c-candidate';
  let id = base, n = 2;
  while ((D.matches || []).some((r) => r.id === id) || (D.candidates || []).some((r) => r.id === id)) id = base + '-' + (n++);
  const skills = String(f.skills || '').split(/,|;|\/|\band\b/i).map((s) => s.trim()).filter(Boolean);
  let rate = String(f.rate || '').trim();
  if (/^\d+(\.\d+)?$/.test(rate)) rate = '$' + rate + '/hr';
  const email = String(f.email || '').trim();
  const phone = String(f.phone || '').trim();
  return {
    id, state: 'review', stage: 'source', tier: 3,
    name, title: String(f.title || '').trim() || 'Candidate',
    company: String(f.lastRole || '').trim() || 'Independent',
    location: String(f.location || '').trim() || 'Unknown',
    email: email || undefined, phone: phone || undefined,
    // linkedin stays a boolean for the display icon; linkedinUrl keeps the handle for dedup.
    rate: rate || undefined, linkedin: !!(f.linkedin || capHarvestLinkedin(rawResume)), linkedinUrl: (f.linkedin || capHarvestLinkedin(rawResume)) || undefined, skills, reasons: [],
    workAuth: String(f.workAuth || '').trim() || undefined,
    availability: String(f.availability || '').trim() || undefined,
    education: String(f.education || '').trim() || undefined,
    aiSummary: capCandSummary(f),
    resumeText: String(rawResume || '').trim() || undefined,
  };
}
function capCandStatusRow(id, f) {
  const D = window.E8DATA || {};
  return { id, status: 'Sourced', source: 'Résumé', availability: String(f.availability || '').trim() || 'Unknown', owner: (D.user || {}).name || 'You', last: 'Just now', lastBy: 'human', lastNote: 'Captured from résumé' };
}

/* One row of the live checklist. Three states: pending (question as hint), reading (that
   field's extraction call is running), captured (value + source chip + provenance badge on
   AI-filled values). Click / Enter / Space edits inline at any time; a hand edit clears the
   badge and the field is never overwritten by later extraction. */
function CapReqField({ f, val, src, reading, onCommit }) {
  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState('');
  const has = String(val == null ? '' : val).trim() !== '';
  const begin = () => { setDraft(has ? String(val) : ''); setEditing(true); };
  const commit = () => { setEditing(false); onCommit(f.key, draft); };
  if (editing) {
    return (
      <div className="e8-cap-f">
        <div className="e8-cap-f-k">{f.label}</div>
        <input
          className="e8-cap-f-i" autoFocus value={draft} aria-label={f.label} placeholder={f.question}
          onChange={(e) => setDraft(e.target.value)}
          onBlur={commit}
          onKeyDown={(e) => {
            if (e.key === 'Enter') { e.preventDefault(); commit(); }
            if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setEditing(false); }
          }}
        />
      </div>
    );
  }
  return (
    <div
      className={'e8-cap-f e8-cap-f-click' + (has || reading ? '' : ' pending')}
      role="button" tabIndex={0} title={'Edit ' + f.label.toLowerCase()}
      onClick={begin}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); begin(); } }}
    >
      <div className="e8-cap-f-k">{f.label}</div>
      <div className="e8-cap-f-v">
        {has ? (
          <React.Fragment>
            <span style={{ minWidth: 0, overflowWrap: 'anywhere' }}>{String(val)}</span>
            {src ? <span className="e8-cap-heard">{src}</span> : null}
            {src ? <DScap.ProvenanceBadge kind="drafted" /> : null}
          </React.Fragment>
        ) : reading ? (
          <span className="e8-cap-reading">reading<span className="e8-cap-pulse" /></span>
        ) : (
          <span>{f.question}</span>
        )}
      </div>
    </div>
  );
}

/* The job intake agent: a multi-turn conversation (talk via Whisper, or type/paste) on the
   left, the live field checklist on the right, then a review step that creates the job order
   directly or sends the draft to Approvals. The AI only ever drafts - every value is editable,
   sparse captures still go through, and nothing blocks on a model. */
function CaptureReq({ cfg, onClose, onRequestClose, dirtyRef }) {
  const { showToast } = React.useContext(E8Ctx);
  const prefill = (cfg && cfg.prefill) || {};
  const [mode, setMode] = React.useState('talk');
  const [step, setStep] = React.useState('capture'); // capture | review
  const [turns, setTurns] = React.useState(() => [{
    who: 'ai',
    text: (prefill.client ? 'Capturing a job order for ' + prefill.client + '. ' : '')
      + 'Describe the role - talk or type, and I fill the checklist as you go. '
      + (prefill.client ? REQ_FIELDS[0].question : 'You can also edit any field directly.'),
  }]);
  const [draftText, setDraftText] = React.useState('');
  const [fieldVals, setFieldVals] = React.useState(() => (prefill.client ? { client: prefill.client } : {}));
  const [fieldSrc, setFieldSrc] = React.useState({}); // key -> 'heard' | 'typed' (AI-extracted values only)
  const [busy, setBusy] = React.useState(false);
  const [readingKey, setReadingKey] = React.useState(null);
  const [jd, setJd] = React.useState(null); // R99: an optional AI-drafted JD (null until drafted)
  const dict = useDictation();
  /* Refs mirror state so the async extraction merge always sees current hand edits. */
  const valsRef = React.useRef(fieldVals); valsRef.current = fieldVals;
  const editedRef = React.useRef(new Set(prefill.client ? ['client'] : []));
  const turnsRef = React.useRef(null);
  /* aliveRef stops a mid-flight extraction from merging/speaking after the sheet closes;
     busyRef gates re-entrancy (a Talk onDone closure holds a stale `busy`); askedRef caps
     how many times the agent re-asks a field the model keeps missing. */
  const aliveRef = React.useRef(true);
  const busyRef = React.useRef(false);
  const askedRef = React.useRef({});
  React.useEffect(() => () => { aliveRef.current = false; }, []);
  React.useEffect(() => { const el = turnsRef.current; if (el) el.scrollTop = el.scrollHeight; }, [turns, busy]);
  /* Dirty signal for confirm-on-close: a real conversation turn, a captured field, or a draft. */
  React.useEffect(() => {
    if (!dirtyRef) return;
    const userFields = Object.keys(fieldVals).some((k) => !(k === 'client' && prefill.client));
    dirtyRef.current = !!(turns.some((t) => t.who === 'me') || userFields || draftText.trim() || step === 'review');
  }, [turns, fieldVals, draftText, step, dirtyRef]);

  const missingOf = (vals) => REQ_FIELDS.filter((fd) => String(vals[fd.key] == null ? '' : vals[fd.key]).trim() === '');
  const say = (text) => setTurns((p) => [...p, { who: 'ai', text }]);

  /* One utterance/paste in: extract ONLY the still-missing fields from the NEW text, merge
     non-empty answers (never over a hand-edited field), then ask one targeted follow-up for
     the highest-priority missing field. Template questions from the schema - no LLM for the ask. */
  const ingest = async (text, via) => {
    const t = String(text || '').trim();
    if (!t || busyRef.current) return;
    setTurns((p) => [...p, { who: 'me', text: t }]);
    // R99: once the work model is known, a hidden conditional field (on-site days / timezone /
    // address) joins the extraction targets so the agent can pull it from the reply too.
    const cond = capLocFollowup(valsRef.current.workModel);
    const condActive = cond && String(valsRef.current[cond.key] || '').trim() === '' && !editedRef.current.has(cond.key);
    const missing = missingOf(valsRef.current).filter((fd) => !editedRef.current.has(fd.key)).concat(condActive ? [cond] : []);
    let got = {};
    if (missing.length) {
      busyRef.current = true; setBusy(true);
      try {
        const raw = await E8AI.extractFields(t, missing, (s) => {
          if (!aliveRef.current) return;
          const m = REQ_FIELDS.find((fd) => (s.detail || '') === 'Reading ' + fd.label);
          setReadingKey(m ? m.key : null);
        });
        if (!aliveRef.current) { busyRef.current = false; return; }
        Object.keys(raw || {}).forEach((k) => {
          const v = capCleanValue(raw[k], k);
          if (!editedRef.current.has(k) && v && !capBadValue(v, REQ_FIELDS.find((fd) => fd.key === k))) got[k] = v;
        });
        if (Object.keys(got).length) {
          setFieldVals((p) => Object.assign({}, p, got));
          setFieldSrc((p) => { const n = Object.assign({}, p); Object.keys(got).forEach((k) => { n[k] = via; }); return n; });
        }
      } catch (e) {
        busyRef.current = false; setBusy(false); setReadingKey(null);
        if (aliveRef.current) say('On-device extraction is not available here, so nothing was auto-filled. The checklist works by hand - click any field to fill it in.');
        return;
      }
      busyRef.current = false; setBusy(false); setReadingKey(null);
    }
    if (!aliveRef.current) return;
    const merged = Object.assign({}, valsRef.current, got);
    const left = missingOf(merged);
    const n = Object.keys(got).length;
    const gotLead = n ? 'Got ' + n + ' field' + (n === 1 ? '' : 's') + '. ' : '';
    // R99: the conditional location follow-up takes priority right after the work model lands.
    const cond2 = capLocFollowup(merged.workModel);
    const condPending = cond2 && String(merged[cond2.key] || '').trim() === '' && !editedRef.current.has(cond2.key) && (askedRef.current[cond2.key] || 0) < 2;
    if (condPending) {
      askedRef.current[cond2.key] = (askedRef.current[cond2.key] || 0) + 1;
      say((n ? gotLead : '') + cond2.question);
      return;
    }
    if (!left.length) { say('That is everything on the checklist. Review the summary and create the job order.'); return; }
    // Ask the highest-priority field not yet asked twice; once the remaining fields are all
    // exhausted, stop re-asking and point to the review step so the model can't trap the flow.
    const ask = left.find((fd) => (askedRef.current[fd.key] || 0) < 2);
    if (!ask) { say(gotLead + 'I could not get the rest from what you described - add ' + left.slice(0, 3).map((fd) => fd.label.toLowerCase()).join(', ') + ' on the review step, or create with what we have.'); return; }
    askedRef.current[ask.key] = (askedRef.current[ask.key] || 0) + 1;
    say((n ? gotLead : 'I could not pull anything new from that. ') + ask.question);
  };

  /* Hand edit: only a real change counts - it clears the AI badge and joins the never-
     overwrite set. Clearing a value returns the field to pending (and lets AI refill it). */
  const commitField = (key, raw) => {
    const v = String(raw == null ? '' : raw).trim();
    const cur = String(valsRef.current[key] == null ? '' : valsRef.current[key]).trim();
    if (v === cur) return;
    if (v) editedRef.current.add(key); else editedRef.current.delete(key);
    setFieldVals((p) => { const n = Object.assign({}, p); if (v) n[key] = v; else delete n[key]; return n; });
    setFieldSrc((p) => { if (!(key in p)) return p; const n = Object.assign({}, p); delete n[key]; return n; });
  };

  const sendTyped = () => { const t = draftText; setDraftText(''); ingest(t, 'typed'); };
  const startTalk = () => dict.start((txt) => { dict.reset(); ingest(txt, 'heard'); });

  /* ---- Review + commit ---- */
  const f = fieldVals;
  const role = String(f.role || '').trim();
  const clientRaw = String(f.client || '').trim();
  const matched = capMatchClient(clientRaw);
  const clientName = matched ? matched.name : clientRaw;
  const dup = capFindDupJob(role, clientName);
  const sparse = !role || !clientRaw || !String(f.rate || '').trim();
  const filledN = REQ_FIELDS.length - missingOf(f).length;

  const createNow = () => {
    const row = capJobRow(f, clientName);
    const newClient = !matched && clientRaw ? capClientRow(clientRaw) : null;
    if (newClient) {
      window.E8Store.add('clients', newClient);
      if (window.E8Audit) window.E8Audit.log({ agent: 'Req intake', prov: 'drafted', action: 'Created client from intake', target: newClient.name, route: 'client/' + newClient.id });
    }
    window.E8Store.add('jobs', row);
    if (window.E8Audit) window.E8Audit.log({ agent: 'Req intake', prov: 'drafted', action: 'Created job order from guided intake', target: row.id + ' · ' + row.title, route: 'job/' + row.id });
    if (window.E8Events) window.E8Events.emit('job:created', { id: row.id });
    if (showToast) showToast('Created ' + row.title + ' for ' + row.client, 'Open', () => navigate('job/' + row.id + '/overview'));
    onClose();
  };

  const sendToReview = () => {
    const row = capJobRow(f, clientName);
    const newClient = !matched && clientRaw ? capClientRow(clientRaw) : null;
    const missing = missingOf(f).map((fd) => fd.label.toLowerCase());
    window.E8Queue.enqueue({
      kind: 'Job order', icon: 'lab_profile', agent: 'Req intake', prov: 'drafted', conf: sparse ? 'low' : 'high',
      title: 'Job order draft - ' + row.title + ' · ' + row.client,
      detail: filledN + ' of ' + REQ_FIELDS.length + ' fields captured'
        + (missing.length ? ' · missing ' + missing.slice(0, 4).join(', ') + (missing.length > 4 ? '…' : '') : '')
        + '. Approving creates the job order' + (newClient ? ' and the client record' : '') + '.',
      route: 'jobs', primary: 'Create job order',
      action: { type: 'create-job', payload: { job: row, client: newClient } },
    });
    if (showToast) showToast('Draft sent to review', 'Open approvals', () => navigate('approvals'));
    onClose();
  };

  /* R99: hand off to the structured form, prefilled from what the agent captured, so the user
     finishes the rich fields (bill rate, must-have rows, interview rounds, compliance) there. */
  const continueInForm = () => {
    if (!window.e8OpenReqForm) { if (showToast) showToast('The requisition form is not available here'); return; }
    const prefill = capReqToPrefill(f, clientName, jd, !!jd);
    onClose();
    window.e8OpenReqForm({ prefill: prefill });
  };
  const draftJDNow = () => setJd(capDraftJD(f));

  return (
    <React.Fragment>
      <div className="e8-cap-head">
        <b>New job order</b>
        <span className="e8-cap-hint" style={{ marginRight: 'auto' }}>Req intake · on-device</span>
        {step === 'capture' ? (
          <div className="e8-cap-seg">
            <button type="button" className={mode === 'talk' ? 'on' : ''} aria-pressed={mode === 'talk'} onClick={() => setMode('talk')}><span className="material-symbols-outlined" style={{ fontSize: 15, verticalAlign: '-2px', marginRight: 5 }}>mic</span>Talk</button>
            <button type="button" className={mode === 'type' ? 'on' : ''} aria-pressed={mode === 'type'} onClick={() => { if (dict.phase === 'recording' || dict.phase === 'transcribing') dict.cancel(); setMode('type'); }}><span className="material-symbols-outlined" style={{ fontSize: 15, verticalAlign: '-2px', marginRight: 5 }}>keyboard</span>Type</button>
          </div>
        ) : null}
        <DScap.Button variant="ghost" size="sm" icon="close" iconOnly title="Close" onClick={onRequestClose || onClose}></DScap.Button>
      </div>
      <div className="e8-cap-body">
        {step === 'capture' ? (
          <div className="e8-cap-2col">
            <div className="e8-cap-convo">
              <div className="e8-cap-turns" ref={turnsRef}>
                {turns.map((t, i) => <div key={i} className={'e8-cap-msg ' + (t.who === 'ai' ? 'ai' : 'me')}>{t.text}</div>)}
                {busy ? (
                  <div className="e8-cap-msg ai">
                    {(readingKey ? 'Reading ' + ((REQ_FIELDS.find((fd) => fd.key === readingKey) || {}).label || 'fields').toLowerCase() : 'Reading the brief')}…<span className="e8-cap-pulse" style={{ marginLeft: 7 }} />
                  </div>
                ) : null}
              </div>
              {mode === 'talk' ? (
                <div className="e8-cap-send">
                  {dict.phase === 'idle' || dict.phase === 'done' ? (
                    <React.Fragment>
                      <DScap.Button variant="primary" size="sm" icon="mic" onClick={startTalk} disabled={busy}>{turns.some((t) => t.who === 'me') ? 'Talk again' : 'Start talking'}</DScap.Button>
                      <span className="e8-cap-hint" style={{ flex: 1 }}>Whisper transcribes and a small language model fills the checklist - all on your device, nothing leaves the browser. First use downloads the models (~40MB speech, ~150MB language).</span>
                    </React.Fragment>
                  ) : null}
                  {dict.phase === 'recording' ? (
                    <React.Fragment>
                      <span className="e8-cap-rec" style={{ marginBottom: 0 }}>Recording… <span className="tnum">{dict.secs}s</span><span className="e8-cap-pulse" /></span>
                      <DScap.Button variant="secondary" size="sm" icon="stop" onClick={dict.stop}>Stop &amp; transcribe</DScap.Button>
                    </React.Fragment>
                  ) : null}
                  {dict.phase === 'transcribing' ? (
                    <span className="e8-cap-rec" style={{ marginBottom: 0 }}>{dict.detail || 'Transcribing on-device'}…<span className="e8-cap-pulse" /></span>
                  ) : null}
                  {dict.phase === 'error' ? (
                    <React.Fragment>
                      <span className="e8-cap-hint" style={{ flex: 1 }}>{dict.detail || 'Recording could not run'}. You can type the brief instead.</span>
                      <DScap.Button variant="secondary" size="sm" icon="keyboard" onClick={() => { dict.reset(); setMode('type'); }}>Type instead</DScap.Button>
                    </React.Fragment>
                  ) : null}
                </div>
              ) : (
                <div className="e8-cap-send">
                  <textarea
                    className="e8-cap-textarea" style={{ minHeight: 64, flex: 1 }} value={draftText}
                    onChange={(e) => setDraftText(e.target.value)}
                    onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendTyped(); } }}
                    placeholder="Paste or type the brief - e.g. 'Need 2 senior Java engineers for FedEx in Memphis, hybrid, around $95/hr, must know Spring Boot and AWS, start in 3 weeks.'"
                  />
                  <DScap.Button variant="primary" size="sm" icon="send" onClick={sendTyped} disabled={busy || !draftText.trim()}>Send</DScap.Button>
                </div>
              )}
            </div>
            <div className="e8-cap-rail">
              <div className="e8-sectionlabel" style={{ marginBottom: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
                Checklist <span className="e8-cap-hint tnum">{filledN}/{REQ_FIELDS.length}</span>
              </div>
              {REQ_FIELDS.map((fd) => (
                <CapReqField key={fd.key} f={fd} val={fieldVals[fd.key]} src={fieldSrc[fd.key]} reading={busy && readingKey === fd.key} onCommit={commitField} />
              ))}
            </div>
          </div>
        ) : (
          <div>
            <div className="e8-sectionlabel" style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
              Job order summary <DScap.ProvenanceBadge kind="drafted" />
              <span className="e8-cap-hint">AI-filled values carry the badge - click any row to edit</span>
            </div>
            <div className="e8-cap-review">
              {REQ_FIELDS.map((fd) => (
                <CapReqField key={fd.key} f={fd} val={fieldVals[fd.key]} src={fieldSrc[fd.key]} reading={busy && readingKey === fd.key} onCommit={commitField} />
              ))}
            </div>
            {clientRaw ? (matched ? (
              <div className="e8-cap-note ok"><span className="material-symbols-outlined" style={{ fontSize: 15 }}>link</span><span>Linked to existing client {matched.name}.</span></div>
            ) : (
              <div className="e8-cap-note warn"><span className="material-symbols-outlined" style={{ fontSize: 15 }}>fiber_new</span><span>New client &quot;{clientRaw}&quot; will be created with this job order.</span></div>
            )) : (
              <div className="e8-cap-note warn"><span className="material-symbols-outlined" style={{ fontSize: 15 }}>help</span><span>No client captured - add one above, or send to review and finish it in Approvals.</span></div>
            )}
            {dup ? (
              <div className="e8-cap-note warn">
                <span className="material-symbols-outlined" style={{ fontSize: 15 }}>content_copy</span>
                <span>Possible duplicate: {dup.id} · {dup.title} at {dup.client} is already open. <button type="button" onClick={() => { onClose(); navigate('job/' + dup.id + '/overview'); }}>Open existing</button></span>
              </div>
            ) : null}
            {capWorkType(f.workModel) ? (
              <div className="e8-cap-note ok">
                <span className="material-symbols-outlined" style={{ fontSize: 15 }}>{capWorkType(f.workModel) === 'remote' ? 'home_work' : 'location_on'}</span>
                <span>Location: {capWorkType(f.workModel)}{String(f.onsiteDays || '').trim() ? ' · on-site ' + String(f.onsiteDays).trim() : String(f.timezone || '').trim() ? ' · ' + String(f.timezone).trim() : String(f.address || '').trim() ? ' · ' + String(f.address).trim() : String(f.location || '').trim() ? ' · ' + String(f.location).trim() : ''}</span>
              </div>
            ) : null}
            <div className="e8-cap-struct" style={{ marginTop: 12 }}>
              <div className="e8-sectionlabel" style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
                Job description {jd ? <DScap.ProvenanceBadge kind="drafted" /> : null}
              </div>
              {jd ? (
                <div>
                  <div className="e8-cap-transcript" style={{ whiteSpace: 'pre-wrap' }}>{jd}</div>
                  <div style={{ marginTop: 8 }}><DScap.Button variant="ghost" size="sm" icon="refresh" onClick={draftJDNow}>Redraft</DScap.Button></div>
                </div>
              ) : (
                <div>
                  <DScap.Button variant="secondary" size="sm" icon="auto_awesome" onClick={draftJDNow} disabled={!role}>Draft job description</DScap.Button>
                  <div className="e8-cap-hint" style={{ marginTop: 6 }}>Synthesized from the fields captured above - it rides into the full form marked as drafted.</div>
                </div>
              )}
            </div>
          </div>
        )}
      </div>
      <div className="e8-cap-foot">
        {step === 'capture' ? (
          <React.Fragment>
            <span className="e8-cap-hint" style={{ flex: 1 }}><span className="tnum">{filledN}/{REQ_FIELDS.length}</span> captured - missing fields never block, you can finish them on review.</span>
            <DScap.Button variant="primary" icon="arrow_forward" onClick={() => setStep('review')}>Review &amp; create</DScap.Button>
          </React.Fragment>
        ) : (
          <React.Fragment>
            <DScap.Button variant="ghost" size="sm" icon="arrow_back" onClick={() => setStep('capture')}>Back</DScap.Button>
            <span className="e8-cap-hint" style={{ flex: 1 }}>Finish the rich fields in the full form, or create a quick req now.</span>
            <DScap.Button variant="secondary" size="sm" icon="lab_profile" disabled={!role} onClick={continueInForm}>Continue in full form</DScap.Button>
            <DScap.Button variant="ghost" size="sm" icon="rule" onClick={sendToReview}>Send to review</DScap.Button>
            <DScap.Button variant="primary" size="sm" icon="add_task" disabled={!role || !clientRaw} onClick={createNow}>Create job order</DScap.Button>
          </React.Fragment>
        )}
      </div>
    </React.Fragment>
  );
}

/* The candidate intake agent (5A): a résumé document is the PRIMARY input - upload a .txt/.md
   (real FileReader) or paste the text, or dictate. The text feeds on-device extraction into the
   same live checklist as the job agent (CapReqField + provenance + hand-edit guard). The moment
   name/email/phone are known we search existing candidates for a likely-same person and surface a
   duplicate panel; an exact email/phone match makes duplicate-resolution the emphasized path. */
function CaptureCandidate({ cfg, onClose, onRequestClose, dirtyRef }) {
  const { showToast } = React.useContext(E8Ctx);
  const D = window.E8DATA || {};
  const jobCtx = (cfg && cfg.jobContext) || null;
  const [mode, setMode] = React.useState('resume'); // resume | paste | talk
  const [step, setStep] = React.useState('capture'); // capture | review
  const [fieldVals, setFieldVals] = React.useState({});
  const [fieldSrc, setFieldSrc] = React.useState({}); // key -> 'resume' | 'typed' | 'heard' | 'parsed'
  const [rawResume, setRawResume] = React.useState('');
  const [fileName, setFileName] = React.useState('');
  const [pasteText, setPasteText] = React.useState('');
  const [pdfWarn, setPdfWarn] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [readingKey, setReadingKey] = React.useState(null);
  const [status, setStatus] = React.useState('');
  const dict = useDictation();
  const fileRef = React.useRef(null);
  const valsRef = React.useRef(fieldVals); valsRef.current = fieldVals;
  const editedRef = React.useRef(new Set());
  const aliveRef = React.useRef(true);
  const busyRef = React.useRef(false);
  React.useEffect(() => () => { aliveRef.current = false; }, []);

  const missingOf = (vals) => CAND_FIELDS.filter((fd) => String(vals[fd.key] == null ? '' : vals[fd.key]).trim() === '');
  const filledN = CAND_FIELDS.length - missingOf(fieldVals).length;

  /* Dirty signal for confirm-on-close: any résumé text, captured/edited field, or a review step. */
  React.useEffect(() => {
    if (!dirtyRef) return;
    dirtyRef.current = !!(rawResume.trim() || pasteText.trim() || Object.keys(fieldVals).length || step === 'review');
  }, [rawResume, pasteText, fieldVals, step, dirtyRef]);

  /* Extract the still-missing fields from the résumé/paste/utterance, harvest email+phone by regex,
     merge non-empty answers (never over a hand-edited field). Nothing blocks on the model. */
  const ingest = async (text, via) => {
    const t = String(text || '').trim();
    if (!t || busyRef.current) return;
    // Deterministic contact harvest first - reliable email/phone for dedup, applied immediately so
    // the duplicate check can fire before the (slower, optional) language model even loads.
    const got = {};
    const em = capHarvestEmail(t); if (em && !editedRef.current.has('email') && !String(valsRef.current.email || '').trim()) got.email = em;
    const ph = capHarvestPhone(t); if (ph && !editedRef.current.has('phone') && !String(valsRef.current.phone || '').trim()) got.phone = ph;
    // LinkedIn handle is a hidden dedup signal (not a checklist field) - harvest it so the
    // duplicate check can use it as one of the "other ways" to spot the same person.
    const lk = capHarvestLinkedin(t); if (lk && !String(valsRef.current.linkedin || '').trim()) got.linkedin = lk;
    const gotSrc = {}; Object.keys(got).forEach((k) => { gotSrc[k] = 'parsed'; });
    if (Object.keys(got).length) {
      setFieldVals((p) => Object.assign({}, got, p)); // never over an existing/edited value
      setFieldSrc((p) => Object.assign({}, gotSrc, p));
    }
    const missing = missingOf(Object.assign({}, valsRef.current, got)).filter((fd) => !editedRef.current.has(fd.key));
    if (missing.length) {
      busyRef.current = true; setBusy(true); setStatus('Reading the résumé');
      try {
        const raw = await E8AI.extractFields(t, missing, (s) => {
          if (!aliveRef.current) return;
          const m = CAND_FIELDS.find((fd) => (s.detail || '') === 'Reading ' + fd.label);
          setReadingKey(m ? m.key : null);
        });
        if (!aliveRef.current) { busyRef.current = false; return; }
        Object.keys(raw || {}).forEach((k) => {
          if (k === 'email') { if (capValidEmail(raw[k]) && !got.email) { got.email = String(raw[k]).trim(); gotSrc.email = via; } return; }
          if (k === 'phone') { const p = capHarvestPhone(raw[k]) || String(raw[k]).trim(); if (capLast10(p).length >= 10 && !got.phone) { got.phone = p; gotSrc.phone = via; } return; }
          const v = capCleanValue(raw[k], k);
          if (!editedRef.current.has(k) && v && !capBadValue(v, CAND_FIELDS.find((fd) => fd.key === k))) { got[k] = v; gotSrc[k] = via; }
        });
      } catch (e) {
        busyRef.current = false; setBusy(false); setReadingKey(null); setStatus('');
        if (aliveRef.current) showToast('On-device extraction could not run here - the checklist works by hand, click any field to fill it in.');
        // Still keep the harvested email/phone below.
      }
      busyRef.current = false; setBusy(false); setReadingKey(null); setStatus('');
    }
    if (!aliveRef.current) return;
    // Apply everything gathered, but never clobber a field the user hand-edited in the meantime.
    const finalGot = {}; const finalSrc = {};
    Object.keys(got).forEach((k) => { if (!editedRef.current.has(k)) { finalGot[k] = got[k]; finalSrc[k] = gotSrc[k] || via; } });
    if (Object.keys(finalGot).length) {
      setFieldVals((p) => Object.assign({}, p, finalGot));
      setFieldSrc((p) => Object.assign({}, p, finalSrc));
    }
  };

  const onFile = (file) => {
    if (!file) return;
    setPdfWarn('');
    const nm = file.name || 'résumé';
    setFileName(nm);
    const isPdf = /\.pdf$/i.test(nm) || file.type === 'application/pdf';
    const reader = new FileReader();
    reader.onload = () => {
      const text = String(reader.result || '');
      if (isPdf || capLooksBinary(text)) {
        setPdfWarn('PDF text extraction is limited in-browser. Open the résumé, copy the text, and paste it below.');
        setMode('paste');
        return;
      }
      setRawResume(text);
      ingest(text, 'resume');
    };
    reader.onerror = () => { setPdfWarn('Could not read that file. Paste the résumé text below instead.'); setMode('paste'); };
    reader.readAsText(file);
  };
  const sendPaste = () => { const t = pasteText.trim(); if (!t) return; setRawResume((r) => r || t); ingest(t, 'typed'); };
  const startTalk = () => dict.start((txt) => { dict.reset(); setRawResume((r) => r || txt); ingest(txt, 'heard'); });

  const commitField = (key, raw) => {
    const v = String(raw == null ? '' : raw).trim();
    const cur = String(valsRef.current[key] == null ? '' : valsRef.current[key]).trim();
    if (v === cur) return;
    if (v) editedRef.current.add(key); else editedRef.current.delete(key);
    setFieldVals((p) => { const n = Object.assign({}, p); if (v) n[key] = v; else delete n[key]; return n; });
    setFieldSrc((p) => { if (!(key in p)) return p; const n = Object.assign({}, p); delete n[key]; return n; });
  };

  const f = fieldVals;
  const dup = capFindDupCandidate(f);
  const hasName = !!String(f.name || '').trim();
  /* R109 T2: rank the captured fields against the corpus (same matcher the review queue + qualify path
     use). A high-confidence match (the exact-signal `dup.hard`) auto-LINKS instead of minting a twin; a
     review-band match stamps the new record's possibleDuplicateOf so it flags + enters the queue. */
  const dupRanked = window.candidateMatches ? (window.candidateMatches({ name: f.name, email: f.email, phone: f.phone, linkedin: f.linkedin, company: f.lastRole, lastRole: f.lastRole, location: f.location, skills: f.skills }, {}) || []) : [];
  const dupTop = dupRanked[0];
  // Flag a separately-created record whenever a matcher hit exists (high or review), so choosing "create
  // a separate record" over the auto-link still surfaces it in the review queue for a human to resolve.
  const dupReviewIds = dupTop && dupTop.band !== 'low' ? dupRanked.filter((m) => m.band !== 'low').map((m) => m.id) : [];

  /* ---- Commit paths ---- */
  /* Mint a brand-new separate record (matches + candidates rows). Extracted so the auto-link undo can
     re-create the separate record ("splits back"), and so a review-band create stamps possibleDuplicateOf. */
  const mintSeparate = () => {
    const row = capCandRow(f, rawResume);
    window.E8Store.add('matches', row);
    window.E8Store.add('candidates', capCandStatusRow(row.id, f));
    if (dupReviewIds.length && window.E8Store.setMeta) window.E8Store.setMeta(row.id, { possibleDuplicateOf: dupReviewIds, tags: ['Possible duplicate'] });
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Added candidate from résumé', target: row.name, route: 'candidate/' + row.id });
    if (window.E8Events) window.E8Events.emit('candidate:created', { candId: row.id });
    return row;
  };
  /* R109 T2: auto-link the résumé intake onto an existing person on a high-confidence match - no twin.
     Attach a submission (when there is a job context), write a "Linked to existing candidate" timeline
     note, land on the existing record, and offer Undo that splits back into a separate record. */
  const autoLinkExisting = (rec) => {
    const noteId = 'n-caplink-' + Date.now().toString(36);
    const sid = (jobCtx && jobCtx.id) ? 's-caplink-' + rec.id : null;
    const sigText = (dup && dup.reason) ? dup.reason : 'matching contact details';
    window.E8Store.add('notes', {
      id: noteId, title: 'Linked to existing candidate',
      points: ['Résumé intake recognized as this person - matched on ' + sigText],
      refType: 'candidate', refId: rec.id, ts: Date.now(),
      author: (D.user || {}).name || 'You', prov: 'human', when: 'Just now',
    });
    if (sid && !(D.submissions || []).some((s) => s.id === sid)) {
      window.E8Store.add('submissions', { id: sid, cand: rec.name, candId: rec.id, candTitle: (rec.title || '') + ' · ' + (rec.company || ''), job: jobCtx.title, jobId: jobCtx.id, client: jobCtx.client, rate: rec.rate || '$-', stage: 0, when: 'Just now', owner: (D.user || {}).name || 'You', resp: { label: 'New', tone: 'neutral' }, open: true, stageHistory: [{ stage: 0, at: Date.now(), by: (D.user || {}).name || 'You' }] });
    }
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Linked résumé to existing candidate', target: rec.name, route: 'candidate/' + rec.id });
    if (window.E8Events) window.E8Events.emit('candidate:updated', { candId: rec.id });
    onClose();
    navigate('candidate/' + rec.id);
    showToast('Recognized ' + rec.name + ' - linked to their existing profile', 'Undo', () => {
      window.E8Store.remove('notes', noteId);
      if (sid) window.E8Store.remove('submissions', sid);
      const row = mintSeparate();
      showToast('Created a separate record for ' + row.name, 'Open', () => navigate('candidate/' + row.id));
    });
  };
  const createNow = () => {
    const row = mintSeparate();
    const undo = () => {
      window.E8Store.remove('matches', row.id);
      window.E8Store.remove('candidates', row.id);
      if (window.E8Store.removeMeta) window.E8Store.removeMeta(row.id);
      if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Removed candidate (undo)', target: row.name, route: 'candidates' });
    };
    onClose();
    // Primary toast: open the new record; job context swaps it for a submit offer. Undo (removes
    // both rows) always rides a second toast so the create is fully reversible either way.
    if (jobCtx && jobCtx.id) {
      showToast('Added ' + row.name + ' - submit to ' + (jobCtx.title || jobCtx.id) + '?', 'Submit', () => {
        const sid = 's-cap-' + row.id;
        if (!(D.submissions || []).some((s) => s.id === sid)) {
          window.E8Store.add('submissions', { id: sid, cand: row.name, candId: row.id, candTitle: (row.title || '') + ' · ' + (row.company || ''), job: jobCtx.title, jobId: jobCtx.id, client: jobCtx.client, rate: row.rate || '$-', stage: 0, when: 'Just now', owner: (D.user || {}).name || 'You', resp: { label: 'New', tone: 'neutral' }, open: true });
          if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Submitted candidate to job', target: row.name + ' → ' + (jobCtx.title || jobCtx.id), route: 'job/' + jobCtx.id });
        }
        navigate('job/' + jobCtx.id + '/pipeline');
      });
    } else {
      showToast('Added ' + row.name + ' from résumé', 'Open', () => navigate('candidate/' + row.id));
    }
    showToast('Undo add of ' + row.name + '?', 'Undo', undo);
  };
  const updateExisting = (rec) => {
    const fields = {};
    ['title', 'email', 'phone', 'rate', 'location', 'workAuth', 'availability', 'education'].forEach((k) => { const v = String(f[k] || '').trim(); if (v) fields[k] = v; });
    const skills = String(f.skills || '').split(/,|;|\/|\band\b/i).map((s) => s.trim()).filter(Boolean);
    if (skills.length) fields.skills = Array.from(new Set([].concat(rec.skills || [], skills)));
    if (rawResume.trim()) fields.resumeText = rawResume.trim();
    const snap = window.E8Store.snapshot('matches', rec.id, Object.keys(fields));
    window.E8Store.set('matches', rec.id, fields);
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: 'Updated ' + rec.name + ' from résumé', target: rec.name, route: 'candidate/' + rec.id });
    if (window.E8Events) window.E8Events.emit('candidate:updated', { candId: rec.id });
    showToast('Updated ' + rec.name + ' from résumé', 'Open', () => navigate('candidate/' + rec.id));
    if (snap) showToast('Merged résumé into ' + rec.name, 'Undo', () => window.E8Store.set('matches', rec.id, snap));
    onClose();
  };
  const openExisting = (rec) => { onClose(); navigate('candidate/' + rec.id); };

  const rail = (
    <div className="e8-cap-rail">
      <div className="e8-sectionlabel" style={{ marginBottom: 4, display: 'flex', alignItems: 'center', gap: 8 }}>
        Checklist <span className="e8-cap-hint tnum">{filledN}/{CAND_FIELDS.length}</span>
      </div>
      {CAND_FIELDS.map((fd) => (
        <CapReqField key={fd.key} f={fd} val={fieldVals[fd.key]} src={fieldSrc[fd.key]} reading={busy && readingKey === fd.key} onCommit={commitField} />
      ))}
    </div>
  );

  const dupPanel = dup ? (
    <div className={'e8-cap-dup' + (dup.hard ? ' hard' : '')} role="status" aria-live="polite">
      <div className="e8-cap-dup-head">
        <span className="material-symbols-outlined" aria-hidden="true">{dup.hard ? 'content_copy' : 'help'}</span>
        <span>Possible duplicate - {dup.reason}</span>
      </div>
      <div className="e8-cap-dup-body">
        {dup.record.name}{dup.record.title ? ' · ' + dup.record.title : ''}{dup.record.company ? ' · ' + dup.record.company : ''} is already in your candidates.
        {dup.hard ? ' High-confidence match - link this application to their profile instead of creating a second record.' : ' Create a new record only if this is a different person.'}
      </div>
      <div className="e8-cap-dup-acts">
        {/* R109 T2: on a high-confidence (exact) match, lead with the real auto-link - a second
            application recognizes the existing person, no twin. Undo splits back into a separate record. */}
        {dup.hard ? <DScap.Button variant="primary" size="sm" icon="link" onClick={() => autoLinkExisting(dup.record)}>Link to {String(dup.record.name || '').split(' ')[0] || 'existing'}</DScap.Button> : null}
        <DScap.Button variant="secondary" size="sm" icon="open_in_new" onClick={() => openExisting(dup.record)}>Open existing</DScap.Button>
        <DScap.Button variant={dup.hard ? 'ghost' : 'primary'} size="sm" icon="merge" onClick={() => updateExisting(dup.record)}>Update with this résumé</DScap.Button>
      </div>
    </div>
  ) : null;

  const talkPhase = dict.phase;
  return (
    <React.Fragment>
      <div className="e8-cap-head">
        <b>Add candidate{jobCtx ? ' · ' + (jobCtx.title || jobCtx.id) : ''}</b>
        <span className="e8-cap-hint" style={{ marginRight: 'auto' }}>Résumé intake · on-device</span>
        {step === 'capture' ? (
          <div className="e8-cap-seg">
            <button type="button" className={mode === 'resume' ? 'on' : ''} aria-pressed={mode === 'resume'} onClick={() => setMode('resume')}><span className="material-symbols-outlined" style={{ fontSize: 15, verticalAlign: '-2px', marginRight: 5 }}>description</span>Résumé</button>
            <button type="button" className={mode === 'paste' ? 'on' : ''} aria-pressed={mode === 'paste'} onClick={() => { if (dict.phase === 'recording' || dict.phase === 'transcribing') dict.cancel(); setMode('paste'); }}><span className="material-symbols-outlined" style={{ fontSize: 15, verticalAlign: '-2px', marginRight: 5 }}>content_paste</span>Paste</button>
            <button type="button" className={mode === 'talk' ? 'on' : ''} aria-pressed={mode === 'talk'} onClick={() => setMode('talk')}><span className="material-symbols-outlined" style={{ fontSize: 15, verticalAlign: '-2px', marginRight: 5 }}>mic</span>Talk</button>
          </div>
        ) : null}
        <DScap.Button variant="ghost" size="sm" icon="close" iconOnly title="Close" onClick={onRequestClose || onClose}></DScap.Button>
      </div>
      <div className="e8-cap-body">
        {step === 'capture' ? (
          <div className="e8-cap-2col">
            <div className="e8-cap-convo">
              {mode === 'resume' ? (
                <div>
                  <input ref={fileRef} type="file" accept=".txt,.md,.text,.pdf" style={{ display: 'none' }} onChange={(e) => { onFile(e.target.files && e.target.files[0]); e.target.value = ''; }} />
                  <div
                    className="e8-cap-drop" role="button" tabIndex={0}
                    onClick={() => fileRef.current && fileRef.current.click()}
                    onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileRef.current && fileRef.current.click(); } }}
                    onDragOver={(e) => { e.preventDefault(); }}
                    onDrop={(e) => { e.preventDefault(); onFile(e.dataTransfer.files && e.dataTransfer.files[0]); }}
                  >
                    <span className="material-symbols-outlined" aria-hidden="true">upload_file</span>
                    <span className="e8-cap-drop-t">{fileName ? fileName : 'Choose a résumé'}</span>
                    <span className="e8-cap-drop-s">Drop a .txt or .md résumé here, or click to browse. On-device AI reads it into the checklist - nothing leaves the browser.</span>
                  </div>
                  {pdfWarn ? <div className="e8-cap-note warn" style={{ marginTop: 10 }}><span className="material-symbols-outlined" style={{ fontSize: 15 }}>picture_as_pdf</span><span>{pdfWarn}</span></div> : null}
                  {busy ? <div className="e8-cap-msg ai" style={{ marginTop: 10 }}>{status || 'Reading the résumé'}…<span className="e8-cap-pulse" style={{ marginLeft: 7 }} /></div> : null}
                  {rawResume && !busy ? <div className="e8-cap-note ok" style={{ marginTop: 10 }}><span className="material-symbols-outlined" style={{ fontSize: 15 }}>check_circle</span><span>Résumé read · {filledN} of {CAND_FIELDS.length} fields captured. Review the checklist, then create.</span></div> : null}
                </div>
              ) : null}
              {mode === 'paste' ? (
                <div className="e8-cap-send" style={{ alignItems: 'stretch', flexDirection: 'column' }}>
                  {pdfWarn ? <div className="e8-cap-note warn"><span className="material-symbols-outlined" style={{ fontSize: 15 }}>picture_as_pdf</span><span>{pdfWarn}</span></div> : null}
                  <textarea
                    className="e8-cap-textarea" style={{ minHeight: 200 }} value={pasteText}
                    onChange={(e) => setPasteText(e.target.value)}
                    onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); sendPaste(); } }}
                    placeholder="Paste the résumé text here - name, contact details, experience, skills. The on-device model reads it into the checklist."
                  />
                  <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
                    <span className="e8-cap-hint" style={{ flex: 1 }}>Paste, then read it into the checklist. ⌘/Ctrl + Enter also works.</span>
                    <DScap.Button variant="primary" size="sm" icon="auto_awesome" onClick={sendPaste} disabled={busy || !pasteText.trim()}>Read résumé</DScap.Button>
                  </div>
                  {busy ? <div className="e8-cap-msg ai">{status || 'Reading the résumé'}…<span className="e8-cap-pulse" style={{ marginLeft: 7 }} /></div> : null}
                </div>
              ) : null}
              {mode === 'talk' ? (
                <div className="e8-cap-send">
                  {talkPhase === 'idle' || talkPhase === 'done' ? (
                    <React.Fragment>
                      <DScap.Button variant="primary" size="sm" icon="mic" onClick={startTalk} disabled={busy}>{rawResume ? 'Talk again' : 'Start talking'}</DScap.Button>
                      <span className="e8-cap-hint" style={{ flex: 1 }}>Describe the candidate out loud - Whisper transcribes and a small model fills the checklist, all on your device.</span>
                    </React.Fragment>
                  ) : null}
                  {talkPhase === 'recording' ? (
                    <React.Fragment>
                      <span className="e8-cap-rec" style={{ marginBottom: 0 }}>Recording… <span className="tnum">{dict.secs}s</span><span className="e8-cap-pulse" /></span>
                      <DScap.Button variant="secondary" size="sm" icon="stop" onClick={dict.stop}>Stop &amp; transcribe</DScap.Button>
                    </React.Fragment>
                  ) : null}
                  {talkPhase === 'transcribing' ? (
                    <span className="e8-cap-rec" style={{ marginBottom: 0 }}>{dict.detail || 'Transcribing on-device'}…<span className="e8-cap-pulse" /></span>
                  ) : null}
                  {talkPhase === 'error' ? (
                    <React.Fragment>
                      <span className="e8-cap-hint" style={{ flex: 1 }}>{dict.detail || 'Recording could not run'}. You can paste the résumé instead.</span>
                      <DScap.Button variant="secondary" size="sm" icon="content_paste" onClick={() => { dict.reset(); setMode('paste'); }}>Paste instead</DScap.Button>
                    </React.Fragment>
                  ) : null}
                  {busy ? <div className="e8-cap-msg ai" style={{ marginTop: 10, width: '100%' }}>{status || 'Reading'}…<span className="e8-cap-pulse" style={{ marginLeft: 7 }} /></div> : null}
                </div>
              ) : null}
              {dupPanel}
              <div style={{ marginTop: 14, fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>
                Prefer to type it in? <button type="button" className="e8-cap-link" onClick={() => { onClose(); if (window.e8OpenCreate) window.e8OpenCreate('candidate'); }}>Enter details manually</button>.
              </div>
            </div>
            {rail}
          </div>
        ) : (
          <div>
            <div className="e8-sectionlabel" style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
              Candidate summary <DScap.ProvenanceBadge kind="drafted" />
              <span className="e8-cap-hint">AI-filled values carry the badge - click any row to edit</span>
            </div>
            <div className="e8-cap-review">
              {CAND_FIELDS.map((fd) => (
                <CapReqField key={fd.key} f={fd} val={fieldVals[fd.key]} src={fieldSrc[fd.key]} reading={busy && readingKey === fd.key} onCommit={commitField} />
              ))}
            </div>
            {dupPanel}
          </div>
        )}
      </div>
      <div className="e8-cap-foot">
        {step === 'capture' ? (
          <React.Fragment>
            <span className="e8-cap-hint" style={{ flex: 1 }}><span className="tnum">{filledN}/{CAND_FIELDS.length}</span> captured - missing fields never block, finish them on review.</span>
            <DScap.Button variant="primary" icon="arrow_forward" onClick={() => setStep('review')}>Review &amp; create</DScap.Button>
          </React.Fragment>
        ) : (
          <React.Fragment>
            <DScap.Button variant="ghost" size="sm" icon="arrow_back" onClick={() => setStep('capture')}>Back</DScap.Button>
            <span className="e8-cap-hint" style={{ flex: 1 }}>{dup && dup.hard ? 'Exact match found - resolve the duplicate above, or create a separate record on purpose.' : 'Creates a candidate profile + pipeline row from this résumé.'}</span>
            <DScap.Button variant={dup && dup.hard ? 'ghost' : 'primary'} icon="person_add" disabled={!hasName} onClick={createNow}>{dup && dup.hard ? 'Create new anyway' : 'Create candidate'}</DScap.Button>
          </React.Fragment>
        )}
      </div>
    </React.Fragment>
  );
}

/* Split a transcript into up to 6 sentence bullets for the saved note's key points. */
function noteSentences(t) {
  const parts = String(t || '').split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter(Boolean);
  return parts.length > 1 ? parts.slice(0, 6) : [String(t || '').trim()].filter(Boolean);
}

function CaptureNote({ cfg, onClose, onRequestClose, dirtyRef }) {
  const D = window.E8DATA;
  const demo = D.captureNoteDemo;
  const { showToast } = React.useContext(E8Ctx);
  const [mode, setMode] = React.useState('talk');
  const [text, setText] = React.useState('');
  const [recPhase, setRecPhase] = React.useState('idle'); // idle | recording | transcribing | done
  const [transcript, setTranscript] = React.useState('');
  const [asrDetail, setAsrDetail] = React.useState('');
  const [secs, setSecs] = React.useState(0);
  const [usedSample, setUsedSample] = React.useState(false);
  const [aiSummary, setAiSummary] = React.useState('');
  const [summaryBusy, setSummaryBusy] = React.useState(false);
  const [summaryDetail, setSummaryDetail] = React.useState('');
  const recRef = React.useRef(null);
  const chunksRef = React.useRef([]);
  const streamRef = React.useRef(null);
  const timerRef = React.useRef(null);

  const stopTracks = () => { try { if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; } } catch (e) {} };
  const stopTimer = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } };
  React.useEffect(() => () => { stopTimer(); stopTracks(); try { if (recRef.current && recRef.current.state === 'recording') recRef.current.stop(); } catch (e) {} }, []);
  React.useEffect(() => { if (dirtyRef) dirtyRef.current = !!(text.trim() || (transcript.trim() && !usedSample)); }, [text, transcript, usedSample, dirtyRef]);

  // Graceful fallback: if there's no mic or the model can't run, show the bundled sample so the demo never dead-ends.
  const useSample = (msg) => { setUsedSample(true); setTranscript(demo.transcript); setRecPhase('done'); if (msg && showToast) showToast(msg); };

  const startRec = async () => {
    setUsedSample(false); setTranscript(''); setAsrDetail(''); setSecs(0); setAiSummary(''); setSummaryBusy(false);
    if (!E8AI.asrSupported()) { useSample('Recording not supported here - using a sample'); return; }
    let stream;
    try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
    catch (e) { useSample('Microphone blocked - using a sample'); return; }
    streamRef.current = stream;
    chunksRef.current = [];
    let rec;
    try { rec = new MediaRecorder(stream); } catch (e) { stopTracks(); useSample('Recording not supported here - using a sample'); return; }
    recRef.current = rec;
    rec.ondataavailable = (e) => { if (e.data && e.data.size) chunksRef.current.push(e.data); };
    rec.onstop = async () => {
      stopTracks();
      setAsrDetail('Preparing audio');
      const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' });
      if (!blob.size) { useSample('No audio captured - using a sample'); return; }
      try {
        const txt = await E8AI.transcribe(blob, (s) => setAsrDetail(s.detail || ''));
        setTranscript(txt || '(No speech detected)');
        setRecPhase('done');
      } catch (e) { useSample('On-device AI could not run here - using a sample'); }
    };
    rec.start();
    setRecPhase('recording');
    timerRef.current = setInterval(() => setSecs((s) => s + 1), 1000);
  };

  const stopRec = () => {
    stopTimer();
    try { if (recRef.current && recRef.current.state === 'recording') { setRecPhase('transcribing'); recRef.current.stop(); } }
    catch (e) { useSample('Could not stop the recorder - using a sample'); }
  };

  // On-device LLM: structure the dictated transcript into a one-line summary. Opt-in; falls back quietly.
  const summarize = async () => {
    setSummaryBusy(true); setSummaryDetail('');
    try {
      const s = await E8AI.summarizeNote(transcript, (st) => setSummaryDetail(st.detail || ''));
      if (s) setAiSummary(s); else if (showToast) showToast('Could not summarize this note');
    } catch (e) { if (showToast) showToast('On-device summary unavailable in this browser'); }
    finally { setSummaryBusy(false); }
  };

  const ready = mode === 'type' ? text.trim().length > 0 : (recPhase === 'done' && transcript.trim().length > 0);
  const save = () => {
    const body = mode === 'talk' ? transcript.trim() : text.trim();
    /* R98: through E8Store so the note survives reload (a bare D.notes.unshift only lived in memory).
       R104 T2: structured record key so the note lands on the record's own activity timeline, not just
       the global grid. Modality (voice/type) is split from authorship - a voice note is human-authored
       (prov 'human', modality 'voice'); it is NOT flagged as AI. entity stays for the global grid. */
    const note = {
      id: 'n-' + Date.now().toString(36),
      title: cfg.entityLabel ? ('Note - ' + cfg.entityLabel) : 'Quick note',
      source: mode === 'talk' ? 'Voice' : 'Note', icon: mode === 'talk' ? 'mic' : 'sticky_note_2',
      when: 'Just now', week: 'this', ai: false, by: D.user.name, entity: cfg.entityLabel || 'Workspace',
      points: mode === 'talk' ? (aiSummary ? [aiSummary] : noteSentences(body)) : [body],
      refType: cfg.refType || 'workspace', refId: cfg.refId || null, ts: Date.now(),
      author: D.user.name, prov: 'human', modality: mode === 'talk' ? 'voice' : 'type',
    };
    if (window.E8Store) window.E8Store.add('notes', note);
    if (window.E8Audit) window.E8Audit.log({ agent: 'You', prov: 'human', action: mode === 'talk' ? 'Saved voice note' : 'Saved note', target: note.title, route: 'notes' });
    if (showToast) showToast('Note saved' + (cfg.entityLabel ? ' to ' + cfg.entityLabel : ''), 'Open notes', () => navigate('notes'));
    onClose();
  };

  return (
    <React.Fragment>
      <div className="e8-cap-head">
        <b>Add note{cfg.entityLabel ? ' · ' + cfg.entityLabel : ''}</b>
        <div className="e8-cap-seg">
          <button type="button" className={mode === 'talk' ? 'on' : ''} aria-pressed={mode === 'talk'} onClick={() => setMode('talk')}><span className="material-symbols-outlined" style={{ fontSize: 15, verticalAlign: '-2px', marginRight: 5 }}>mic</span>Talk</button>
          <button type="button" className={mode === 'type' ? 'on' : ''} aria-pressed={mode === 'type'} onClick={() => setMode('type')}><span className="material-symbols-outlined" style={{ fontSize: 15, verticalAlign: '-2px', marginRight: 5 }}>keyboard</span>Type</button>
        </div>
        <DScap.Button variant="ghost" size="sm" icon="close" iconOnly title="Close" onClick={onRequestClose || onClose}></DScap.Button>
      </div>
      <div className="e8-cap-body">
        {mode === 'talk' ? (
          <div>
            {recPhase === 'idle' ? (
              <div style={{ textAlign: 'center', padding: '18px 8px' }}>
                <DScap.Button variant="primary" icon="mic" onClick={startRec}>Start recording</DScap.Button>
                <div style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', marginTop: 10, lineHeight: 1.5 }}>Dictate your note. Transcribed on your device with Whisper - the audio never leaves the browser. First use downloads the model (~40MB).</div>
              </div>
            ) : null}
            {recPhase === 'recording' ? (
              <div>
                <div className="e8-cap-rec">Recording… <span className="tnum">{secs}s</span><span className="e8-cap-pulse" /></div>
                <div style={{ textAlign: 'center', marginTop: 12 }}>
                  <DScap.Button variant="secondary" icon="stop" onClick={stopRec}>Stop &amp; transcribe</DScap.Button>
                </div>
              </div>
            ) : null}
            {recPhase === 'transcribing' ? (
              <div className="e8-cap-rec">{asrDetail || 'Transcribing on-device'}…<span className="e8-cap-pulse" /></div>
            ) : null}
            {recPhase === 'done' ? (
              <div>
                <div className="e8-cap-rec done"><span className="material-symbols-outlined" style={{ fontSize: 15 }}>check_circle</span>Transcribed {usedSample ? '(sample)' : 'on-device'}</div>
                <div className="e8-cap-transcript">{transcript}</div>
                <div className="e8-cap-struct">
                  <div className="e8-sectionlabel" style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>AI summary <DScap.ProvenanceBadge kind="drafted" /></div>
                  {aiSummary
                    ? <div style={{ fontSize: 'var(--ui-text-base)', fontWeight: 600 }}>{aiSummary}</div>
                    : summaryBusy
                      ? <div className="e8-cap-rec">{summaryDetail || 'Summarizing on-device'}…<span className="e8-cap-pulse" /></div>
                      : <div><DScap.Button variant="secondary" size="sm" icon="auto_awesome" onClick={summarize}>Summarize with on-device AI</DScap.Button><div style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)', marginTop: 6 }}>A small language model runs in your browser. First use downloads it (~150MB).</div></div>}
                </div>
                <div className="e8-cap-struct">
                  <div className="e8-sectionlabel" style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>Key points <DScap.ProvenanceBadge kind="drafted" /></div>
                  <ul style={{ margin: 0, paddingLeft: 18, fontSize: 'var(--ui-text-sm)', color: 'var(--ui-text-secondary)', lineHeight: 1.6 }}>
                    {noteSentences(transcript).map((p, i) => <li key={i}>{p}</li>)}
                  </ul>
                </div>
                <div style={{ marginTop: 10 }}>
                  <DScap.Button variant="ghost" size="sm" icon="refresh" onClick={startRec}>Record again</DScap.Button>
                </div>
              </div>
            ) : null}
          </div>
        ) : (
          <textarea className="e8-cap-textarea" value={text} onChange={(e) => setText(e.target.value)} placeholder="Type your note - the AI will file it on the record's timeline." />
        )}
      </div>
      <div className="e8-cap-foot">
        <span style={{ fontSize: 'var(--ui-text-xs)', color: 'var(--ui-text-tertiary)' }}>Saved to the record timeline + Notes.</span>
        <DScap.Button variant="primary" icon="save" onClick={save} disabled={!ready}>Save note</DScap.Button>
      </div>
    </React.Fragment>
  );
}

function CaptureOverlay({ cfg, onClose }) {
  const open = !!cfg;
  const capRef = React.useRef(null);
  const dirtyRef = React.useRef(false);
  const [confirming, setConfirming] = React.useState(false);
  useDialog(open, capRef);
  /* A pristine sheet closes freely; once there is unsaved user content, Esc / backdrop / the header
     close all route through a confirm. Create/finish flows call onClose directly (no confirm). */
  const requestClose = React.useCallback(() => {
    if (dirtyRef.current) setConfirming(true); else onClose();
  }, [onClose]);
  React.useEffect(() => {
    if (!open) { dirtyRef.current = false; setConfirming(false); return; }
    const onKey = (e) => { if (e.key === 'Escape' && !confirming) { e.preventDefault(); requestClose(); } };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, confirming, requestClose]);
  const childProps = { cfg, onClose, onRequestClose: requestClose, dirtyRef };
  return (
    <React.Fragment>
      <div className={'e8-cap-overlay' + (open ? ' is-open' : '')} onClick={requestClose} aria-hidden={open ? undefined : true}>
        <div ref={capRef} className="e8-cap-sheet" role="dialog" aria-modal="true" aria-label="Capture" onClick={(e) => e.stopPropagation()}>
          {cfg ? (
            cfg.target === 'req' ? <CaptureReq {...childProps} />
              : cfg.target === 'candidate' ? <CaptureCandidate {...childProps} />
                : <CaptureNote {...childProps} />
          ) : null}
        </div>
      </div>
      {confirming ? (
        <ConfirmDialog
          title="Discard this capture?"
          body="You have unsaved details in this capture. Closing will discard them."
          confirmLabel="Discard" cancelLabel="Keep editing" tone="danger" icon="delete"
          onClose={() => setConfirming(false)}
          onConfirm={() => { setConfirming(false); dirtyRef.current = false; onClose(); }}
        />
      ) : null}
    </React.Fragment>
  );
}

Object.assign(window, { CaptureCtx, CaptureOverlay, CaptureReq, CaptureCandidate, CaptureNote });
