import React, { useEffect, useMemo, useRef, useState } from "react";
import { ApiError, api, apiBlob, apiFetch, getApiBase, setApiBase } from "./api.js";
import { formatBytes, sha256Hex, sha256HexOfBlob } from "./sha256.js";

const TOKEN_KEY = "chatapp_token";
const KIND_KEY = "chatapp_session_kind";
const ACTIVE_CONVO_KEY = "chatapp_active_conversation_id";
const APP_CLIPBOARD_KEY = "chatapp_app_clipboard_text";
const TOAST_TTL_MS = 2600;

function loadStorage(key, fallback = "") {
  try {
    return localStorage.getItem(key) || fallback;
  } catch {
    return fallback;
  }
}

function saveStorage(key, value) {
  try {
    if (!value) localStorage.removeItem(key);
    else localStorage.setItem(key, String(value));
  } catch {
    // ignore
  }
}

function formatTs(tsSeconds) {
  if (!tsSeconds) return "—";
  const d = new Date(Number(tsSeconds) * 1000);
  if (Number.isNaN(d.getTime())) return String(tsSeconds);
  return d.toLocaleString();
}

function clampText(s, max) {
  const v = String(s ?? "");
  if (v.length <= max) return v;
  return `${v.slice(0, max - 1)}…`;
}

function normalizeClipboardText(value) {
  return String(value ?? "").trim();
}

function execCommandCopy(text) {
  try {
    const el = document.createElement("textarea");
    el.value = String(text ?? "");
    el.setAttribute("readonly", "");
    el.style.position = "fixed";
    el.style.top = "-1000px";
    el.style.left = "-1000px";
    el.style.opacity = "0";
    document.body.appendChild(el);
    el.focus();
    el.select();
    el.setSelectionRange(0, el.value.length);
    const ok = document.execCommand("copy");
    el.remove();
    return ok;
  } catch {
    return false;
  }
}

async function copyToSystemClipboard(text) {
  const value = String(text ?? "");
  if (!value) return false;
  try {
    if (navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(value);
      return true;
    }
  } catch {
    // fall through
  }
  return execCommandCopy(value);
}

function useMediaQuery(query) {
  const [matches, setMatches] = useState(() => {
    if (typeof window === "undefined") return false;
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const m = window.matchMedia(query);
    const onChange = () => setMatches(m.matches);
    setMatches(m.matches);
    m.addEventListener?.("change", onChange);
    return () => m.removeEventListener?.("change", onChange);
  }, [query]);

  return matches;
}

function useInterval(fn, delayMs) {
  const saved = useRef(fn);
  useEffect(() => {
    saved.current = fn;
  }, [fn]);

  useEffect(() => {
    if (delayMs == null) return;
    const id = setInterval(() => saved.current?.(), delayMs);
    return () => clearInterval(id);
  }, [delayMs]);
}

function ErrorBanner({ error, onClose }) {
  if (!error) return null;
  const msg = typeof error === "string" ? error : error.message || String(error);
  const code = error?.code ? ` (${error.code})` : "";
  return (
    <div className="banner bannerError">
      <div className="bannerText">
        {msg}
        {code}
      </div>
      <button className="btn btnGhost" onClick={onClose}>
        Close
      </button>
    </div>
  );
}

function Toast({ text }) {
  if (!text) return null;
  return <div className="toast">{text}</div>;
}

function Modal({ open, title, children, onClose }) {
  useEffect(() => {
    if (!open) return;
    const onKey = (e) => {
      if (e.key === "Escape") onClose?.();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onClose]);

  if (!open) return null;
  return (
    <div className="modalOverlay" onMouseDown={onClose}>
      <div className="modalCard" onMouseDown={(e) => e.stopPropagation()}>
        <div className="modalHeader">
          <div className="modalTitle">{title}</div>
          <button className="btn btnGhost" onClick={onClose}>
            ✕
          </button>
        </div>
        <div className="modalBody">{children}</div>
      </div>
    </div>
  );
}

function TextField({ label, value, onChange, placeholder = "", type = "text" }) {
  return (
    <label className="field">
      <div className="fieldLabel">{label}</div>
      <input className="input" type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
    </label>
  );
}

function Toggle({ label, checked, onChange }) {
  return (
    <label className="toggle">
      <input type="checkbox" checked={!!checked} onChange={(e) => onChange(e.target.checked)} />
      <span className="toggleLabel">{label}</span>
    </label>
  );
}

function UsernamePill({ user }) {
  if (!user) return null;
  const tags = [];
  if (user.is_admin) tags.push("admin");
  if (user.is_bot) tags.push(user.bot_verified ? "bot✓" : "bot");
  const suffix = tags.length ? ` · ${tags.join(" · ")}` : "";
  return (
    <span className="pill">
      {user.username}
      <span className="pillMuted">{suffix}</span>
    </span>
  );
}

function ConversationTitle({ conversation, members, me }) {
  if (!conversation) return "Chat";
  if (conversation.kind === "group") return conversation.name || "Group";
  const other = (members || []).find((m) => m.id !== me?.id);
  if (other) return `DM · ${other.username}`;
  return "DM";
}

function AuthView({ apiBase, setApiBaseInput, onSession, setGlobalError }) {
  const [mode, setMode] = useState("login"); // login | register
  const [busy, setBusy] = useState(false);

  const [login, setLogin] = useState("");
  const [password, setPassword] = useState("");

  const [email, setEmail] = useState("");
  const [username, setUsername] = useState("");
  const [regPassword, setRegPassword] = useState("");

  async function doLogin() {
    setGlobalError("");
    setBusy(true);
    try {
      const res = await api("/api/v1/auth/login", { method: "POST", body: { login, password } });
      onSession({ token: res.token, kind: res.kind || "normal", mustRenew: !!res.must_renew_password });
    } catch (e) {
      setGlobalError(e);
    } finally {
      setBusy(false);
    }
  }

  async function doRegister() {
    setGlobalError("");
    setBusy(true);
    try {
      await api("/api/v1/auth/register", { method: "POST", body: { email, username, password: regPassword } });
      const res = await api("/api/v1/auth/login", { method: "POST", body: { login: email || username, password: regPassword } });
      onSession({ token: res.token, kind: res.kind || "normal", mustRenew: !!res.must_renew_password });
    } catch (e) {
      setGlobalError(e);
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="wrap">
      <div className="topTitle">
        <div className="appName">Chat Web</div>
        <div className="muted">Desktop + mobile test client</div>
      </div>

      <div className="card">
        <div className="row rowWrap" style={{ justifyContent: "space-between" }}>
          <div className="tabs">
            <button className={`tab ${mode === "login" ? "tabActive" : ""}`} onClick={() => setMode("login")}>
              Login
            </button>
            <button className={`tab ${mode === "register" ? "tabActive" : ""}`} onClick={() => setMode("register")}>
              Register
            </button>
          </div>
          <div className="muted">API: {apiBase || "(same origin)"}</div>
        </div>

        <div className="grid2">
          <TextField
            label="API Base URL (optional)"
            value={apiBase}
            onChange={(v) => {
              setApiBaseInput(v);
              setApiBase(v);
            }}
            placeholder="e.g. http://127.0.0.1:8080"
          />
          <div className="muted" style={{ alignSelf: "end" }}>
            Tip: leave empty when using Vite proxy.
          </div>
        </div>
      </div>

      {mode === "login" ? (
        <div className="card">
          <h2>Login</h2>
          <div className="col">
            <TextField label="Email or Username" value={login} onChange={setLogin} placeholder="you@example.com" />
            <TextField label="Password or OTP" type="password" value={password} onChange={setPassword} placeholder="••••••••" />
            <div className="row">
              <button className="btn btnPrimary" disabled={busy || !login || !password} onClick={doLogin}>
                {busy ? "Working…" : "Login"}
              </button>
              <span className="muted">OTP login triggers password reset</span>
            </div>
          </div>
        </div>
      ) : (
        <div className="card">
          <h2>Register</h2>
          <div className="col">
            <div className="grid2">
              <TextField label="Email" value={email} onChange={setEmail} placeholder="you@example.com" />
              <TextField label="Username" value={username} onChange={setUsername} placeholder="alice" />
            </div>
            <TextField label="Password" type="password" value={regPassword} onChange={setRegPassword} placeholder="••••••••" />
            <div className="row">
              <button className="btn btnPrimary" disabled={busy || !email || !username || !regPassword} onClick={doRegister}>
                {busy ? "Working…" : "Create account"}
              </button>
              <span className="muted">Auto-login after register</span>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

function ResetPasswordView({ token, onSession, setGlobalError }) {
  const [p1, setP1] = useState("");
  const [p2, setP2] = useState("");
  const [busy, setBusy] = useState(false);

  async function doReset() {
    setGlobalError("");
    if (!p1 || p1 !== p2) {
      setGlobalError(new Error("Passwords do not match"));
      return;
    }
    setBusy(true);
    try {
      const res = await api("/api/v1/auth/reset-password/complete", { method: "POST", token, body: { new_password: p1 } });
      onSession({ token: res.token, kind: res.kind || "normal", mustRenew: false });
    } catch (e) {
      setGlobalError(e);
    } finally {
      setBusy(false);
    }
  }

  return (
    <div className="wrap">
      <div className="topTitle">
        <div className="appName">Password reset required</div>
        <div className="muted">Set a new password to continue</div>
      </div>
      <div className="card">
        <div className="col">
          <TextField label="New password" type="password" value={p1} onChange={setP1} />
          <TextField label="Repeat new password" type="password" value={p2} onChange={setP2} />
          <div className="row">
            <button className="btn btnPrimary" disabled={busy || !p1 || !p2} onClick={doReset}>
              {busy ? "Working…" : "Update password"}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

function Attachment({ attachment, messageKind, token, onOpenImage }) {
  const [url, setUrl] = useState("");
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");

  const isImage = (attachment?.content_type || "").startsWith("image/") || messageKind === "image";
  const isAudio = (attachment?.content_type || "").startsWith("audio/") || messageKind === "voice";

  useEffect(() => {
    let cancelled = false;
    let objectUrl = "";

    async function load() {
      if (!attachment?.url) return;
      if (!isImage && !isAudio) return;
      setErr("");
      try {
        const b = await apiBlob(attachment.url, { token });
        if (cancelled) return;
        objectUrl = URL.createObjectURL(b);
        setUrl(objectUrl);
      } catch (e) {
        setErr(e.message || String(e));
      }
    }
    load();

    return () => {
      cancelled = true;
      if (objectUrl) URL.revokeObjectURL(objectUrl);
    };
  }, [attachment?.url, isImage, isAudio, token]);

  async function download() {
    if (!attachment?.url) return;
    setErr("");
    setBusy(true);
    try {
      const b = await apiBlob(`${attachment.url}?download=1`, { token });
      const objectUrl = URL.createObjectURL(b);
      const a = document.createElement("a");
      a.href = objectUrl;
      a.download = attachment.filename || "download";
      document.body.appendChild(a);
      a.click();
      a.remove();
      setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
    } catch (e) {
      setErr(e.message || String(e));
    } finally {
      setBusy(false);
    }
  }

  if (!attachment) return null;
  return (
    <div className="attachment">
      {isImage ? (
        url ? (
          <button className="attachmentImageBtn" onClick={() => onOpenImage?.(url, attachment.filename)}>
            <img className="attachmentImage" src={url} alt={attachment.filename || "image"} />
          </button>
        ) : (
          <div className="muted">{err ? `Image failed: ${err}` : "Loading image…"}</div>
        )
      ) : isAudio ? (
        url ? <audio className="attachmentAudio" controls src={url} /> : <div className="muted">{err ? `Audio failed: ${err}` : "Loading audio…"}</div>
      ) : (
        <div className="attachmentFile">
          <div className="attachmentFileMeta">
            <div className="attachmentFileName">{attachment.filename || "file"}</div>
            <div className="muted">
              {formatBytes(attachment.size)} · {attachment.content_type || "application/octet-stream"}
            </div>
          </div>
          <button className="btn btnGhost" disabled={busy} onClick={download}>
            {busy ? "…" : "Download"}
          </button>
        </div>
      )}
      {err && !isImage && !isAudio ? <div className="muted">{err}</div> : null}
    </div>
  );
}

function MessageRow({
  msg,
  me,
  sender,
  onEdit,
  onDelete,
  onToggleReaction,
  token,
  onOpenImage,
  editing,
  setEditing,
  editText,
  setEditText,
  busyMessageId,
  canModerate,
}) {
  const mine = msg.sender_id === me?.id;
  const canEdit = mine && msg.kind === "text";
  const canDelete = mine || !!canModerate;
  const deleted = !!msg.deleted_at;

  return (
    <div className={`messageRow ${mine ? "messageMine" : ""}`}>
      <div className="messageMeta">
        <div className="messageSender">{sender ? sender.username : msg.sender_id}</div>
        <div className="messageTime">
          {formatTs(msg.created_at)}
          {msg.edited_at ? " · edited" : ""}
        </div>
      </div>

      <div className={`messageBubble ${deleted ? "messageDeleted" : ""}`}>
        {deleted ? (
          <div className="muted">Message deleted</div>
        ) : msg.kind === "text" ? (
          editing ? (
            <div className="col">
              <textarea className="input textarea" value={editText} onChange={(e) => setEditText(e.target.value)} rows={3} />
              <div className="row rowWrap">
                <button className="btn btnPrimary" disabled={busyMessageId === msg.id} onClick={() => onEdit(msg.id, editText)}>
                  Save
                </button>
                <button className="btn btnGhost" disabled={busyMessageId === msg.id} onClick={() => setEditing(null)}>
                  Cancel
                </button>
              </div>
            </div>
          ) : (
            <div className="messageText">{msg.text}</div>
          )
        ) : (
          <Attachment attachment={msg.attachment} messageKind={msg.kind} token={token} onOpenImage={onOpenImage} />
        )}
      </div>

      {!deleted ? (
        <div className="messageActions row rowWrap">
          <div className="reactions">
            {(msg.reactions || []).map((r) => (
              <button
                key={r.emoji}
                className={`reactionChip ${r.mine ? "reactionMine" : ""}`}
                onClick={() => onToggleReaction(msg.id, r.emoji, r.mine)}
              >
                {r.emoji} <span className="reactionCount">{r.count}</span>
              </button>
            ))}
            {["👍", "❤️", "😂", "😮", "😢", "👎"].map((emoji) => {
              const existing = (msg.reactions || []).find((r) => r.emoji === emoji);
              const mine = !!existing?.mine;
              return (
                <button
                  key={emoji}
                  className={`reactionAdd ${mine ? "reactionMine" : ""}`}
                  onClick={() => onToggleReaction(msg.id, emoji, mine)}
                  title="React"
                >
                  {emoji}
                </button>
              );
            })}
          </div>

          <div className="spacer" />

          {canEdit && !editing ? (
            <button
              className="btn btnGhost"
              disabled={busyMessageId === msg.id}
              onClick={() => {
                setEditing(msg.id);
                setEditText(msg.text || "");
              }}
            >
              Edit
            </button>
          ) : null}
          {canDelete ? (
            <button className="btn btnDanger" disabled={busyMessageId === msg.id} onClick={() => onDelete(msg.id)}>
              Delete
            </button>
          ) : null}
        </div>
      ) : null}
    </div>
  );
}

function ChatView({ token, me, conversationId, clipboardText, onBack, onTouchConversationUpdated, setGlobalError }) {
  const isMobile = useMediaQuery("(max-width: 720px)");

  const [loading, setLoading] = useState(false);
  const [conversation, setConversation] = useState(null);
  const [members, setMembers] = useState([]);
  const [messages, setMessages] = useState([]);

  const [composer, setComposer] = useState("");
  const [sendBusy, setSendBusy] = useState(false);

  const [editingId, setEditingId] = useState(null);
  const [editText, setEditText] = useState("");
  const [busyMessageId, setBusyMessageId] = useState("");

  const [showInfo, setShowInfo] = useState(false);
  const [memberAddId, setMemberAddId] = useState("");

  const [uploadState, setUploadState] = useState(null); // { phase, filename, processedBytes, totalBytes }
  const uploadAbortRef = useRef(null);

  const [imageModal, setImageModal] = useState({ open: false, url: "", title: "" });

  const listRef = useRef(null);
  const autoScrollRef = useRef(true);
  const lastCreatedAtRef = useRef(0);
  const pollBusyRef = useRef(false);

  const memberById = useMemo(() => {
    const m = new Map();
    for (const u of members || []) m.set(u.id, u);
    return m;
  }, [members]);

  const title = ConversationTitle({ conversation, members, me });

  function sortMessages(list) {
    return [...list].sort((a, b) => {
      if (a.created_at !== b.created_at) return a.created_at - b.created_at;
      return a.id.localeCompare(b.id);
    });
  }

  function messageSig(m) {
    const att = m.attachment
      ? `${m.attachment.id || ""}|${m.attachment.filename || ""}|${m.attachment.size || ""}|${m.attachment.content_type || ""}|${m.attachment.url || ""}`
      : "";
    const reactions = Array.isArray(m.reactions) ? m.reactions.map((r) => `${r.emoji}:${r.count}:${r.mine ? 1 : 0}`).join(",") : "";
    return `${m.kind}|${m.text || ""}|${m.edited_at || 0}|${m.deleted_at || 0}|${m.deleted_by || ""}|${att}|${reactions}`;
  }

  function mergeMessages(incoming) {
    if (!Array.isArray(incoming) || incoming.length === 0) return;

    const beforeMax = lastCreatedAtRef.current;
    let maxCreated = beforeMax;
    for (const m of incoming) maxCreated = Math.max(maxCreated, Number(m.created_at) || 0);
    lastCreatedAtRef.current = maxCreated;

    setMessages((prev) => {
      const byId = new Map();
      for (const m of prev) byId.set(m.id, m);
      let changed = false;
      for (const m of incoming) {
        const old = byId.get(m.id);
        if (!old) {
          byId.set(m.id, m);
          changed = true;
          continue;
        }
        const merged = { ...old, ...m };
        if (messageSig(old) !== messageSig(merged)) {
          byId.set(m.id, merged);
          changed = true;
        }
      }
      if (!changed) return prev;
      return sortMessages(Array.from(byId.values()));
    });
    if (maxCreated > beforeMax) onTouchConversationUpdated?.();
  }

  async function loadConversationAndMessages() {
    if (!conversationId) return;
    setGlobalError("");
    setLoading(true);
    setConversation(null);
    setMembers([]);
    setMessages([]);
    lastCreatedAtRef.current = 0;
    try {
      const convoRes = await api(`/api/v1/conversations/${conversationId}`, { token });
      setConversation(convoRes.conversation);
      setMembers(convoRes.members || []);

      // Page forward from since=0 until we're caught up (bounded).
      let since = 0;
      for (let i = 0; i < 30; i++) {
        const sinceParam = Math.max(0, Number(since) - 1);
        const res = await api(`/api/v1/conversations/${conversationId}/messages?since=${sinceParam}&limit=200`, { token });
        const batch = res.messages || [];
        mergeMessages(batch);
        const maxCreated = batch.reduce((acc, m) => Math.max(acc, Number(m.created_at) || 0), since);
        if (maxCreated <= since) break;
        since = maxCreated;
        if (batch.length < 200) break;
      }

      // Scroll to bottom after initial load
      requestAnimationFrame(() => {
        const el = listRef.current;
        if (!el) return;
        el.scrollTop = el.scrollHeight;
      });
    } catch (e) {
      setGlobalError(e);
    } finally {
      setLoading(false);
    }
  }

  async function pollNew() {
    if (!conversationId) return;
    if (pollBusyRef.current) return;
    pollBusyRef.current = true;
    try {
      const since = Math.max(0, Number(lastCreatedAtRef.current || 0) - 1);
      const res = await api(`/api/v1/conversations/${conversationId}/messages?since=${since}&limit=200`, { token });
      const batch = res.messages || [];
      mergeMessages(batch);
    } catch (e) {
      if (e instanceof ApiError && (e.status === 401 || e.status === 423)) throw e;
      // ignore transient polling errors
    } finally {
      pollBusyRef.current = false;
    }
  }

  useEffect(() => {
    loadConversationAndMessages();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [conversationId, token]);

  useInterval(() => {
    pollNew().catch((e) => setGlobalError(e));
  }, conversationId ? 1500 : null);

  useEffect(() => {
    const el = listRef.current;
    if (!el) return;
    const onScroll = () => {
      const dist = el.scrollHeight - (el.scrollTop + el.clientHeight);
      autoScrollRef.current = dist < 200;
    };
    el.addEventListener("scroll", onScroll);
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  useEffect(() => {
    if (!autoScrollRef.current) return;
    const el = listRef.current;
    if (!el) return;
    el.scrollTop = el.scrollHeight;
  }, [messages]);

  async function sendText() {
    const text = composer.trimEnd();
    if (!text) return;
    setSendBusy(true);
    setGlobalError("");
    try {
      await api(`/api/v1/conversations/${conversationId}/messages`, { method: "POST", token, body: { kind: "text", text } });
      setComposer("");
      await pollNew();
    } catch (e) {
      setGlobalError(e);
    } finally {
      setSendBusy(false);
    }
  }

  async function uploadAndSend({ blob, filename, contentType, kind }) {
    if (!blob || !filename) return;
    setGlobalError("");
    setUploadState({ phase: "hashing", filename, processedBytes: 0, totalBytes: blob.size || 0 });
    const controller = new AbortController();
    uploadAbortRef.current = { type: "upload", controller };
    try {
      const overallSha = await sha256HexOfBlob(blob, {
        chunkSize: 1024 * 1024,
        onProgress: ({ processedBytes, totalBytes }) => setUploadState({ phase: "hashing", filename, processedBytes, totalBytes }),
      });

      const createRes = await api("/api/v1/uploads", {
        method: "POST",
        token,
        body: {
          filename,
          content_type: contentType || null,
          total_size: blob.size,
          overall_sha256_hex: overallSha,
          chunk_size: 1024 * 1024,
        },
      });
      const uploadId = createRes.upload_id;
      const chunkSize = Number(createRes.chunk_size) || 1024 * 1024;
      const numChunks = Number(createRes.num_chunks) || Math.ceil(blob.size / chunkSize);

      let uploaded = 0;
      for (let i = 0; i < numChunks; i++) {
        const start = i * chunkSize;
        const end = Math.min(blob.size, start + chunkSize);
        const slice = blob.slice(start, end);
        const buf = new Uint8Array(await slice.arrayBuffer());
        const chunkSha = sha256Hex(buf);
        setUploadState({ phase: "uploading", filename, processedBytes: uploaded, totalBytes: blob.size });

        const res = await apiFetch(`/api/v1/uploads/${uploadId}/chunks/${i}`, {
          method: "PUT",
          token,
          headers: { "X-Chunk-Sha256": chunkSha },
          body: buf,
          signal: controller.signal,
        });
        if (!res.ok) {
          const data = await res.json().catch(() => ({}));
          throw new ApiError(data?.error?.message || `HTTP ${res.status}`, { status: res.status, code: data?.error?.code || "error", data });
        }
        uploaded += buf.length;
        setUploadState({ phase: "uploading", filename, processedBytes: uploaded, totalBytes: blob.size });
      }

      setUploadState({ phase: "finalizing", filename, processedBytes: blob.size, totalBytes: blob.size });
      const finalizeRes = await api(`/api/v1/uploads/${uploadId}/finalize`, { method: "POST", token, body: null });
      const attachmentId = finalizeRes.attachment_id;

      await api(`/api/v1/conversations/${conversationId}/messages`, {
        method: "POST",
        token,
        body: { kind, attachment_id: attachmentId },
      });
      setUploadState(null);
      await pollNew();
    } catch (e) {
      if (e?.name === "AbortError") {
        setGlobalError(new Error("Upload canceled"));
      } else {
        setGlobalError(e);
      }
      setUploadState(null);
    } finally {
      uploadAbortRef.current = null;
    }
  }

  function onPickFile(file) {
    if (!file) return;
    const type = String(file.type || "");
    const kind = type.startsWith("image/") ? "image" : type.startsWith("audio/") ? "voice" : "file";
    uploadAndSend({ blob: file, filename: file.name || "upload", contentType: file.type || null, kind });
  }

  async function recordVoice() {
    setGlobalError("");
    if (typeof MediaRecorder === "undefined") {
      setGlobalError(new Error("Audio recording not supported in this browser"));
      return;
    }
    if (!navigator.mediaDevices?.getUserMedia) {
      setGlobalError(new Error("Audio recording not supported in this browser"));
      return;
    }
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const mime = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "";
      const recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
      const chunks = [];
      recorder.ondataavailable = (e) => {
        if (e.data && e.data.size > 0) chunks.push(e.data);
      };
      recorder.onstop = () => {
        stream.getTracks().forEach((t) => t.stop());
      };

      recorder.start();
      setUploadState({ phase: "recording", filename: "voice", processedBytes: 0, totalBytes: 0 });

      uploadAbortRef.current = { type: "voice", recorder, chunks, stream };
    } catch (e) {
      setGlobalError(e);
    }
  }

  async function stopVoiceAndSend() {
    const handle = uploadAbortRef.current;
    if (!handle || handle.type !== "voice") return;
    const recorder = handle.recorder;
    const chunks = handle.chunks;
    const stream = handle.stream;
    setUploadState({ phase: "processing", filename: "voice", processedBytes: 0, totalBytes: 0 });

    recorder.onstop = async () => {
      stream.getTracks().forEach((t) => t.stop());
      try {
        const blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" });
        const filename = `voice-${Date.now()}.webm`;
        await uploadAndSend({ blob, filename, contentType: blob.type || "audio/webm", kind: "voice" });
      } catch (e) {
        setGlobalError(e);
      }
    };
    recorder.stop();
    uploadAbortRef.current = null;
  }

  function cancelUploadOrRecording() {
    const h = uploadAbortRef.current;
    if (!h) return;
    try {
      if (h.type === "upload") h.controller.abort();
      else if (h.type === "voice") {
        h.recorder.onstop = () => {
          h.stream.getTracks().forEach((t) => t.stop());
        };
        h.recorder.stop();
      }
    } catch {
      // ignore
    }
    uploadAbortRef.current = null;
    setUploadState(null);
  }

  async function editMessage(messageId, newText) {
    setBusyMessageId(messageId);
    setGlobalError("");
    try {
      await api(`/api/v1/messages/${messageId}`, { method: "PATCH", token, body: { text: newText } });
      setMessages((prev) => prev.map((m) => (m.id === messageId ? { ...m, text: newText, edited_at: Math.floor(Date.now() / 1000) } : m)));
      setEditingId(null);
    } catch (e) {
      setGlobalError(e);
    } finally {
      setBusyMessageId("");
    }
  }

  async function deleteMessage(messageId) {
    if (!confirm("Delete this message?")) return;
    setBusyMessageId(messageId);
    setGlobalError("");
    try {
      await api(`/api/v1/messages/${messageId}`, { method: "DELETE", token, body: null });
      setMessages((prev) => prev.filter((m) => m.id !== messageId));
    } catch (e) {
      setGlobalError(e);
    } finally {
      setBusyMessageId("");
    }
  }

  async function toggleReaction(messageId, emoji, mine) {
    setGlobalError("");
    const action = mine ? "remove" : "add";
    try {
      await api(`/api/v1/messages/${messageId}/reactions`, { method: "POST", token, body: { emoji, action } });
      setMessages((prev) =>
        prev.map((m) => {
          if (m.id !== messageId) return m;
          const reactions = Array.isArray(m.reactions) ? [...m.reactions] : [];
          const idx = reactions.findIndex((r) => r.emoji === emoji);
          if (action === "add") {
            if (idx === -1) reactions.push({ emoji, count: 1, mine: true });
            else reactions[idx] = { ...reactions[idx], count: reactions[idx].count + (mine ? 0 : 1), mine: true };
          } else {
            if (idx === -1) return m;
            const nextCount = Math.max(0, reactions[idx].count - 1);
            if (nextCount === 0) reactions.splice(idx, 1);
            else reactions[idx] = { ...reactions[idx], count: nextCount, mine: false };
          }
          return { ...m, reactions };
        })
      );
    } catch (e) {
      setGlobalError(e);
    }
  }

  async function addMember() {
    const uid = memberAddId.trim();
    if (!uid) return;
    setGlobalError("");
    try {
      await api(`/api/v1/conversations/${conversationId}/members`, { method: "POST", token, body: { user_id: uid } });
      setMemberAddId("");
      const convoRes = await api(`/api/v1/conversations/${conversationId}`, { token });
      setConversation(convoRes.conversation);
      setMembers(convoRes.members || []);
    } catch (e) {
      setGlobalError(e);
    }
  }

  async function removeMember(uid, ban) {
    if (!confirm(ban ? "Ban this member?" : "Remove this member?")) return;
    setGlobalError("");
    try {
      const q = ban ? "?ban=1" : "";
      await api(`/api/v1/conversations/${conversationId}/members/${uid}${q}`, { method: "DELETE", token, body: null });
      const convoRes = await api(`/api/v1/conversations/${conversationId}`, { token });
      setConversation(convoRes.conversation);
      setMembers(convoRes.members || []);
    } catch (e) {
      setGlobalError(e);
    }
  }

  if (!conversationId) {
    return (
      <div className="card">
        <div className="muted">Select a conversation to start chatting.</div>
      </div>
    );
  }

  const canManageMembers = conversation?.kind === "group" && (Number(conversation?.is_admin) === 1 || Number(me?.is_admin) === 1);
  const canModerate = Number(me?.is_admin) === 1 || (conversation?.kind === "group" && Number(conversation?.is_admin) === 1);

  return (
    <div className="chat">
      <div className="chatHeader">
        <div className="row rowWrap">
          {isMobile ? (
            <button className="btn btnGhost" onClick={onBack}>
              ←
            </button>
          ) : null}
          <div className="chatTitle">{title}</div>
          <button className="btn btnGhost" onClick={() => setShowInfo(true)}>
            Info
          </button>
        </div>
        <div className="muted">Conversation id: {conversationId}</div>
      </div>

      <div className="chatBody">
        {loading ? <div className="muted">Loading…</div> : null}
        <div className="messageList" ref={listRef}>
          {messages.length === 0 ? <div className="muted">No messages yet.</div> : null}
          {messages.map((m) => (
            <MessageRow
              key={m.id}
              msg={m}
              me={me}
              sender={memberById.get(m.sender_id)}
              onEdit={editMessage}
              onDelete={deleteMessage}
              onToggleReaction={toggleReaction}
              token={token}
              onOpenImage={(url, title) => setImageModal({ open: true, url, title: title || "image" })}
              editing={editingId === m.id}
              setEditing={setEditingId}
              editText={editText}
              setEditText={setEditText}
              busyMessageId={busyMessageId}
              canModerate={canModerate}
            />
          ))}
        </div>

        <div className="composer">
          {uploadState ? (
            <div className="uploadBar">
              <div className="uploadLabel">
                {uploadState.phase === "recording"
                  ? "Recording…"
                  : `${uploadState.phase}: ${clampText(uploadState.filename, 30)} (${formatBytes(uploadState.processedBytes)} / ${formatBytes(uploadState.totalBytes)})`}
              </div>
              <div className="spacer" />
              {uploadState.phase === "recording" ? (
                <button className="btn btnPrimary" onClick={stopVoiceAndSend}>
                  Stop & send
                </button>
              ) : null}
              <button className="btn btnGhost" onClick={cancelUploadOrRecording}>
                Cancel
              </button>
            </div>
          ) : null}

          <div className="composerRow">
            <textarea
              className="input textarea"
              rows={2}
              placeholder="Message…"
              value={composer}
              disabled={sendBusy || !!uploadState}
              onChange={(e) => setComposer(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter" && !e.shiftKey) {
                  e.preventDefault();
                  sendText();
                }
              }}
            />
            <div className="composerButtons">
              <label className="btn btnGhost fileBtn">
                Attach
                <input
                  type="file"
                  style={{ display: "none" }}
                  disabled={!!uploadState}
                  onChange={(e) => onPickFile(e.target.files?.[0] || null)}
                />
              </label>
              <button className="btn btnGhost" disabled={!!uploadState} onClick={recordVoice}>
                Voice
              </button>
              <button
                className="btn btnGhost"
                disabled={!!uploadState || !normalizeClipboardText(clipboardText)}
                onClick={() => {
                  const v = normalizeClipboardText(clipboardText);
                  if (!v) return;
                  setComposer((prev) => {
                    const p = String(prev || "");
                    if (!p) return v;
                    const sep = p.endsWith(" ") || p.endsWith("\n") ? "" : " ";
                    return `${p}${sep}${v}`;
                  });
                }}
              >
                Paste
              </button>
              <button className="btn btnPrimary" disabled={sendBusy || !!uploadState || !composer.trim()} onClick={sendText}>
                Send
              </button>
            </div>
          </div>
          <div className="muted">Enter = send, Shift+Enter = newline</div>
        </div>
      </div>

      <Modal open={showInfo} title="Conversation info" onClose={() => setShowInfo(false)}>
        <div className="col">
          <div className="muted">Members ({members.length})</div>
          <div className="col">
            {members.map((m) => (
              <div key={m.id} className="row rowWrap" style={{ justifyContent: "space-between" }}>
                <div className="col" style={{ gap: 2 }}>
                  <div className="row rowWrap">
                    <UsernamePill user={m} />
                    {m.is_admin ? <span className="tag">group admin</span> : null}
                  </div>
                  <div className="muted">id: {m.id}</div>
                </div>
                {canManageMembers && m.id !== me?.id ? (
                  <div className="row">
                    <button className="btn btnGhost" onClick={() => removeMember(m.id, false)}>
                      Remove
                    </button>
                    <button className="btn btnDanger" onClick={() => removeMember(m.id, true)}>
                      Ban
                    </button>
                  </div>
                ) : null}
              </div>
            ))}
          </div>

          {canManageMembers ? (
            <div className="card cardInner">
              <div className="muted">Add member (by user id)</div>
              <div className="row rowWrap">
                <input className="input" value={memberAddId} onChange={(e) => setMemberAddId(e.target.value)} placeholder="user_id…" />
                <button
                  className="btn btnGhost"
                  disabled={!normalizeClipboardText(clipboardText)}
                  onClick={() => setMemberAddId(normalizeClipboardText(clipboardText))}
                >
                  Paste
                </button>
                <button className="btn btnPrimary" onClick={addMember}>
                  Add
                </button>
              </div>
            </div>
          ) : null}
        </div>
      </Modal>

      <Modal open={imageModal.open} title={imageModal.title} onClose={() => setImageModal({ open: false, url: "", title: "" })}>
        {imageModal.url ? <img className="imagePreview" src={imageModal.url} alt={imageModal.title || "image"} /> : null}
      </Modal>
    </div>
  );
}

export default function App() {
  const isMobile = useMediaQuery("(max-width: 720px)");

  const [apiBase, setApiBaseInput] = useState(() => getApiBase());

  const [token, setToken] = useState(() => loadStorage(TOKEN_KEY, ""));
  const [sessionKind, setSessionKind] = useState(() => loadStorage(KIND_KEY, "normal"));

  const [appClipboard, setAppClipboard] = useState(() => loadStorage(APP_CLIPBOARD_KEY, ""));
  const [toast, setToast] = useState("");

  const [me, setMe] = useState(null);
  const [conversations, setConversations] = useState([]);
  const [activeConversationId, setActiveConversationId] = useState(() => loadStorage(ACTIVE_CONVO_KEY, ""));

  const [settingsOpen, setSettingsOpen] = useState(false);
  const [globalError, setGlobalError] = useState("");
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!toast) return;
    const id = setTimeout(() => setToast(""), TOAST_TTL_MS);
    return () => clearTimeout(id);
  }, [toast]);

  async function copyText(text) {
    const value = normalizeClipboardText(text);
    if (!value) return;
    setAppClipboard(value);
    saveStorage(APP_CLIPBOARD_KEY, value);
    const ok = await copyToSystemClipboard(value);
    setToast(ok ? "Copied to clipboard" : "Clipboard blocked (saved in app clipboard; use Paste)");
  }

  function onSession({ token, kind, mustRenew }) {
    saveStorage(TOKEN_KEY, token);
    saveStorage(KIND_KEY, kind || "normal");
    setToken(token);
    setSessionKind(kind || "normal");
    if (mustRenew) {
      // /me will still load, but chat endpoints will reject until reset complete.
    }
  }

  async function loadMeAndConversations() {
    if (!token) return;
    setLoading(true);
    try {
      const meRes = await api("/api/v1/me", { token });
      setMe(meRes.user);
      if (meRes.session?.kind) {
        setSessionKind(meRes.session.kind);
        saveStorage(KIND_KEY, meRes.session.kind);
      }
      const convRes = await api("/api/v1/conversations", { token });
      setConversations(convRes.conversations || []);
    } catch (e) {
      setGlobalError(e);
      saveStorage(TOKEN_KEY, "");
      saveStorage(KIND_KEY, "");
      setToken("");
      setSessionKind("normal");
      setMe(null);
      setConversations([]);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    loadMeAndConversations();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [token]);

  useInterval(() => {
    if (!token) return;
    api("/api/v1/conversations", { token })
      .then((r) => setConversations(r.conversations || []))
      .catch(() => {});
  }, token ? 10000 : null);

  function selectConversation(id) {
    setActiveConversationId(id);
    saveStorage(ACTIVE_CONVO_KEY, id);
  }

  async function logout() {
    try {
      await api("/api/v1/auth/logout", { method: "POST", token, body: null });
    } catch {
      // ignore
    }
    saveStorage(TOKEN_KEY, "");
    saveStorage(KIND_KEY, "");
    setToken("");
    setSessionKind("normal");
    setMe(null);
    setConversations([]);
    setActiveConversationId("");
    saveStorage(ACTIVE_CONVO_KEY, "");
    setSettingsOpen(false);
  }

  async function createDm(otherUserId) {
    setGlobalError("");
    try {
      const res = await api("/api/v1/conversations/dm", { method: "POST", token, body: { other_user_id: otherUserId } });
      await loadMeAndConversations();
      selectConversation(res.conversation_id);
    } catch (e) {
      setGlobalError(e);
    }
  }

  async function createGroup(name, memberIds) {
    setGlobalError("");
    try {
      const res = await api("/api/v1/conversations/group", { method: "POST", token, body: { name, member_ids: memberIds } });
      await loadMeAndConversations();
      selectConversation(res.conversation_id);
    } catch (e) {
      setGlobalError(e);
    }
  }

  async function updateSettings(patch) {
    setGlobalError("");
    try {
      await api("/api/v1/me/settings", { method: "PATCH", token, body: patch });
      await loadMeAndConversations();
    } catch (e) {
      setGlobalError(e);
    }
  }

  if (!token) {
    return <AuthView apiBase={apiBase} setApiBaseInput={setApiBaseInput} onSession={onSession} setGlobalError={setGlobalError} />;
  }

  const mustResetPassword = sessionKind === "otp_reset" || Number(me?.password_reset_required) === 1;
  if (mustResetPassword) {
    return <ResetPasswordView token={token} onSession={onSession} setGlobalError={setGlobalError} />;
  }

  return (
    <div className="app">
      <div className="topbar">
        <div className="row rowWrap">
          <div className="appName">Chat Web</div>
          {loading ? <span className="muted">sync…</span> : null}
        </div>
        <div className="row rowWrap" style={{ justifyContent: "flex-end" }}>
          <button className="btn btnGhost" onClick={() => setSettingsOpen(true)}>
            Settings
          </button>
          <button className="btn btnGhost" onClick={logout}>
            Logout
          </button>
        </div>
      </div>

      <ErrorBanner error={globalError} onClose={() => setGlobalError("")} />

      <div className={`layout ${isMobile ? "layoutMobile" : ""} ${activeConversationId ? "layoutHasChat" : ""}`}>
        <div className={`sidebar ${isMobile && activeConversationId ? "sidebarHidden" : ""}`}>
          <div className="card">
            <div className="row rowWrap" style={{ justifyContent: "space-between" }}>
              <div className="col" style={{ gap: 2 }}>
                <div className="row rowWrap">
                  <UsernamePill user={me} />
                  <span className="tag">{me?.email}</span>
                </div>
                <div className="muted">
                  id: {me?.id}{" "}
                  <button className="linkBtn" onClick={() => copyText(me?.id || "")}>
                    copy
                  </button>
                </div>
              </div>
              <button className="btn btnGhost" onClick={loadMeAndConversations}>
                Refresh
              </button>
            </div>
          </div>

          <NewConversationCard onCreateDm={createDm} onCreateGroup={createGroup} clipboardText={appClipboard} />

          <div className="card">
            <h2>Conversations</h2>
            <div className="col">
              {conversations.length === 0 ? <div className="muted">No conversations yet. Create one above.</div> : null}
              {conversations.map((c) => (
                <button
                  key={c.id}
                  className={`convItem ${activeConversationId === c.id ? "convActive" : ""}`}
                  onClick={() => selectConversation(c.id)}
                >
                  <div className="convTitle">{c.kind === "group" ? c.name || "Group" : "DM"}</div>
                  <div className="convMeta">
                    <span className="muted">{c.member_count} members</span>
                    <span className="muted">{c.last_message_at ? formatTs(c.last_message_at) : "—"}</span>
                  </div>
                  <div className="convId muted">{c.id}</div>
                </button>
              ))}
            </div>
          </div>
        </div>

        <div className={`main ${isMobile && !activeConversationId ? "mainHidden" : ""}`}>
          <ChatView
            token={token}
            me={me}
            conversationId={activeConversationId}
            clipboardText={appClipboard}
            onBack={() => selectConversation("")}
            onTouchConversationUpdated={() => {
              api("/api/v1/conversations", { token })
                .then((r) => setConversations(r.conversations || []))
                .catch(() => {});
            }}
            setGlobalError={setGlobalError}
          />
        </div>
      </div>

      <Modal open={settingsOpen} title="Settings" onClose={() => setSettingsOpen(false)}>
        <div className="col">
          <div className="card cardInner">
            <div className="muted">API base</div>
            <div className="row rowWrap">
              <input
                className="input"
                value={apiBase}
                onChange={(e) => {
                  setApiBaseInput(e.target.value);
                  setApiBase(e.target.value);
                }}
                placeholder="(empty = same origin)"
              />
              <button
                className="btn btnGhost"
                onClick={() => {
                  setApiBaseInput("");
                  setApiBase("");
                }}
              >
                Clear
              </button>
            </div>
            <div className="muted">Current: {getApiBase() || "(same origin)"}</div>
          </div>

          <div className="card cardInner">
            <div className="muted">Mentions</div>
            <Toggle
              label="Receive @everyone notifications"
              checked={!!me?.settings?.receive_everyone_mentions}
              onChange={(v) => updateSettings({ receive_everyone_mentions: v })}
            />
          </div>

          <div className="card cardInner">
            <div className="muted">Account</div>
            <div className="row rowWrap" style={{ justifyContent: "space-between" }}>
              <div className="col" style={{ gap: 2 }}>
                <div>
                  <b>{me?.username}</b> <span className="muted">({me?.email})</span>
                </div>
                <div className="muted">id: {me?.id}</div>
              </div>
              <button className="btn btnGhost" onClick={() => copyText(me?.id || "")}>
                Copy id
              </button>
            </div>
          </div>
        </div>
      </Modal>

      <Toast text={toast} />
    </div>
  );
}

function NewConversationCard({ onCreateDm, onCreateGroup, clipboardText }) {
  const [dmUserId, setDmUserId] = useState("");

  const [groupName, setGroupName] = useState("");
  const [groupMemberIds, setGroupMemberIds] = useState("");

  return (
    <div className="card">
      <h2>New</h2>
      <div className="col">
        <div className="card cardInner">
          <div className="muted">Direct message (DM)</div>
          <div className="row rowWrap">
            <input className="input" value={dmUserId} onChange={(e) => setDmUserId(e.target.value)} placeholder="other_user_id…" />
            <button
              className="btn btnGhost"
              disabled={!normalizeClipboardText(clipboardText)}
              onClick={() => setDmUserId(normalizeClipboardText(clipboardText))}
            >
              Paste
            </button>
            <button
              className="btn btnPrimary"
              disabled={!dmUserId.trim()}
              onClick={() => {
                const v = dmUserId.trim();
                setDmUserId("");
                onCreateDm(v);
              }}
            >
              Create
            </button>
          </div>
          <div className="muted">Tip: copy the other user’s id from their sidebar.</div>
        </div>

        <div className="card cardInner">
          <div className="muted">Group</div>
          <div className="grid2">
            <input className="input" value={groupName} onChange={(e) => setGroupName(e.target.value)} placeholder="Group name…" />
            <div className="row rowWrap">
              <input
                className="input"
                value={groupMemberIds}
                onChange={(e) => setGroupMemberIds(e.target.value)}
                placeholder="Member ids (comma-separated)…"
              />
              <button
                className="btn btnGhost"
                disabled={!normalizeClipboardText(clipboardText)}
                onClick={() => {
                  const v = normalizeClipboardText(clipboardText);
                  setGroupMemberIds((prev) => {
                    const base = String(prev || "").trim();
                    if (!base) return v;
                    return base.endsWith(",") ? `${base} ${v}` : `${base}, ${v}`;
                  });
                }}
              >
                Paste
              </button>
            </div>
          </div>
          <div className="row rowWrap">
            <button
              className="btn btnPrimary"
              disabled={!groupName.trim()}
              onClick={() => {
                const ids = groupMemberIds
                  .split(",")
                  .map((s) => s.trim())
                  .filter(Boolean);
                const name = groupName.trim();
                setGroupName("");
                setGroupMemberIds("");
                onCreateGroup(name, ids);
              }}
            >
              Create group
            </button>
            <span className="muted">You are auto-added</span>
          </div>
        </div>
      </div>
    </div>
  );
}
