/* app/ai.jsx - On-device AI engine (Round 47+).
   Runs real models on the user's own hardware (WASM/WebGPU) via Transformers.js loaded from a
   CDN - no server, candidate data never leaves the browser. Opt-in + persisted so the default
   app stays instant; the AI is a deliberate showcase.
   Library: Transformers.js v2 (@xenova/transformers). We pin v2 because v3's bundled ONNX
   Runtime cannot create the quantized Whisper decoder session on WASM (qdq MatMulNBits error);
   v2 runs both the embedding model AND Whisper reliably on WASM, verified end-to-end.
   Surfaces: semantic candidate search (R47, embeddings); Whisper voice->text (R48, ASR);
   a local LLM for note structuring / drafting is a later round. */
const E8AI = (function () {
  const LIB = 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2';
  const EMBED_MODEL = 'Xenova/all-MiniLM-L6-v2';      // 384-dim sentence embeddings, ~25MB
  const ASR_MODEL = 'Xenova/whisper-tiny.en';          // English speech-to-text, ~40MB (int8)
  const GEN_MODEL = 'Xenova/LaMini-Flan-T5-248M';      // instruction-tuned text2text for on-device note summaries

  // Dynamic import without letting Babel-standalone touch the import() syntax.
  const _dynImport = new Function('u', 'return import(u);');

  let _lib = null;
  let _embedder = null;
  let _embedPromise = null;
  let _transcriber = null;
  let _transcriberPromise = null;
  let _generator = null;
  let _generatorPromise = null;
  const _corpusCache = new Map(); // key -> Float32Array (so re-ranking only re-embeds the query)
  const listeners = new Set();

  const state = {
    status: 'idle',   // idle | loading | ready | error  (drives the embeddings/search UI)
    detail: '',
    progress: 0,
    error: null,
    enabled: false,
  };

  function getState() { return Object.assign({}, state); }
  function emit() { const s = getState(); listeners.forEach(function (fn) { try { fn(s); } catch (e) {} }); }
  function subscribe(fn) { listeners.add(fn); return function () { listeners.delete(fn); }; }

  function persistedOptIn() { try { return localStorage.getItem('e8-ai-ondevice') === '1'; } catch (e) { return false; } }
  function setOptIn(v) { try { v ? localStorage.setItem('e8-ai-ondevice', '1') : localStorage.removeItem('e8-ai-ondevice'); } catch (e) {} }

  async function loadLib() {
    if (_lib) return _lib;
    _lib = await _dynImport(LIB);
    try { _lib.env.allowLocalModels = false; } catch (e) {} // skip the /models/ 404 probe, go straight to the hub
    return _lib;
  }

  /* ---- Embeddings (semantic search) - status tracked on the shared `state` ---- */
  async function ensureEmbedder() {
    if (_embedder) return _embedder;
    if (_embedPromise) return _embedPromise;
    state.status = 'loading'; state.detail = 'Loading AI engine'; state.progress = 0; state.error = null; emit();
    _embedPromise = (async function () {
      try {
        const m = await loadLib();
        state.detail = 'Downloading model (one time)'; emit();
        _embedder = await m.pipeline('feature-extraction', EMBED_MODEL, {
          progress_callback: function (p) {
            try {
              if (p && typeof p.progress === 'number') { state.progress = Math.max(0, Math.min(100, Math.round(p.progress))); state.detail = 'Downloading model ' + state.progress + '%'; emit(); }
              else if (p && p.status) { state.detail = String(p.status); emit(); }
            } catch (e) {}
          },
        });
        state.status = 'ready'; state.detail = ''; state.progress = 100; emit();
        return _embedder;
      } catch (e) {
        state.status = 'error'; state.error = String((e && e.message) || e); emit();
        _embedPromise = null;
        throw e;
      }
    })();
    return _embedPromise;
  }

  async function enable() {
    setOptIn(true); state.enabled = true; emit();
    return ensureEmbedder();
  }
  function disable() { setOptIn(false); state.enabled = false; emit(); }

  function cosine(a, b) { let d = 0; for (let i = 0; i < a.length; i++) d += a[i] * b[i]; return d; } // inputs normalized

  async function embed(texts) {
    const pipe = await ensureEmbedder();
    const out = await pipe(texts, { pooling: 'mean', normalize: true });
    const d = out.dims[out.dims.length - 1];
    const n = out.dims.length > 1 ? out.dims[0] : 1;
    const vecs = [];
    for (let i = 0; i < n; i++) vecs.push(out.data.slice(i * d, (i + 1) * d));
    return vecs;
  }

  async function rank(query, docs) {
    await ensureEmbedder();
    const missing = docs.filter(function (d) { return !_corpusCache.has(d.key); });
    if (missing.length) {
      const vecs = await embed(missing.map(function (d) { return d.text; }));
      missing.forEach(function (d, i) { _corpusCache.set(d.key, vecs[i]); });
    }
    const qv = (await embed([query]))[0];
    const scored = docs.map(function (d) { return { key: d.key, score: cosine(qv, _corpusCache.get(d.key)) }; });
    scored.sort(function (a, b) { return b.score - a.score; });
    return scored;
  }

  // R79: embed a set of named facet texts with corpus-cache reuse (the matching engine's
  // primitive). facets = { facetKey: text }. Cache keys include a text hash so edited
  // profiles re-embed. Returns { facetKey: Float32Array } (missing/empty facets omitted).
  function tinyHash(s) { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return h.toString(36); }
  async function embedFacets(id, facets) {
    await ensureEmbedder();
    const entries = Object.keys(facets)
      .map(function (k) { const text = String(facets[k] || '').trim(); return { f: k, text: text, key: 'fac:' + id + ':' + k + ':' + tinyHash(text) }; })
      .filter(function (e) { return e.text; });
    const missing = entries.filter(function (e) { return !_corpusCache.has(e.key); });
    if (missing.length) {
      const vecs = await embed(missing.map(function (e) { return e.text; }));
      missing.forEach(function (e, i) { _corpusCache.set(e.key, vecs[i]); });
    }
    const out = {};
    entries.forEach(function (e) { out[e.f] = _corpusCache.get(e.key); });
    return out;
  }

  // Zero-shot-ish intent classification by nearest exemplar. labelDefs: [{ key, examples:[str] }].
  // Returns [{ key, score }] per input text (key null if below threshold). Exemplar vectors cached.
  async function classifyBatch(texts, labelDefs) {
    if (!texts || !texts.length || !labelDefs || !labelDefs.length) return [];
    await ensureEmbedder();
    const exTexts = [], exKey = [];
    labelDefs.forEach(function (d) { (d.examples || []).forEach(function (e) { exTexts.push(e); exKey.push(d.key); }); });
    const missing = exTexts.filter(function (t) { return !_corpusCache.has('cls:' + t); });
    if (missing.length) { const v = await embed(missing); missing.forEach(function (t, i) { _corpusCache.set('cls:' + t, v[i]); }); }
    const exVecs = exTexts.map(function (t) { return _corpusCache.get('cls:' + t); });
    const txVecs = await embed(texts);
    return txVecs.map(function (tv) {
      let best = { key: null, score: -1 };
      for (let i = 0; i < exVecs.length; i++) { const s = cosine(tv, exVecs[i]); if (s > best.score) best = { key: exKey[i], score: s }; }
      return best.score >= 0.33 ? best : { key: null, score: best.score };
    });
  }

  /* ---- Speech-to-text (Whisper) - reports progress via an onStatus callback so each caller
     (e.g. the capture sheet) drives its own UI without fighting the shared embeddings state. ---- */
  async function ensureTranscriber(onStatus) {
    if (_transcriber) return _transcriber;
    if (_transcriberPromise) return _transcriberPromise;
    _transcriberPromise = (async function () {
      try {
        const m = await loadLib();
        if (onStatus) onStatus({ phase: 'loading', detail: 'Loading speech model', progress: 0 });
        _transcriber = await m.pipeline('automatic-speech-recognition', ASR_MODEL, {
          progress_callback: function (p) {
            try { if (onStatus && p && typeof p.progress === 'number') onStatus({ phase: 'loading', detail: 'Downloading speech model ' + Math.round(p.progress) + '%', progress: Math.round(p.progress) }); } catch (e) {}
          },
        });
        return _transcriber;
      } catch (e) { _transcriberPromise = null; throw e; }
    })();
    return _transcriberPromise;
  }

  // Decode an audio Blob to a mono Float32Array at 16kHz (what Whisper expects).
  async function blobToAudio(blob) {
    const arr = await blob.arrayBuffer();
    const AC = window.AudioContext || window.webkitAudioContext;
    const ctx = new AC();
    let decoded;
    try { decoded = await ctx.decodeAudioData(arr); } finally { if (ctx.close) try { ctx.close(); } catch (e) {} }
    const chN = decoded.numberOfChannels;
    let data = decoded.getChannelData(0);
    if (chN > 1) { // mix down to mono
      const mono = new Float32Array(data.length);
      for (let c = 0; c < chN; c++) { const cd = decoded.getChannelData(c); for (let i = 0; i < cd.length; i++) mono[i] += cd[i] / chN; }
      data = mono;
    }
    if (decoded.sampleRate === 16000) return data;
    const OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
    const len = Math.max(1, Math.round(data.length * 16000 / decoded.sampleRate));
    const off = new OAC(1, len, 16000);
    const buf = off.createBuffer(1, data.length, decoded.sampleRate);
    buf.copyToChannel(data, 0);
    const node = off.createBufferSource(); node.buffer = buf; node.connect(off.destination); node.start();
    const rendered = await off.startRendering();
    return rendered.getChannelData(0);
  }

  // input: a Blob (from MediaRecorder) or a Float32Array @16kHz. Returns the transcript text.
  async function transcribe(input, onStatus) {
    const pipe = await ensureTranscriber(onStatus);
    const audio = input instanceof Float32Array ? input : await blobToAudio(input);
    if (onStatus) onStatus({ phase: 'transcribing', detail: 'Transcribing on-device' });
    // Chunked decoding lifts Whisper's 30-second ceiling: long recordings (phone screens,
    // debriefs) transcribe in overlapping windows instead of silently truncating.
    const out = await pipe(audio, { chunk_length_s: 30, stride_length_s: 5 });
    return (out && out.text ? out.text : '').trim();
  }

  function asrSupported() {
    return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder);
  }

  /* ---- Text generation (a small instruction LLM) - structures a dictated note into a summary. ---- */
  async function ensureGenerator(onStatus) {
    if (_generator) return _generator;
    if (_generatorPromise) return _generatorPromise;
    _generatorPromise = (async function () {
      try {
        const m = await loadLib();
        if (onStatus) onStatus({ phase: 'loading', detail: 'Loading language model', progress: 0 });
        _generator = await m.pipeline('text2text-generation', GEN_MODEL, {
          progress_callback: function (p) { try { if (onStatus && p && typeof p.progress === 'number') onStatus({ phase: 'loading', detail: 'Downloading language model ' + Math.round(p.progress) + '%', progress: Math.round(p.progress) }); } catch (e) {} },
        });
        return _generator;
      } catch (e) { _generatorPromise = null; throw e; }
    })();
    return _generatorPromise;
  }

  async function generate(prompt, onStatus, opts) {
    const pipe = await ensureGenerator(onStatus);
    if (onStatus) onStatus({ phase: 'generating', detail: 'Thinking on-device' });
    const out = await pipe(prompt, Object.assign({ max_new_tokens: 64 }, opts || {}));
    return String((out && out[0] && (out[0].generated_text || out[0].summary_text)) || '').trim();
  }

  async function summarizeNote(text, onStatus) {
    const t = String(text || '').trim();
    if (!t) return '';
    return generate('Summarize this note in one concise sentence: ' + t, onStatus, { max_new_tokens: 60 });
  }

  // Pull a concise value out of the LLM's (often verbose) answer: drop a "Label:" prefix, reduce
  // "The X is Y" to "Y", drop a leading article, and trim trailing punctuation.
  function tidyValue(v) {
    v = (v || '').trim().replace(/\s+/g, ' ').replace(/^["']+|["']+$/g, '');
    v = v.replace(/^[A-Za-z][\w \/-]{0,40}:\s*/, '');
    const m = v.match(/^.*\b(?:is|are|was|will be)\s+(.+)$/i);
    if (m && m[1].length >= 2) v = m[1];
    v = v.replace(/^(?:a|an|the)\s+/i, '').replace(/[.,;:]+$/, '').trim();
    return v;
  }

  // Per-field extractive QA with the on-device LLM. T5 answers the SQuAD-style "question:/context:"
  // format accurately; tidyValue trims its verbosity. fieldDefs: [{ key, label, question }].
  // Returns a { key: value } map, skipping fields the model couldn't find. Sequential for legible progress.
  async function extractFields(text, fieldDefs, onStatus) {
    const t = String(text || '').trim();
    const result = {};
    if (!t || !fieldDefs || !fieldDefs.length) return result;
    await ensureGenerator(onStatus);
    for (let i = 0; i < fieldDefs.length; i++) {
      const f = fieldDefs[i];
      if (onStatus) onStatus({ phase: 'extracting', detail: 'Reading ' + (f.label || f.key), progress: Math.round((i / fieldDefs.length) * 100) });
      let v = '';
      try { v = await generate('question: ' + f.question + ' context: ' + t, null, { max_new_tokens: 24 }); } catch (e) { v = ''; }
      v = tidyValue(v);
      const echo = v.length > 50 && t.toLowerCase().indexOf(v.slice(0, 40).toLowerCase()) !== -1; // model parroted the brief
      const bad = echo || !v || v.length < 2 || /^(unknown|n\/?a|na|none)$/i.test(v) || /\bnot (stated|mentioned|specified|provided|given|available|listed|clear)\b/i.test(v) || /^(there (is|are) no|no\b)/i.test(v);
      if (!bad) result[f.key] = v;
    }
    return result;
  }

  state.enabled = persistedOptIn();

  return { getState, subscribe, enable, disable, embed, rank, classifyBatch, embedFacets, cosine, ensureEmbedder, persistedOptIn, transcribe, ensureTranscriber, asrSupported, generate, summarizeNote, extractFields, ensureGenerator, EMBED_MODEL, ASR_MODEL, GEN_MODEL };
})();

// React hook: re-renders on engine state changes.
function useOnDeviceAI() {
  const [st, setSt] = React.useState(E8AI.getState());
  React.useEffect(function () { return E8AI.subscribe(setSt); }, []);
  return st;
}

/* R78: flight recorder - every on-device AI run, from any surface, lands in the audit log.
   Wrapped at the exported boundary so internal calls (e.g. extractFields -> generate) log once. */
(function () {
  const LABELS = {
    rank: function (args) { return 'Ranked ' + ((args[1] || []).length) + ' items by semantic similarity'; },
    classifyBatch: function (args) { return 'Triaged ' + ((args[0] || []).length) + ' messages by intent'; },
    transcribe: function () { return 'Transcribed audio on-device'; },
    summarizeNote: function () { return 'Summarized a note on-device'; },
    generate: function () { return 'Drafted text on-device'; },
    extractFields: function (args, out) { return 'Extracted ' + Object.keys(out || {}).length + ' fields from a brief'; },
  };
  Object.keys(LABELS).forEach(function (k) {
    const orig = E8AI[k];
    E8AI[k] = async function () {
      const args = Array.prototype.slice.call(arguments);
      const t0 = Date.now();
      const out = await orig.apply(E8AI, args);
      try {
        if (window.E8Audit) window.E8Audit.log({
          agent: 'On-device AI', prov: 'drafted',
          action: LABELS[k](args, out),
          target: ((Date.now() - t0) / 1000).toFixed(1) + 's · local, nothing left the browser',
        });
      } catch (e) {}
      return out;
    };
  });
})();

Object.assign(window, { E8AI, useOnDeviceAI });
