/* ===========================================================
   indie.chat — React app
   =========================================================== */
const { useState, useEffect, useRef, useCallback } = React;
const L = window.IndieLib;

/* ---------- icons ---------- */
const SendIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M12 19V5M5 12l7-7 7 7" />
  </svg>
);
const MenuIcon = () => (
  <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#4d4d51" strokeWidth="2" strokeLinecap="round">
    <path d="M3 6h18M3 12h18M3 18h18" />
  </svg>
);
const ClipIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
  </svg>
);
const SearchIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <circle cx="11" cy="11" r="7" /><path d="M21 21l-4.3-4.3" />
  </svg>
);
const ShieldIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
  </svg>
);
const MicIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <rect x="9" y="2" width="6" height="12" rx="3" />
    <path d="M5 11a7 7 0 0 0 14 0M12 18v4" />
  </svg>
);
const GearIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
    <circle cx="12" cy="12" r="3" />
    <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
  </svg>
);

/* ========================================================
   LOGIN SCREEN
   ======================================================== */
function LoginScreen({ onLogin }) {
  const [tab,      setTab]      = useState("login");
  const [email,    setEmail]    = useState("");
  const [password, setPassword] = useState("");
  const [name,     setName]     = useState("");
  const [loading,  setLoading]  = useState(false);
  const [error,    setError]    = useState(null);
  const [notice,   setNotice]   = useState(null);

  const submit = async e => {
    e.preventDefault();
    setLoading(true);
    setError(null);
    setNotice(null);
    try {
      if (tab === "login") {
        onLogin(await L.Auth.login(email.trim(), password));
      } else {
        const res = await L.Auth.register(email.trim(), name.trim(), password);
        if (res && res.pending) {
          /* Awaiting admin approval — don't log in; send them back to sign-in. */
          setTab("login");
          setName(""); setPassword("");
          setNotice(res.message || "Account created — an administrator must approve it before you can sign in.");
        } else {
          onLogin(res);
        }
      }
    } catch (err) {
      setError(err.message || "Something went wrong");
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="auth-screen">
      <div className="auth-card">
        <div className="auth-brand">
          <div className="logo" style={{ width: 38, height: 38, marginRight: 12 }} />
          <div>
            <div className="brand-name">indie<b>.</b>chat</div>
            <div className="brand-tag">Private · Indie Models</div>
          </div>
        </div>

        <div className="auth-tabs">
          <button className={"auth-tab " + (tab === "login"    ? "on" : "")} onClick={() => { setTab("login");    setError(null); }}>Sign in</button>
          <button className={"auth-tab " + (tab === "register" ? "on" : "")} onClick={() => { setTab("register"); setError(null); setNotice(null); }}>Register</button>
        </div>

        <form onSubmit={submit} className="auth-form">
          {error  && <div className="auth-error">{error}</div>}
          {notice && <div className="auth-notice">{notice}</div>}
          {tab === "register" && !notice && (
            <div className="auth-note-sm">New accounts require administrator approval before first sign-in.</div>
          )}
          {tab === "register" && (
            <div className="field">
              <label>Name</label>
              <input value={name} onChange={e => setName(e.target.value)} placeholder="Jane Doe" required autoFocus />
            </div>
          )}
          <div className="field">
            <label>Email</label>
            <input type="email" value={email} onChange={e => setEmail(e.target.value)}
                   placeholder="you@example.com" required autoFocus={tab === "login"} />
          </div>
          <div className="field">
            <label>Password</label>
            <input type="password" value={password} onChange={e => setPassword(e.target.value)}
                   placeholder={tab === "register" ? "Min. 6 characters" : "••••••••"} required />
          </div>
          <button type="submit" className="btn primary auth-submit" disabled={loading}>
            {loading ? "Please wait…" : (tab === "login" ? "Sign in →" : "Create account →")}
          </button>
        </form>

      </div>
    </div>
  );
}

/* ========================================================
   CONNECTION SETTINGS
   ======================================================== */
function Settings({ onClose, model }) {
  const c = L.getConn();
  const [base,  setBase]  = useState(c.base  || "");
  const [key,   setKey]   = useState(c.key   || "");
  const [proxy, setProxy] = useState(c.proxy || "");
  const [status, setStatus] = useState(null);

  /* Reconcile the advanced fields with the provider system:
     - a base matching a built-in provider → just select that provider
     - a genuine custom base+key → store it and select the "custom" provider
     - no base → no custom endpoint (keep any proxy) */
  const persist = () => {
    const b = base.trim(), k = key.trim(), p = proxy.trim();
    const match = (L.ENDPOINTS || []).find(e => e.base.replace(/\/$/, "") === b.replace(/\/$/, ""));
    if (b && k && !match) { L.setConn({ base: b, key: k, proxy: p }); L.setProvider("custom"); }
    else { L.setConn(p ? { proxy: p } : {}); if (match) L.setProvider(match.name); }
  };

  const test = async () => {
    persist();
    setStatus({ state: "run", text: "Testing connection…" });
    const r = await L.ping(model);
    if (r.ok)          setStatus({ state: "ok",  text: "Connected · " + (L.providerLabel(r.endpoint) || "live") });
    else if (r.status) setStatus({ state: "bad", text: "HTTP " + r.status + " — check key/model" });
    else               setStatus({ state: "bad", text: "Unreachable — check URL/key" });
  };

  const saveClose = () => { persist(); onClose(true); };

  return (
    <div className="modal-scrim" onMouseDown={e => { if (e.target === e.currentTarget) onClose(false); }}>
      <div className="modal">
        <div className="modal-head">
          <div><span className="eb">indie.chat</span><h2>Custom endpoint</h2></div>
          <button className="modal-x" onClick={() => onClose(false)}>✕</button>
        </div>
        <div className="modal-body">
          <div className="note">
            Pick built-in providers from the <b>Provider</b> menu in the toolbar.
            Use this panel only to add your own OpenAI-compatible endpoint.
          </div>
          <div className="field">
            <label>API base URL</label>
            <input value={base} onChange={e => setBase(e.target.value)} placeholder="https://your-host" spellCheck="false" />
          </div>
          <div className="field">
            <label>API key</label>
            <input value={key} onChange={e => setKey(e.target.value)} type="password" placeholder="sk-…" spellCheck="false" />
          </div>
          <div className="field">
            <label>CORS proxy (optional, only if bypassing the server)</label>
            <input value={proxy} onChange={e => setProxy(e.target.value)} placeholder="https://proxy/{url}" spellCheck="false" />
          </div>
        </div>
        <div className="modal-foot">
          <span className={"test-status " + (status ? status.state : "")}>{status ? status.text : ""}</span>
          <button className="btn" onClick={test}>Test</button>
          <button className="btn primary" onClick={saveClose}>Save</button>
        </div>
      </div>
    </div>
  );
}

/* ---------- markdown body ---------- */
function Markdown({ text }) {
  const ref = useRef(null);
  useEffect(() => { if (ref.current) L.decorateCode(ref.current); });
  return (
    <div className="msg-body md" ref={ref}
         dangerouslySetInnerHTML={{ __html: L.renderMarkdown(text) }} />
  );
}

/* ---------- one message ---------- */
function Message({ m, modelLabel, provider, requestedLabel, mismatch, streaming }) {
  const [copied, setCopied] = useState(false);
  const isUser = m.role === "user";
  const copy = () => {
    navigator.clipboard.writeText(m.content);
    setCopied(true);
    setTimeout(() => setCopied(false), 1200);
  };
  return (
    <div className={"msg " + (isUser ? "user" : "bot")}>
      <div className="msg-head">
        <span className="msg-sq" />
        <span className="msg-role">{isUser ? "You" : modelLabel}</span>
        {!isUser && provider && <span className="msg-provider">via {provider}</span>}
        {m.ts && <span className="msg-time">{L.clock(m.ts)}</span>}
      </div>
      {!isUser && mismatch && (
        <div className="model-warn">
          <span className="warn-ic">⚠</span>
          You picked <b>{requestedLabel}</b>, but {provider || "the provider"} answered with <b>{modelLabel}</b>.
        </div>
      )}
      {m.searched && (
        <div className="search-pill"><span className="tt-dot" /> Searched the web</div>
      )}
      {m.privacy && (
        <div className="redaction-note">
          <span className="rn-ic"><ShieldIcon /></span>
          {m.redactions
            ? <span>Redacted before sending · <b>{L.redactionSummary(m.redactionTypes) || (m.redactions + (m.redactions > 1 ? " items" : " item"))}</b></span>
            : <span>Privacy filter on · nothing to redact</span>}
        </div>
      )}
      {m.attachments && m.attachments.length > 0 && (
        <div className="msg-attach">
          {m.attachments.map((a, i) => (
            <div key={i} className={"chip " + (a.binary ? "bin" : "")}>
              <span className="chip-ic" />
              <span className="chip-name">{a.name}</span>
              <span className="chip-sz">{a.size != null ? fmtSize(a.size) : ""}</span>
            </div>
          ))}
        </div>
      )}
      {isUser
        ? <div className="msg-body">{m.content}</div>
        : (
          <>
            <Markdown text={m.content || ""} />
            {streaming && <span className="caret" />}
          </>
        )}
      {!streaming && m.content && (
        <div className="msg-actions">
          <button className="act-btn" onClick={copy}>{copied ? "Copied" : "Copy"}</button>
        </div>
      )}
    </div>
  );
}

/* ---------- model selector ---------- */
function ModelSelect({ model, setModel, models }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  const list = models || L.MODELS;
  const cur = list.find(m => m.id === model) || list[0];
  useEffect(() => {
    const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, []);
  return (
    <div className="model-sel" ref={ref}>
      <button className="model-btn" onClick={() => setOpen(!open)}>
        <span className="sq" />
        <span className="sel-label">{cur.label}</span>
        <span className="chev">▼</span>
      </button>
      {open && (
        <div className="model-menu">
          <div className="menu-cap">Model</div>
          {list.map(m => (
            <button key={m.id} className={"model-opt " + (m.id === model ? "sel" : "")}
                    onClick={() => { setModel(m.id); setOpen(false); }}>
              <span className="sq" />
              <span className="label">
                {m.label}
                <span className="desc">{m.desc}</span>
              </span>
              {m.id === model && <span className="check">✓</span>}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

/* ---------- provider selector ---------- */
function ProviderSelect({ provider, setProvider, live }) {
  const [open, setOpen] = useState(false);
  const ref = useRef(null);
  const list = L.providersList();
  const selName = provider || (list[0] && list[0].name);
  useEffect(() => {
    const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, []);
  return (
    <div className="model-sel prov-sel" ref={ref}>
      <button className="model-btn" onClick={() => setOpen(!open)} title="Provider">
        <span className={"prov-dot " + (live && live === selName ? "on" : "")} />
        <span className="sel-label">{L.providerLabel(selName)}</span>
        <span className="chev">▼</span>
      </button>
      {open && (
        <div className="model-menu">
          <div className="menu-cap">Provider</div>
          {list.map(p => (
            <button key={p.name} className={"model-opt " + (p.name === selName ? "sel" : "")}
                    onClick={() => { setProvider(p.name); setOpen(false); }}>
              <span className={"prov-dot " + (live && live === p.name ? "on" : "")} />
              <span className="label">
                {L.providerLabel(p.name)}
                <span className="desc">{(p.base || "").replace(/^https?:\/\//, "")}</span>
              </span>
              {p.name === selName && <span className="check">✓</span>}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

/* ---------- empty state ---------- */
const SUGGESTIONS = [
  { k: "Explain", t: "Explain how a VAT return works for a small German GmbH." },
  { k: "Draft",   t: "Draft a short, friendly reminder email about a missing invoice." },
  { k: "Code",    t: "Write a Python function that parses an SSE stream line by line." },
  { k: "Compare", t: "Compare a dense 27B model with a 35B mixture-of-experts model." },
];
function Empty({ onPick }) {
  return (
    <div className="empty">
      <div className="empty-mark" />
      <h1>indie<b>.</b>chat</h1>
      <p className="sub">A clean, private chat workspace running on indie models. Pick a model up top, ask anything below.</p>
      <div className="eyebrow">Try one of these</div>
      <div className="prompts">
        {SUGGESTIONS.map((s, i) => (
          <button key={i} className="prompt" onClick={() => onPick(s.t)}>
            <span className="p-k">{s.k}</span>
            <span className="p-t">{s.t}</span>
          </button>
        ))}
      </div>
    </div>
  );
}

/* ---------- composer ---------- */
const TEXT_RE = /^(text\/|application\/(json|xml|javascript|x-sh|x-yaml|sql))|\.(md|markdown|csv|tsv|json|js|jsx|ts|tsx|py|rb|go|rs|java|c|cpp|h|css|html|xml|yml|yaml|toml|ini|sh|sql|txt|log)$/i;
function isTextFile(f) { return TEXT_RE.test(f.type) || TEXT_RE.test(f.name); }
function fmtSize(n) {
  return n < 1024 ? n + "B" : n < 1048576 ? (n / 1024).toFixed(0) + "K" : (n / 1048576).toFixed(1) + "M";
}

function Composer({ onSend, onStop, busy, presetText, clearPreset }) {
  const [text,   setText]   = useState("");
  const [focus,  setFocus]  = useState(false);
  const [drag,   setDrag]   = useState(false);
  const [files,  setFiles]  = useState([]);
  const [search, setSearch] = useState(false);
  const [recording,    setRecording]    = useState(false);
  const [transcribing, setTranscribing] = useState(false);
  const [voiceErr,     setVoiceErr]      = useState(null);
  const ta        = useRef(null);
  const fileInput = useRef(null);
  const dragDepth = useRef(0);
  const rec       = useRef(null);   /* { ctx, processor, source, stream, chunks } */

  const [priv,   setPriv]   = useState(false);
  const searchAvailable  = L.toolEnabled("search");
  const filesAvailable   = L.toolEnabled("files");
  const privacyAvailable = L.toolEnabled("privacy");
  const voiceAvailable   = typeof navigator !== "undefined" && navigator.mediaDevices && window.AudioContext;

  useEffect(() => {
    if (presetText) {
      setText(presetText);
      clearPreset();
      requestAnimationFrame(() => ta.current && ta.current.focus());
    }
  }, [presetText]);

  useEffect(() => {
    const el = ta.current; if (!el) return;
    el.style.height = "auto";
    el.style.height = Math.min(el.scrollHeight, 220) + "px";
  }, [text]);

  const MAX_UPLOAD = 32 * 1024 * 1024; /* must match server express.raw limit */
  const patchFile = (id, patch) => setFiles(fs => fs.map(x => x.id === id ? { ...x, ...patch } : x));

  /* Every attachment is sent to the backend, converted to Markdown via
     MarkItDown, and the resulting text becomes the model context. */
  const addFiles = list => {
    Array.from(list).forEach(f => {
      const id = Math.random().toString(36).slice(2);
      setFiles(fs => [...fs, { id, name: f.name, size: f.size, type: f.type, converting: true }]);
      if (f.size > MAX_UPLOAD) {
        patchFile(id, { converting: false, error: "too large (max 32 MB)" });
        return;
      }
      L.convertFile(f)
        .then(r => patchFile(id, { converting: false, text: r.markdown, chars: r.chars, truncated: r.truncated }))
        .catch(e => patchFile(id, { converting: false, error: (e && e.message) || "conversion failed" }));
    });
  };

  /* ---- voice input: capture mic → WAV → MarkItDown transcription ---- */
  const startRecording = async () => {
    setVoiceErr(null);
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const ctx    = new (window.AudioContext || window.webkitAudioContext)();
      const source = ctx.createScriptProcessor ? ctx.createScriptProcessor(4096, 1, 1) : ctx.createScriptProcessor(4096, 1, 1);
      const mic    = ctx.createMediaStreamSource(stream);
      const chunks = [];
      source.onaudioprocess = e => { chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); };
      mic.connect(source); source.connect(ctx.destination);
      rec.current = { ctx, processor: source, source: mic, stream, chunks };
      setRecording(true);
    } catch (e) {
      setVoiceErr(e && e.name === "NotAllowedError" ? "Microphone permission denied" : "Couldn't start recording");
    }
  };

  const stopRecording = async () => {
    const r = rec.current; rec.current = null;
    setRecording(false);
    if (!r) return;
    try { r.processor.disconnect(); r.source.disconnect(); } catch {}
    r.stream.getTracks().forEach(t => t.stop());
    const sampleRate = r.ctx.sampleRate;
    try { await r.ctx.close(); } catch {}

    const totalSamples = r.chunks.reduce((n, c) => n + c.length, 0);
    if (totalSamples < sampleRate * 0.3) { setVoiceErr("Recording too short"); return; } /* < 0.3s */

    setTranscribing(true);
    try {
      const wav  = L.pcmToWavBlob(r.chunks, sampleRate);
      const file = new File([wav], "voice.wav", { type: "audio/wav" });
      const res  = await L.convertFile(file);
      const said = L.transcriptText(res.markdown);
      if (said) {
        setText(t => (t ? t.replace(/\s*$/, " ") : "") + said);
        requestAnimationFrame(() => ta.current && ta.current.focus());
      } else {
        setVoiceErr("Couldn't make out any speech");
      }
    } catch (e) {
      setVoiceErr((e && e.message) || "Transcription failed");
    } finally {
      setTranscribing(false);
    }
  };

  const toggleMic = () => { if (transcribing) return; recording ? stopRecording() : startRecording(); };

  const onDrop      = e => { e.preventDefault(); dragDepth.current = 0; setDrag(false); if (filesAvailable && e.dataTransfer.files?.length) addFiles(e.dataTransfer.files); };
  const onDragEnter = e => { e.preventDefault(); if (!filesAvailable) return; dragDepth.current++; setDrag(true); };
  const onDragLeave = e => { e.preventDefault(); dragDepth.current--; if (dragDepth.current <= 0) setDrag(false); };

  const converting = files.some(f => f.converting);
  const submit = () => {
    const t = text.trim();
    if ((!t && !files.length) || busy || converting) return;
    /* only forward attachments that converted successfully */
    onSend(t, { files: files.filter(f => f.text), search: search && searchAvailable, privacy: priv && privacyAvailable });
    setText(""); setFiles([]);
  };
  const key = e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } };

  return (
    <div className="composer-wrap">
      <div className={"composer " + (focus ? "focus " : "") + (drag ? "drag" : "")}
           onDrop={onDrop} onDragOver={e => e.preventDefault()}
           onDragEnter={onDragEnter} onDragLeave={onDragLeave}>

        {files.length > 0 && (
          <div className="attach-row">
            {files.map(f => (
              <div key={f.id}
                   className={"chip " + (f.error ? "bin " : "") + (f.converting ? "loading" : "")}
                   title={f.error ? f.error : f.converting ? "Converting to Markdown…" : (f.truncated ? f.name + " (truncated to fit context)" : f.name)}>
                <span className="chip-ic" />
                <span className="chip-name">{f.name}</span>
                <span className="chip-sz">
                  {f.converting ? "converting…" : f.error ? "failed" : f.truncated ? "truncated" : fmtSize(f.size)}
                </span>
                <button className="chip-x" onClick={() => setFiles(fs => fs.filter(x => x.id !== f.id))}>✕</button>
              </div>
            ))}
          </div>
        )}

        <div className="composer-row">
          {filesAvailable && (
            <>
              <button className="attach-btn" title="Attach files" onClick={() => fileInput.current && fileInput.current.click()}><ClipIcon /></button>
              <input ref={fileInput} type="file" multiple hidden onChange={e => { addFiles(e.target.files); e.target.value = ""; }} />
            </>
          )}
          <textarea
            ref={ta} value={text} rows={1}
            placeholder={drag ? "Drop files to attach…" : "Message indie.chat…"}
            onChange={e => setText(e.target.value)}
            onKeyDown={key}
            onFocus={() => setFocus(true)}
            onBlur={() => setFocus(false)}
          />
          {voiceAvailable && (
            <button className={"mic-btn " + (recording ? "rec " : "") + (transcribing ? "busy" : "")}
                    onClick={toggleMic} disabled={transcribing}
                    title={recording ? "Stop & transcribe" : transcribing ? "Transcribing…" : "Voice input"}>
              <MicIcon />
            </button>
          )}
          {busy
            ? <button className="stop" onClick={onStop} title="Stop"><span className="sq" /></button>
            : <button className="send" onClick={submit} disabled={converting || recording || transcribing || (!text.trim() && !files.length)} title={converting ? "Converting attachments…" : "Send"}><SendIcon /></button>}
        </div>

        <div className="composer-foot">
          <div className="foot-left">
            {searchAvailable && (
              <button className={"tool-toggle " + (search ? "on" : "")} onClick={() => setSearch(s => !s)} title="Web search">
                <SearchIcon /> Search
              </button>
            )}
            {privacyAvailable && (
              <button className={"tool-toggle " + (priv ? "on" : "")} onClick={() => setPriv(p => !p)}
                      title="Strip PII before sending to the model">
                <ShieldIcon /> Private
              </button>
            )}
            {recording
              ? <span className="voice-status rec"><span className="rec-dot" /> Recording… tap mic to stop</span>
              : transcribing
              ? <span className="voice-status"><span className="rec-dot busy" /> Transcribing…</span>
              : voiceErr
              ? <span className="voice-status err">{voiceErr}</span>
              : <span className="hint"><b>Enter</b> to send · <b>Shift+Enter</b> for newline</span>}
          </div>
          {text.length > 0 && <span className="count">{text.length}</span>}
        </div>
      </div>
    </div>
  );
}

/* ---------- sidebar ---------- */
function Sidebar({ convs, activeId, onSelect, onNew, onDelete, user, onLogout }) {
  const initials = user ? user.name.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() : "?";
  const isAdmin  = user && ["owner", "admin"].includes(user.role);

  return (
    <aside className="sidebar">
      <div className="brand">
        <div className="logo" />
        <div>
          <div className="brand-name">indie<b>.</b>chat</div>
          <div className="brand-tag">Private · Indie Models</div>
        </div>
      </div>
      <button className="new-chat" onClick={onNew}>
        New chat <span className="plus">+</span>
      </button>
      <div className="conv-head mono">History</div>
      <div className="conv-list">
        {convs.length === 0 && (
          <div style={{ padding: "10px 12px", fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-4)" }}>
            No conversations yet
          </div>
        )}
        {convs.map(c => (
          <div key={c.id}
               className={"conv " + (c.id === activeId ? "active" : "")}
               onClick={() => onSelect(c.id)}>
            <div className="conv-title">{c.title || "New conversation"}</div>
            <div className="conv-meta">{L.relTime(c.updated)}</div>
            <button className="conv-del" title="Delete"
                    onClick={e => { e.stopPropagation(); onDelete(c.id); }}>✕</button>
          </div>
        ))}
      </div>

      {isAdmin && (
        <div className="sidebar-foot">
          <a className="admin-link" href="admin.html" title="Admin"><ShieldIcon /> Admin</a>
        </div>
      )}

      {user && (
        <div className="user-foot">
          <span className="user-avatar">{initials}</span>
          <span className="user-info">
            <span className="user-name">{user.name}</span>
            <span className="user-email">{user.email}</span>
          </span>
          <button className="logout-btn" onClick={onLogout} title="Sign out">⏻</button>
        </div>
      )}
    </aside>
  );
}

/* ========================================================
   ROOT APP
   ======================================================== */
function App() {
  const ui = L.load();

  /* --- auth state --- */
  const [user,         setUser]         = useState(L.getCachedUser()); // show cached immediately
  const [authChecked,  setAuthChecked]  = useState(false);

  /* --- chat state --- */
  const [convs,    setConvs]    = useState([]);
  const [activeId, setActiveId] = useState(ui.activeId || null);
  const [model,    setModel]    = useState(ui.model || L.DEFAULT_MODEL);
  const [busy,     setBusy]     = useState(false);
  const [error,    setError]    = useState(null);
  const [endpoint, setEndpoint] = useState(localStorage.getItem("indie.endpoint") || null);
  const [provider, setProviderState] = useState(L.getProvider());
  const [navOpen,  setNavOpen]  = useState(false);
  const [preset,   setPreset]   = useState("");
  const [showSettings, setShowSettings] = useState(false);

  const abortRef  = useRef(null);
  const scrollRef = useRef(null);
  const stickRef  = useRef(true);

  const active        = convs.find(c => c.id === activeId) || null;
  const curProvider   = provider || (L.providersList()[0] || {}).name;
  const providerModels = L.modelsFor(curProvider);
  const modelLabel    = (L.findModel(model) || providerModels[0]).label;

  /* --- persist UI state (not conversations) --- */
  useEffect(() => { L.save({ activeId, model }); }, [activeId, model]);

  /* Keep the selected model valid for the chosen provider. */
  useEffect(() => {
    if (!providerModels.some(m => m.id === model)) setModel(providerModels[0].id);
  }, [curProvider]);

  /* --- auth check + load convs on mount --- */
  useEffect(() => {
    L.Auth.me().then(u => {
      setUser(u);
      setAuthChecked(true);
      if (u) loadConvs();
    }).catch(() => {
      setUser(null);
      setAuthChecked(true);
    });
  }, []);

  const loadConvs = async () => {
    try {
      const apiConvs = await L.ConvsAPI.list();
      setConvs(apiConvs);
    } catch (e) {
      console.error("Failed to load conversations:", e);
    }
  };

  /* --- autoscroll while streaming --- */
  const scrollToBottom = useCallback(() => {
    const el = scrollRef.current; if (!el) return;
    el.scrollTop = el.scrollHeight;
  }, []);
  useEffect(() => {
    const el = scrollRef.current; if (!el) return;
    const onScroll = () => {
      stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
    };
    el.addEventListener("scroll", onScroll);
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  /* --- handlers --- */
  const handleLogin = async u => {
    setUser(u);
    await loadConvs();
  };

  const handleLogout = async () => {
    await L.Auth.logout();
    setUser(null);
    setConvs([]);
    setActiveId(null);
  };

  const newChat = () => { setActiveId(null); setNavOpen(false); setError(null); };

  const deleteConv = async id => {
    setConvs(cs => cs.filter(c => c.id !== id));
    if (id === activeId) setActiveId(null);
    try { await L.ConvsAPI.delete(id); } catch (e) { console.error("Delete failed:", e); }
  };

  /* --- send message --- */
  const send = async (textRaw, opts = {}) => {
    setError(null);
    const fileList   = opts.files || [];
    const attachMeta = fileList.map(f => ({ name: f.name, size: f.size, type: f.type, binary: !!f.binary }));

    let convId  = activeId;
    const isNew = !convId;

    /* Capture prior messages now, before any state mutations */
    const priorMessages = convId
      ? (convs.find(c => c.id === convId)?.messages || [])
      : [];

    /* Web search */
    let searchCtx = "", searched = false;
    if (opts.search) {
      const r = await L.searchWeb(textRaw);
      if (r.ok) { searched = true; searchCtx = L.searchContext(r.results); }
      else setError(r.error || "Search failed");
    }

    const privacy   = !!opts.privacy;
    const redact    = privacy ? L.privacyMode() : "off";
    const userMsg   = { role: "user", content: textRaw, ts: Date.now(), attachments: attachMeta, searched, privacy };
    const convTitle = (textRaw || (attachMeta[0] && attachMeta[0].name) || "New chat").slice(0, 48).replace(/\n/g, " ");

    /* Create or update conv in local state */
    if (!convId) {
      convId = L.uid();
      setConvs(cs => [{ id: convId, title: convTitle, created: Date.now(), updated: Date.now(), messages: [userMsg] }, ...cs]);
      setActiveId(convId);
    } else {
      setConvs(cs => cs.map(c => c.id === convId
        ? { ...c, updated: Date.now(), messages: [...c.messages, userMsg] }
        : c));
    }

    const botTs  = Date.now();
    const botMsg = { role: "assistant", content: "", ts: botTs, model };
    setConvs(cs => cs.map(c => c.id === convId
      ? { ...c, messages: [...c.messages, botMsg] }
      : c));

    stickRef.current = true;
    requestAnimationFrame(scrollToBottom);

    /* Build API payload */
    const payload = [...priorMessages, userMsg]
      .filter(m => m.content !== "" || m.role === "user")
      .map(m => ({ role: m.role, content: m.content }));

    /* Augment with search results + file contents (already converted to
       Markdown by the backend). Use plain delimiters rather than a code fence
       so Markdown inside the document renders normally and can't break out. */
    const fileBlocks = fileList.filter(f => f.text)
      .map(f => "--- Attached file: " + f.name + " (converted to Markdown) ---\n"
              + f.text
              + "\n--- end of " + f.name + " ---")
      .join("\n\n");
    const parts = [];
    if (searchCtx)  parts.push(searchCtx);
    if (fileBlocks) parts.push(fileBlocks);
    if (textRaw)    parts.push(textRaw);
    if ((searchCtx || fileBlocks) && payload.length) {
      for (let i = payload.length - 1; i >= 0; i--) {
        if (payload[i].role === "user") { payload[i] = { role: "user", content: parts.join("\n\n") }; break; }
      }
    }

    const controller = new AbortController();
    abortRef.current = controller;
    setBusy(true);

    const cid = convId; /* capture for closure */
    let acc = "";
    let served = { endpoint: null, model: null }; /* the provider + model that ACTUALLY answered */

    const flush = () => {
      setConvs(cs => cs.map(c => {
        if (c.id !== cid) return c;
        const msgs = c.messages.slice();
        for (let i = msgs.length - 1; i >= 0; i--) {
          if (msgs[i].role === "assistant") { msgs[i] = { ...msgs[i], content: acc }; break; }
        }
        return { ...c, messages: msgs, updated: Date.now() };
      }));
      if (stickRef.current) requestAnimationFrame(scrollToBottom);
    };

    /* Save conversation to API after streaming */
    const persistConv = () => {
      if (!acc) return;
      const finalMsgs = [
        ...priorMessages,
        userMsg,
        { role: "assistant", content: acc, ts: botTs, model,
          endpoint: served.endpoint, servedModel: served.model || model },
      ];
      const payload = { id: cid, title: convTitle, messages: finalMsgs, model };
      if (isNew) {
        L.ConvsAPI.create(payload).catch(e => console.error("Create conv failed:", e));
      } else {
        L.ConvsAPI.update(cid, { messages: finalMsgs }).catch(e => console.error("Update conv failed:", e));
      }
    };

    await L.streamChat({
      model,
      messages: payload,
      redact,
      signal: controller.signal,
      onToken: t => { acc += t; flush(); },
      onDone: info => {
        const ep          = info && info.endpoint;
        const servedModel = (info && info.model) || null;
        served = { endpoint: ep, model: servedModel };
        setEndpoint(ep); setBusy(false); abortRef.current = null;
        /* Stamp the real provider + real model onto the assistant message so
           every bubble shows what actually answered, not what we requested. */
        const redactions     = (info && info.redactions) || 0;
        const redactionTypes = (info && info.redactionTypes) || {};
        setConvs(cs => cs.map(c => {
          if (c.id !== cid) return c;
          const msgs = c.messages.slice();
          let stampedAssistant = false;
          for (let i = msgs.length - 1; i >= 0; i--) {
            if (!stampedAssistant && msgs[i].role === "assistant") {
              msgs[i] = { ...msgs[i], content: acc, endpoint: ep, servedModel: servedModel || msgs[i].model };
              stampedAssistant = true;
            } else if (privacy && msgs[i].role === "user") {
              msgs[i] = { ...msgs[i], redactions, redactionTypes };
              break;
            }
          }
          return { ...c, messages: msgs };
        }));
        persistConv();
      },
      onError: err => {
        setBusy(false); abortRef.current = null;
        if (err && err.name === "AbortError") {
          persistConv(); /* save partial if stopped */
          return;
        }
        setError((err && err.message) || "Request failed");
        if (!acc) {
          setConvs(cs => cs.map(c => {
            if (c.id !== cid) return c;
            const msgs = c.messages.slice();
            if (msgs.length && msgs[msgs.length - 1].role === "assistant" && !msgs[msgs.length - 1].content)
              msgs.pop();
            return { ...c, messages: msgs };
          }));
        }
      },
    });
  };

  const stop = () => { if (abortRef.current) abortRef.current.abort(); setBusy(false); };

  /* ---- render ---- */

  /* Show loading until auth is verified to avoid flash of wrong screen */
  if (!authChecked) {
    return (
      <div style={{ height: "100%", display: "grid", placeItems: "center", background: "var(--canvas)" }}>
        <span style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-4)", letterSpacing: "0.1em", textTransform: "uppercase" }}>
          Loading…
        </span>
      </div>
    );
  }

  if (!user) return <LoginScreen onLogin={handleLogin} />;

  const msgs = active ? active.messages : [];

  return (
    <div className={"app " + (navOpen ? "nav-open" : "")}>
      <div className="scrim" onClick={() => setNavOpen(false)} />
      <Sidebar
        convs={convs} activeId={activeId}
        onSelect={id => { setActiveId(id); setNavOpen(false); setError(null); }}
        onNew={newChat} onDelete={deleteConv}
        user={user} onLogout={handleLogout}
      />
      <div className="main">
        <div className="topbar">
          <div style={{ display: "flex", alignItems: "center", minWidth: 0 }}>
            <button className="menu-btn" onClick={() => setNavOpen(true)}><MenuIcon /></button>
            <div className="topbar-title">
              {active
                ? <><span>Chat</span>{active.title}</>
                : <><span>New</span>Start a conversation</>}
            </div>
          </div>
          <div className="tools">
            <ProviderSelect
              provider={provider} live={endpoint}
              setProvider={name => { L.setProvider(name); setProviderState(name); }} />
            <ModelSelect model={model} setModel={setModel} models={providerModels} />
            <button className="icon-btn" title="Advanced connection settings" onClick={() => setShowSettings(true)}><GearIcon /></button>
          </div>
        </div>

        <div className="scroll" ref={scrollRef}>
          {msgs.length === 0
            ? <Empty onPick={t => setPreset(t)} />
            : (
              <div className="thread">
                {msgs.map((m, i) => {
                  const requestedId    = m.model || model;
                  const servedId       = m.servedModel || requestedId;
                  const known          = L.findModel(servedId);
                  const modelLabel     = known ? known.label : servedId; /* raw id when off-catalog */
                  const reqKnown       = L.findModel(requestedId);
                  const requestedLabel = reqKnown ? reqKnown.label : requestedId;
                  /* Warn only on a GENUINE substitution. Normalise by dropping
                     provider prefixes + separators so "privatemode/gpt-oss-120b"
                     == "openai/gpt-oss-120b"; never warn for router/* aliases,
                     which resolve to another model by design. */
                  const norm = s => String(s || "").toLowerCase().split("/").pop().replace(/[\s._-]/g, "");
                  const isAlias = /^router\//i.test(requestedId);
                  const mismatch = !!m.servedModel && !isAlias && norm(m.servedModel) !== norm(requestedId);
                  return (
                    <Message key={i} m={m}
                             modelLabel={modelLabel}
                             provider={L.providerLabel(m.endpoint)}
                             requestedLabel={requestedLabel}
                             mismatch={mismatch}
                             streaming={busy && i === msgs.length - 1 && m.role === "assistant"} />
                  );
                })}
              </div>
            )}
        </div>

        {error && (
          <div style={{ padding: "0 24px" }}>
            <div className="banner">
              <span className="sq" style={{ width: 8, height: 8, background: "var(--maroon)" }} />
              {error}
              <button className="act-btn"
                      style={{ marginLeft: "auto", color: "var(--maroon)", borderColor: "var(--terra-line)" }}
                      onClick={() => setShowSettings(true)}>Connection</button>
              <button className="x" onClick={() => setError(null)}>✕</button>
            </div>
          </div>
        )}

        <Composer onSend={send} onStop={stop} busy={busy}
                  presetText={preset} clearPreset={() => setPreset("")} />
      </div>
      {showSettings && <Settings model={model} onClose={saved => { setShowSettings(false); setError(null); if (saved) setProviderState(L.getProvider()); }} />}
    </div>
  );
}

/* ---------- PWA install prompt ---------- */
const IosShareIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ width: 15, height: 15, verticalAlign: "-3px" }}>
    <path d="M12 15V3M8 7l4-4 4 4" />
    <path d="M6 11H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-1" />
  </svg>
);

function InstallPrompt() {
  const [deferred, setDeferred] = useState(null);
  const [show, setShow] = useState(false);
  const [ios,  setIos]  = useState(false);

  useEffect(() => {
    if (localStorage.getItem("indie.pwa.dismissed")) return;
    const standalone = (window.matchMedia && window.matchMedia("(display-mode: standalone)").matches)
      || window.navigator.standalone === true;
    if (standalone) return; /* already installed */

    const ua = navigator.userAgent || "";
    const isIOS = /iphone|ipad|ipod/i.test(ua)
      || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); /* iPadOS reports as Mac */
    if (isIOS) { setIos(true); setShow(true); return; }

    const onBIP = e => { e.preventDefault(); setDeferred(e); setShow(true); };
    const onInstalled = () => { setShow(false); setDeferred(null); };
    window.addEventListener("beforeinstallprompt", onBIP);
    window.addEventListener("appinstalled", onInstalled);
    return () => {
      window.removeEventListener("beforeinstallprompt", onBIP);
      window.removeEventListener("appinstalled", onInstalled);
    };
  }, []);

  const dismiss = () => { setShow(false); try { localStorage.setItem("indie.pwa.dismissed", "1"); } catch {} };
  const install = async () => {
    if (!deferred) return;
    deferred.prompt();
    try { await deferred.userChoice; } catch {}
    setShow(false); setDeferred(null);
  };

  if (!show) return null;
  return (
    <div className="pwa-banner" role="dialog" aria-label="Install indie.chat">
      <img className="pwa-ic" src="/icons/icon-192.png" alt="" />
      {ios ? (
        <span className="pwa-txt">
          Install <b>indie.chat</b>: tap <IosShareIcon /> <b>Share</b>, then <b>Add to Home Screen</b>.
        </span>
      ) : (
        <span className="pwa-txt">Add <b>indie.chat</b> to your home screen.</span>
      )}
      {!ios && <button className="pwa-install" onClick={install}>Install</button>}
      <button className="pwa-x" onClick={dismiss} aria-label="Dismiss">✕</button>
    </div>
  );
}

function Root() {
  return <><App /><InstallPrompt /></>;
}

L.configureMarked();
L.seedAdmin();
ReactDOM.createRoot(document.getElementById("root")).render(<Root />);
