/* ===========================================================================
   lib.jsx — utilities, storage, image handling, API streaming, mock engine,
   and media synthesis (WAV + abstract poster). Exposed on window.
   =========================================================================== */

/* ----------------------------- basics ---------------------------------- */
const uid = (p = "id") => p + "-" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);

function fmtTime(ts) {
  try {
    return new Date(ts).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
  } catch (e) { return ""; }
}
function fmtDuration(ms) {
  if (ms == null) return null;
  if (ms < 1000) return Math.round(ms) + "ms";
  return (ms / 1000).toFixed(1) + "s";
}

/* ----------------------------- storage --------------------------------- */
const LS = {
  get(key, fallback) {
    try { const v = localStorage.getItem(key); return v == null ? fallback : JSON.parse(v); }
    catch (e) { return fallback; }
  },
  set(key, val) {
    try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) { /* quota */ }
  },
};

const SETTINGS_KEY = "openclaw:settings";
const CONVOS_KEY = "openclaw:conversations";
const CURRENT_KEY = "openclaw:currentId";

// Connected to the foundation by default: an empty baseUrl posts to the
// same origin (/v1/chat/completions), which the dashboard proxies to the
// OpenClaw gateway with the operator token injected server-side. Demo mode
// is off so the UI talks to the real gateway out of the box; flip it on in
// Settings to explore the interface without a running gateway.
// baseUrl defaults to same-origin (""), but a deployment can point the UI at
// a remote bridge by setting window.OPENCLAW_CONFIG.baseUrl in /config.js
// (used when the front-end is hosted separately, e.g. on Netlify).
const DEFAULT_SETTINGS = {
  baseUrl: (typeof window !== "undefined" && window.OPENCLAW_CONFIG && window.OPENCLAW_CONFIG.baseUrl) || "",
  ownerId: (typeof window !== "undefined" && window.OPENCLAW_CONFIG && window.OPENCLAW_CONFIG.ownerId) || "",
  token: "",
  demoMode: false,
  theme: "light",
};

function loadSettings() {
  return Object.assign({}, DEFAULT_SETTINGS, LS.get(SETTINGS_KEY, {}));
}
function saveSettings(s) { LS.set(SETTINGS_KEY, s); }

function loadConversations() {
  const raw = LS.get(CONVOS_KEY, {});
  const normalized = normalizeStoredConversations(raw);
  try {
    if (JSON.stringify(raw) !== JSON.stringify(normalized)) LS.set(CONVOS_KEY, normalized);
  } catch (e) {
    // If storage contains unusual values, still return the safe normalized view.
  }
  return normalized;
}
function saveConversations(c) { LS.set(CONVOS_KEY, c); }

function normalizeStoredConversations(conversations) {
  if (!conversations || typeof conversations !== "object" || Array.isArray(conversations)) return {};
  const next = {};
  for (const [id, convo] of Object.entries(conversations)) {
    if (!convo || typeof convo !== "object") continue;
    next[id] = {
      ...convo,
      messages: Array.isArray(convo.messages) ? convo.messages.map(normalizeStoredMessage) : [],
    };
  }
  return next;
}

function normalizeStoredMessage(message) {
  if (!message || typeof message !== "object") return message;
  const next = { ...message };
  if (Array.isArray(next.toolEvents)) {
    next.toolEvents = next.toolEvents.map((e) => e && e.status === "running" ? { ...e, status: "done" } : e);
  }
  if (next.thinking && typeof next.thinking === "object") {
    next.thinking = {
      ...next.thinking,
      status: next.thinking.status === "running" ? "done" : next.thinking.status,
      steps: Array.isArray(next.thinking.steps)
        ? next.thinking.steps.map((s) => s && s.status === "running" ? { ...s, status: "done" } : s)
        : [],
    };
  }
  return next;
}

/* ----------------------- image attachment handling --------------------- */
const ACCEPTED_IMAGE = ["image/png", "image/jpeg", "image/webp", "image/gif"];
const MAX_EDGE = 1568;

function readFileAsDataURL(file) {
  return new Promise((resolve, reject) => {
    const fr = new FileReader();
    fr.onload = () => resolve(fr.result);
    fr.onerror = reject;
    fr.readAsDataURL(file);
  });
}

// Downscale to MAX_EDGE long edge, re-encode. GIFs left untouched (animation).
async function processImageFile(file) {
  const original = await readFileAsDataURL(file);
  if (file.type === "image/gif") {
    return { id: uid("att"), kind: "image", name: file.name, url: original, mime: file.type };
  }
  const img = await loadImage(original);
  let { width: w, height: h } = img;
  const longEdge = Math.max(w, h);
  let outUrl = original;
  if (longEdge > MAX_EDGE) {
    const scale = MAX_EDGE / longEdge;
    w = Math.round(w * scale); h = Math.round(h * scale);
    const canvas = document.createElement("canvas");
    canvas.width = w; canvas.height = h;
    const ctx = canvas.getContext("2d");
    ctx.imageSmoothingQuality = "high";
    ctx.drawImage(img, 0, 0, w, h);
    const outMime = file.type === "image/png" ? "image/png" : "image/jpeg";
    outUrl = canvas.toDataURL(outMime, 0.9);
  }
  return { id: uid("att"), kind: "image", name: file.name || "image", url: outUrl, mime: file.type, w, h };
}

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = src;
  });
}

/* --------------------- convert to OpenAI message format ---------------- */
// internal message: { role, text, attachments:[{kind,url,mime,name}] }
function toApiMessages(messages) {
  return messages.map((m) => {
    const atts = m.attachments || [];
    if (m.role === "assistant") {
      return { role: "assistant", content: m.text || "" };
    }
    // user: build multimodal content array if image attachments present.
    // Audio attachments are NOT sent to the gateway — they're transcribed
    // to text via Blocks (see transcribeAudio) before the turn is sent, so
    // the gateway only ever sees text + images.
    const images = atts.filter((a) => a.kind === "image");
    if (!images.length) return { role: "user", content: m.text || "" };
    const content = [];
    if (m.text) content.push({ type: "text", text: m.text });
    for (const a of images) {
      content.push({ type: "image_url", image_url: { url: a.url } });
    }
    return { role: "user", content };
  });
}

/* ---------------------- microphone → prompt (Blocks) ------------------- */
// Send a recorded clip to the foundation server's /api/transcribe, which
// hires a speech-to-text agent on Blocks and returns the words. This is how
// the mic "translates the prompt into prompt format" through the network.
async function transcribeAudio(attachment, settings, signal) {
  const { b64, format } = splitDataUrl(attachment && attachment.url);
  if (!b64) throw new Error("Couldn’t read the recording (unsupported audio format).");
  const baseUrl = ((settings && settings.baseUrl) || "").replace(/\/$/, "");
  const headers = { "Content-Type": "application/json" };
  if (settings && settings.token) headers["Authorization"] = `Bearer ${settings.token}`;

  const res = await fetch(`${baseUrl}/api/transcribe`, {
    method: "POST",
    headers,
    body: JSON.stringify({ audio: b64, format }),
    signal,
  });
  let data = null;
  try { data = await res.json(); } catch (e) {}
  if (!res.ok || !data || data.ok === false) {
    throw new Error((data && data.error) || `Transcription failed (HTTP ${res.status})`);
  }
  const text = (data.text || "").trim();
  if (!text) throw new Error("Transcriber returned no text.");
  return text;
}

// Demo-mode transcription: no network, no gateway — return a canned line so
// the mic flow is demonstrable on a projector.
function mockTranscribe() {
  return new Promise((resolve) => setTimeout(
    () => resolve("This is a simulated voice transcription (demo mode)."),
    900,
  ));
}

/* ---------------------- image → understanding (Blocks) ---------------- */
// Send an uploaded image to the foundation server's /api/describe-image,
// which hires a vision (image-to-text) agent on Blocks and returns a text
// description. This is how an uploaded picture is "processed as part of a
// task" through the network: the words come back, get folded into the
// prompt, and the gateway acts on them. Mirrors transcribeAudio.
async function describeImage(attachment, prompt, settings, signal) {
  const { b64, format } = splitImageDataUrl(attachment && attachment.url);
  if (!b64) throw new Error("Couldn’t read the image (unsupported format).");
  const baseUrl = ((settings && settings.baseUrl) || "").replace(/\/$/, "");
  const headers = { "Content-Type": "application/json" };
  if (settings && settings.token) headers["Authorization"] = `Bearer ${settings.token}`;

  const res = await fetch(`${baseUrl}/api/describe-image`, {
    method: "POST",
    headers,
    body: JSON.stringify({ image: b64, format, prompt: (prompt || "").trim() }),
    signal,
  });
  let data = null;
  try { data = await res.json(); } catch (e) {}
  if (!res.ok || !data || data.ok === false) {
    throw new Error((data && data.error) || `Image understanding failed (HTTP ${res.status})`);
  }
  const text = (data.text || "").trim();
  if (!text) throw new Error("Vision agent returned no description.");
  return text;
}

// Demo-mode image understanding: no network, no gateway — canned line.
function mockDescribeImage() {
  return new Promise((resolve) => setTimeout(
    () => resolve("A clear central subject with soft, even lighting and a cohesive palette (demo mode)."),
    900,
  ));
}

// Cheap client gate: only attempt intent routing when the text plausibly
// matches a specialist. Keeps the "Finding a specialist…" step from flashing
// on every normal chat. The bridge does the authoritative match in /api/route.
function looksRoutable(text) {
  return /linkedin\.com/i.test(text || "");
}

function looksPersonalAssistant(text) {
  const t = text || "";
  return /\bconfirm_[a-f0-9]{16}\b/i.test(t)
    || /\b(availability|available|free|busy|calendar|schedule|meeting|book|draft an email|email|gmail|poster|image|ask .+ assistant)\b/i.test(t);
}

async function runAssistant(text, settings, signal) {
  const ownerId = ((settings && settings.ownerId) || "").trim();
  if (!ownerId) throw new Error("Set an owner ID in Settings first.");
  const baseUrl = ((settings && settings.baseUrl) || "").replace(/\/$/, "");
  const headers = { "Content-Type": "application/json" };
  if (settings && settings.token) headers["Authorization"] = `Bearer ${settings.token}`;
  const res = await fetch(`${baseUrl}/api/assistant/run`, {
    method: "POST",
    headers,
    body: JSON.stringify({ text: text || "", ownerId }),
    signal,
  });
  let data = null;
  try { data = await res.json(); } catch (e) {}
  if (!res.ok || !data || data.ok === false) {
    throw new Error((data && data.error) || `Assistant run failed (HTTP ${res.status})`);
  }
  return data;
}

function streamAssistant(text, settings, callbacks) {
  const ownerId = ((settings && settings.ownerId) || "").trim();
  if (!ownerId) throw new Error("Set an owner ID in Settings first.");
  const baseUrl = ((settings && settings.baseUrl) || "").replace(/\/$/, "");
  const headers = { "Content-Type": "application/json" };
  if (settings && settings.token) headers["Authorization"] = `Bearer ${settings.token}`;
  const controller = new AbortController();
  const startedAt = performance.now();
  const cb = callbacks || {};

  (async () => {
    try {
      const res = await fetch(`${baseUrl}/api/assistant/stream`, {
        method: "POST",
        headers,
        body: JSON.stringify({ text: text || "", ownerId }),
        signal: controller.signal,
      });
      if (!res.ok) {
        let detail = "";
        try { detail = (await res.text()).slice(0, 300); } catch (e) {}
        throw new Error(`Assistant stream failed (HTTP ${res.status})${detail ? " — " + detail : ""}`);
      }
      if (!res.body) throw new Error("No assistant event stream from bridge.");

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        let idx;
        while ((idx = buffer.indexOf("\n\n")) !== -1) {
          const raw = buffer.slice(0, idx);
          buffer = buffer.slice(idx + 2);
          handleAssistantSSE(raw, cb);
        }
      }
      if (buffer.trim()) handleAssistantSSE(buffer, cb);
    } catch (err) {
      if (err && err.name === "AbortError") return;
      cb.onError && cb.onError(err);
    }
  })();

  return { cancel: () => controller.abort(), startedAt };
}

function handleAssistantSSE(block, cb) {
  let event = "message";
  const data = [];
  for (const line of block.split("\n")) {
    const t = line.trimEnd();
    if (t.startsWith("event:")) event = t.slice(6).trim();
    else if (t.startsWith("data:")) data.push(t.slice(5).trim());
  }
  if (!data.length) return;
  let payload;
  try { payload = JSON.parse(data.join("\n")); } catch (e) { return; }
  if (event === "status") cb.onStatus && cb.onStatus(payload);
  else if (event === "final") cb.onFinal && cb.onFinal(payload);
  else if (event === "error") cb.onError && cb.onError(new Error(payload.error || "Assistant stream failed"));
}

async function bridgeIdentity(settings, signal) {
  const baseUrl = ((settings && settings.baseUrl) || "").replace(/\/$/, "");
  const headers = {};
  if (settings && settings.token) headers["Authorization"] = `Bearer ${settings.token}`;
  const res = await fetch(`${baseUrl}/api/identity`, { headers, signal });
  let data = null;
  try { data = await res.json(); } catch (e) {}
  if (!res.ok || !data || data.ok === false || !data.ownerId) {
    throw new Error((data && data.error) || `Identity lookup failed (HTTP ${res.status})`);
  }
  return data;
}

// Ask the bridge whether this turn maps onto a Blocks specialist, and if so
// run it. Returns { matched, handle, text, mode, meta } or { matched: false }.
// Mirrors transcribeAudio/describeImage: frontend posts, bridge does the work.
async function routeIntent(text, settings, signal) {
  const baseUrl = ((settings && settings.baseUrl) || "").replace(/\/$/, "");
  const headers = { "Content-Type": "application/json" };
  if (settings && settings.token) headers["Authorization"] = `Bearer ${settings.token}`;
  const res = await fetch(`${baseUrl}/api/route`, {
    method: "POST",
    headers,
    body: JSON.stringify({ text: text || "" }),
    signal,
  });
  let data = null;
  try { data = await res.json(); } catch (e) {}
  if (!res.ok || !data || data.ok === false) return { matched: false };
  return data;
}

async function integrationStatus(settings, signal) {
  const owner = ((settings && settings.ownerId) || "").trim();
  if (!owner) return { ok: true, google: { connected: false } };
  const baseUrl = ((settings && settings.baseUrl) || "").replace(/\/$/, "");
  const res = await fetch(`${baseUrl}/api/integrations/status?owner=${encodeURIComponent(owner)}`, { signal });
  let data = null;
  try { data = await res.json(); } catch (e) {}
  if (!res.ok || !data || data.ok === false) {
    throw new Error((data && data.error) || `Integration status failed (HTTP ${res.status})`);
  }
  return data;
}

async function assistantOverview(settings, signal) {
  const baseUrl = ((settings && settings.baseUrl) || "").replace(/\/$/, "");
  const res = await fetch(`${baseUrl}/api/assistant/overview`, { signal });
  let data = null;
  try { data = await res.json(); } catch (e) {}
  if (!res.ok || !data || data.ok === false) {
    throw new Error((data && data.error) || `Assistant overview failed (HTTP ${res.status})`);
  }
  return data;
}

async function startGoogleConnect(settings) {
  const owner = ((settings && settings.ownerId) || "").trim();
  if (!owner) throw new Error("Set an owner ID in Settings first.");
  const baseUrl = ((settings && settings.baseUrl) || "").replace(/\/$/, "");
  const here = window.location.origin + window.location.pathname;
  const params = new URLSearchParams({ owner, returnTo: here });
  const res = await fetch(`${baseUrl}/api/integrations/google/start?${params.toString()}`);
  let data = null;
  try { data = await res.json(); } catch (e) {}
  if (!res.ok || !data || data.ok === false || !data.url) {
    throw new Error((data && data.error) || `Google connect failed (HTTP ${res.status})`);
  }
  window.location.href = data.url;
}

function splitImageDataUrl(dataUrl) {
  const s = dataUrl || "";
  const comma = s.indexOf(",");
  if (comma < 0) return { b64: "", format: "png" };
  const header = s.slice(0, comma);
  const b64 = s.slice(comma + 1);
  const isBase64 = /;base64/i.test(header);
  const mime = (/^data:([^;,]+)/i.exec(header) || [])[1] || "";
  let format = "png";
  if (mime.includes("jpeg") || mime.includes("jpg")) format = "jpg";
  else if (mime.includes("png")) format = "png";
  else if (mime.includes("webp")) format = "webp";
  else if (mime.includes("gif")) format = "gif";
  return { b64: isBase64 ? b64 : "", format };
}

function splitDataUrl(dataUrl) {
  // Robust against parameters in the header, e.g. MediaRecorder emits
  // "data:audio/webm;codecs=opus;base64,…" — split on the FIRST comma and
  // read the mime from the header rather than assuming ";base64" sits
  // immediately after the mime type.
  const s = dataUrl || "";
  const comma = s.indexOf(",");
  if (comma < 0) return { b64: "", format: "wav" };
  const header = s.slice(0, comma);          // e.g. data:audio/webm;codecs=opus;base64
  const b64 = s.slice(comma + 1);
  const isBase64 = /;base64\s*$/i.test(header) || header.includes(";base64;") || header.includes(";base64,");
  const mime = (/^data:([^;,]+)/i.exec(header) || [])[1] || "";
  let format = "wav";
  if (mime.includes("mpeg") || mime.includes("mp3")) format = "mp3";
  else if (mime.includes("webm")) format = "webm";
  else if (mime.includes("ogg")) format = "ogg";
  else if (mime.includes("mp4") || mime.includes("m4a")) format = "mp4";
  else if (mime.includes("wav")) format = "wav";
  return { b64: isBase64 ? b64 : "", format };
}

/* ============================ REAL STREAMING ===========================
   Implements the OpenClaw contract exactly. Returns { cancel }.
   Callbacks: onToken(text), onToolEvent(evt), onMeta(meta), onDone(meta), onError(err)
   ======================================================================== */
function streamChat(opts) {
  const {
    baseUrl, token, conversationId, messages,
    onToken, onToolEvent, onMeta, onDone, onError,
  } = opts;

  const controller = new AbortController();
  const startedAt = performance.now();
  let usage = null, cost = null;

  (async () => {
    try {
      const headers = {
        "Content-Type": "application/json",
        "x-openclaw-session-key": conversationId,
      };
      // Only attach a bearer when the operator filled one in; otherwise the
      // same-origin proxy injects the gateway token server-side.
      if (token) headers["Authorization"] = `Bearer ${token}`;

      const res = await fetch(`${baseUrl.replace(/\/$/, "")}/v1/chat/completions`, {
        method: "POST",
        headers,
        body: JSON.stringify({
          model: "openclaw/default",
          stream: true,
          max_completion_tokens: 1024,
          messages,
        }),
        signal: controller.signal,
      });

      if (!res.ok) {
        let detail = "";
        try { detail = (await res.text()).slice(0, 300); } catch (e) {}
        throw new Error(`Gateway responded ${res.status} ${res.statusText}${detail ? " — " + detail : ""}`);
      }
      if (!res.body) throw new Error("No response stream from gateway.");

      const reader = res.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";

      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });

        // SSE events split on double newline
        let idx;
        while ((idx = buffer.indexOf("\n\n")) !== -1) {
          const raw = buffer.slice(0, idx);
          buffer = buffer.slice(idx + 2);
          handleSSEBlock(raw);
        }
      }
      if (buffer.trim()) handleSSEBlock(buffer);

      const latency = performance.now() - startedAt;
      onDone && onDone({ latency, cost, usage });
    } catch (err) {
      if (err && err.name === "AbortError") return;
      onError && onError(err);
    }
  })();

  function handleSSEBlock(block) {
    for (const line of block.split("\n")) {
      const t = line.trim();
      if (!t.startsWith("data:")) continue;
      const payload = t.slice(5).trim();
      if (payload === "[DONE]") continue;
      let json;
      try { json = JSON.parse(payload); } catch (e) { continue; }

      // standard OpenAI delta
      const choice = json.choices && json.choices[0];
      const delta = choice && choice.delta;
      if (delta && typeof delta.content === "string" && delta.content) {
        onToken && onToken(delta.content);
      }

      // OpenClaw tool/agent activity — accept a few plausible shapes
      const evt = json.openclaw_event || (delta && delta.openclaw_event) || json.tool_event;
      if (evt) onToolEvent && onToolEvent(evt);
      if (delta && Array.isArray(delta.tool_calls)) {
        for (const tc of delta.tool_calls) {
          if (tc.function && tc.function.name) {
            onToolEvent && onToolEvent({ type: "tool", id: tc.id || uid("tc"), skill: tc.function.name, status: "running", label: `Calling ${tc.function.name}…` });
          }
        }
      }

      if (json.usage) { usage = json.usage; onMeta && onMeta({ usage }); }
      const c = json.cost ?? json.openclaw_cost ?? (json.usage && json.usage.cost);
      if (c != null) { cost = c; onMeta && onMeta({ cost }); }
    }
  }

  return { cancel: () => controller.abort() };
}

/* ============================ MOCK ENGINE ==============================
   Drives a believable OpenClaw experience offline: tool dispatch on
   Blocks.ai, streamed tokens, generated image + playable audio.
   ======================================================================== */
function mockChat(opts) {
  const { messages, onToken, onToolEvent, onDone, onError } = opts;
  let cancelled = false;
  const timers = [];
  const startedAt = performance.now();
  const at = (ms, fn) => timers.push(setTimeout(() => { if (!cancelled) fn(); }, ms));

  const lastUser = [...messages].reverse().find((m) => m.role === "user");
  const text = ((lastUser && lastUser.text) || "").toLowerCase();
  const hasImageInput = lastUser && (lastUser.attachments || []).some((a) => a.kind === "image");

  const wantsImage = /poster|image|draw|picture|logo|design|illustrat|art|visual|cover|graphic/.test(text);
  const wantsAudio = /narrat|audio|speak|voice|read|podcast|say|sound|song|music|sing/.test(text);

  // pick a scenario
  let scenario = "text";
  if (wantsImage && wantsAudio) scenario = "both";
  else if (wantsImage) scenario = "image";
  else if (wantsAudio) scenario = "audio";
  else if (hasImageInput) scenario = "describe";

  runScenario(scenario);

  function streamText(str, doneCb, baseDelay) {
    const tokens = str.match(/\s+|\S+/g) || [];
    let i = 0;
    const tick = () => {
      if (cancelled) return;
      if (i >= tokens.length) { doneCb && doneCb(); return; }
      onToken && onToken(tokens[i]);
      i++;
      at((baseDelay || 22) + Math.random() * 34, tick);
    };
    tick();
  }

  function finish(extra) {
    if (cancelled) return;
    const latency = performance.now() - startedAt;
    const cost = +(0.0008 + Math.random() * 0.004).toFixed(4);
    onDone && onDone(Object.assign({ latency, cost, usage: { total_tokens: 200 + Math.floor(Math.random() * 600) } }, extra || {}));
  }

  function runScenario(s) {
    if (s === "image" || s === "both") {
      const tid = uid("tool");
      const nid = uid("tool");
      at(300, () => onToolEvent({ type: "tool", id: "disc", status: "running", skill: "blocks.discover", label: "Discovering agents on Blocks.ai…" }));
      at(1100, () => onToolEvent({ type: "tool", id: "disc", status: "done", skill: "blocks.discover", label: "Found 3 matching agents" }));
      at(1300, () => onToolEvent({ type: "tool", id: tid, status: "running", skill: "poster-maker.v2", label: "Hiring poster-maker to render an image…" }));
      at(1500, () => streamText("On it — I’ve hired a **poster-maker** agent on Blocks.ai to generate this for you.\n\n", () => {}, 18));
      at(3200, () => {
        const poster = makePosterImage(text || "openclaw", pickTitle(text));
        onToolEvent({ type: "tool", id: tid, status: "done", skill: "poster-maker.v2", label: "Image rendered (768×512)" });
        onToken("![Generated poster — soft gradient composition](" + poster + ")\n\n");
        if (s === "both") {
          at(400, () => onToolEvent({ type: "tool", id: nid, status: "running", skill: "narrator.tts", label: "Hiring narrator for a voiceover…" }));
          at(2400, () => {
            const wav = makeToneWav();
            onToolEvent({ type: "tool", id: nid, status: "done", skill: "narrator.tts", label: "Narration synthesized (3s)" });
            onToken("And here’s the narrated caption:\n\n[▶ Narration.wav](" + wav + ")\n\n");
            at(400, () => streamText("Let me know if you’d like a different palette or a square crop.", finish, 18));
          });
        } else {
          at(300, () => streamText("Want me to try a different palette, or a wider crop?", finish, 18));
        }
      });
    } else if (s === "audio") {
      const nid = uid("tool");
      at(300, () => onToolEvent({ type: "tool", id: "disc", status: "running", skill: "blocks.discover", label: "Discovering agents on Blocks.ai…" }));
      at(1000, () => onToolEvent({ type: "tool", id: "disc", status: "done", skill: "blocks.discover", label: "Found a text-to-speech agent" }));
      at(1200, () => onToolEvent({ type: "tool", id: nid, status: "running", skill: "narrator.tts", label: "Hiring narrator to read this aloud…" }));
      at(1400, () => streamText("Sure — I’ve asked the **narrator** agent to voice this. Here’s your clip:\n\n", () => {}, 18));
      at(3200, () => {
        const wav = makeToneWav();
        onToolEvent({ type: "tool", id: nid, status: "done", skill: "narrator.tts", label: "Audio synthesized (3s)" });
        onToken("[▶ Narration.wav](" + wav + ")\n\n");
        at(400, () => streamText("I can adjust pacing, pitch, or generate a longer take if you’d like.", finish, 18));
      });
    } else if (s === "describe") {
      at(300, () => onToolEvent({ type: "tool", id: "vis", status: "running", skill: "vision.analyze", label: "Analyzing attached image…" }));
      at(1500, () => onToolEvent({ type: "tool", id: "vis", status: "done", skill: "vision.analyze", label: "Image analyzed" }));
      at(1700, () => streamText(
        "Thanks for the image. Here’s what I can see:\n\n" +
        "- A clear primary subject with strong central composition\n" +
        "- Soft, even lighting and a muted, cohesive palette\n" +
        "- Plenty of negative space toward the edges\n\n" +
        "Would you like me to **hire a poster-maker** to restyle it, or have the **narrator** describe it aloud?",
        finish, 20));
    } else {
      at(280, () => streamText(
        replyFor(text),
        () => {
          finish();
        }, 22));
    }
  }

  return { cancel: () => { cancelled = true; timers.forEach(clearTimeout); } };
}

function pickTitle(text) {
  const t = (text || "").replace(/[^a-z0-9 ]/gi, "").trim();
  if (!t) return "Untitled";
  const words = t.split(/\s+/).slice(0, 4).join(" ");
  return words.charAt(0).toUpperCase() + words.slice(1);
}

function replyFor(text) {
  if (/hello|hi |hey|^hi$/.test(text)) {
    return "Hey! I’m **OpenClaw** — your gateway to specialist agents on Blocks.ai. " +
      "Send me a message (you can attach images too), and when a task needs a specialist I’ll *hire one* on the fly: a **poster-maker** for images, a **narrator** for audio, summarizers, and more.\n\n" +
      "Try: *“make a poster for a calm productivity app”* or *“narrate a short welcome message.”*";
  }
  if (/who|what are you|openclaw|blocks/.test(text)) {
    return "I’m **OpenClaw**, an OpenAI-compatible gateway. What makes me unusual is that I can **delegate to other agents** discovered on the **Blocks.ai** network by skill — then fold their results (text, images, audio) right back into our conversation.\n\n" +
      "Ask me to *make an image* or *read something aloud* and you’ll see me hire a specialist in the activity line above each reply.";
  }
  return "Got it. In a live deployment I’d route this to my best available reasoning model and, if the task calls for it, **hire a specialist agent on Blocks.ai** to help.\n\n" +
    "Since you’re in **demo mode** right now, try asking me to:\n\n" +
    "1. *“Make a poster for …”* — I’ll hire a **poster-maker** and return an image card.\n" +
    "2. *“Narrate …”* — I’ll hire a **narrator** and return a playable audio clip.\n\n" +
    "Flip the connection to your real gateway anytime in **Settings**.";
}

/* ====================== media synthesis ============================== */

// Abstract, calm poster as a PNG data URL.
function makePosterImage(seed, title) {
  const W = 768, H = 512;
  const canvas = document.createElement("canvas");
  canvas.width = W; canvas.height = H;
  const ctx = canvas.getContext("2d");
  const rnd = mulberry32(hashStr(seed || "openclaw"));

  const palettes = [
    ["#e8edf3", "#cdd9ec", "#9bb4d8", "#5f7fb0"],
    ["#f0ece6", "#e6d6c6", "#cdb59b", "#9c8a73"],
    ["#e9eef0", "#cfe0df", "#a7c9c5", "#6f9d97"],
    ["#efeaf2", "#ddd2ea", "#bcaede", "#8e7fc0"],
    ["#f1ece9", "#efd8cf", "#e3b3a6", "#bd8576"],
  ];
  const pal = palettes[Math.floor(rnd() * palettes.length)];

  // base wash
  const g = ctx.createLinearGradient(0, 0, W, H);
  g.addColorStop(0, pal[0]); g.addColorStop(1, pal[1]);
  ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);

  // soft radial blobs
  for (let i = 0; i < 5; i++) {
    const cx = rnd() * W, cy = rnd() * H, r = 140 + rnd() * 240;
    const rg = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
    const col = pal[2 + Math.floor(rnd() * 2)];
    rg.addColorStop(0, hexA(col, 0.55));
    rg.addColorStop(1, hexA(col, 0));
    ctx.fillStyle = rg;
    ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();
  }

  // thin geometric accents
  ctx.globalAlpha = 0.5;
  for (let i = 0; i < 3; i++) {
    ctx.strokeStyle = hexA(pal[3], 0.5);
    ctx.lineWidth = 1.5;
    ctx.beginPath();
    const yy = H * (0.3 + i * 0.2);
    ctx.moveTo(0, yy + rnd() * 40 - 20);
    ctx.bezierCurveTo(W * 0.33, yy - 60, W * 0.66, yy + 60, W, yy + rnd() * 40 - 20);
    ctx.stroke();
  }
  ctx.globalAlpha = 1;

  // subtle grain
  const grain = ctx.createImageData(W, H);
  for (let i = 0; i < grain.data.length; i += 4) {
    const v = 128 + (Math.random() * 18 - 9);
    grain.data[i] = grain.data[i + 1] = grain.data[i + 2] = v;
    grain.data[i + 3] = 8;
  }
  ctx.putImageData(blendGrain(ctx, grain, W, H), 0, 0);

  // title plate
  ctx.fillStyle = hexA(pal[3], 0.92);
  ctx.font = "600 30px 'Hanken Grotesk', system-ui, sans-serif";
  ctx.textBaseline = "alphabetic";
  const label = (title || "Untitled").slice(0, 28);
  ctx.fillText(label, 44, H - 64);
  ctx.fillStyle = hexA(pal[3], 0.6);
  ctx.font = "500 14px ui-monospace, monospace";
  ctx.fillText("poster-maker.v2 · blocks.ai", 44, H - 40);

  return canvas.toDataURL("image/png");
}

function blendGrain(ctx, grain, W, H) {
  // composite grain over current canvas content
  const base = ctx.getImageData(0, 0, W, H);
  for (let i = 0; i < base.data.length; i += 4) {
    const a = grain.data[i + 3] / 255;
    base.data[i]     = base.data[i]     * (1 - a) + grain.data[i]     * a;
    base.data[i + 1] = base.data[i + 1] * (1 - a) + grain.data[i + 1] * a;
    base.data[i + 2] = base.data[i + 2] * (1 - a) + grain.data[i + 2] * a;
  }
  return base;
}

// Pleasant 3s WAV (gentle arpeggio) as a data URL.
function makeToneWav() {
  const sr = 22050, dur = 3.0;
  const n = Math.floor(sr * dur);
  const data = new Float32Array(n);
  // soft major-ish arpeggio
  const notes = [261.63, 329.63, 392.0, 523.25, 392.0, 329.63];
  const noteLen = dur / notes.length;
  for (let i = 0; i < n; i++) {
    const t = i / sr;
    const ni = Math.min(notes.length - 1, Math.floor(t / noteLen));
    const f = notes[ni];
    const localT = t - ni * noteLen;
    const env = Math.min(1, localT * 14) * Math.exp(-localT * 2.2);
    let s = Math.sin(2 * Math.PI * f * t) * 0.5;
    s += Math.sin(2 * Math.PI * f * 2 * t) * 0.12; // overtone
    data[i] = s * env * 0.5;
  }
  return encodeWav(data, sr);
}

function encodeWav(samples, sampleRate) {
  const n = samples.length;
  const buffer = new ArrayBuffer(44 + n * 2);
  const view = new DataView(buffer);
  const ws = (off, str) => { for (let i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i)); };
  ws(0, "RIFF"); view.setUint32(4, 36 + n * 2, true); ws(8, "WAVE");
  ws(12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
  view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true);
  ws(36, "data"); view.setUint32(40, n * 2, true);
  let off = 44;
  for (let i = 0; i < n; i++) {
    let s = Math.max(-1, Math.min(1, samples[i]));
    view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true);
    off += 2;
  }
  // base64
  const bytes = new Uint8Array(buffer);
  let bin = "";
  const chunk = 0x8000;
  for (let i = 0; i < bytes.length; i += chunk) {
    bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
  }
  return "data:audio/wav;base64," + btoa(bin);
}

/* ----------------------------- small math ------------------------------ */
function hashStr(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }
function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
function hexA(hex, a) {
  const h = hex.replace("#", "");
  const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
  return `rgba(${r},${g},${b},${a})`;
}

/* ----------------------------- exports --------------------------------- */
Object.assign(window, {
  uid, fmtTime, fmtDuration, LS,
  SETTINGS_KEY, CONVOS_KEY, CURRENT_KEY, DEFAULT_SETTINGS,
  loadSettings, saveSettings, loadConversations, saveConversations,
  ACCEPTED_IMAGE, processImageFile, readFileAsDataURL, loadImage,
  toApiMessages, splitDataUrl, transcribeAudio, mockTranscribe,
  describeImage, mockDescribeImage, splitImageDataUrl,
  routeIntent, looksRoutable, looksPersonalAssistant, runAssistant, streamAssistant, bridgeIdentity, integrationStatus, assistantOverview, startGoogleConnect,
  streamChat, mockChat, makePosterImage, makeToneWav,
});
