/* datamodel — main app shell wiring all panels together.

   Storage model: each diagram is a Firestore document at /diagrams/{uuid}
   with a /versions subcollection. Anyone with the URL can read; signing in
   grants edit; every save mutation records the signed-in user as the
   last editor. See cloud-library.js and cloud-versions.js. */
const { useEffect, useMemo, useRef, useState, useCallback } = React;

const MAX_HISTORY = 40;
const THEME_KEY = "lab900-tools:theme";
const AUTOSAVE_DEBOUNCE_MS = 1500;
const AUTOSNAPSHOT_INTERVAL_MS = 5 * 60 * 1000;
const CHAT_STORAGE_PREFIX = "lab900-tools:datamodel:chat:";
const CHAT_MAX_MESSAGES = 60;

function pickInitialEntity(model) {
  for (const e of model.entities.values()) {
    if (isKeyEntity(e)) return e.name;
  }
  return [...model.entities.keys()][0] || null;
}

/* BFS over `children` to produce history entries for every transitive subclass
   of rootName that isn't already in `existing`. Each entry's `source` is its
   immediate superclass so the graph columns cascade left-to-right. */
function descendantsOf(model, rootName, existing) {
  if (!model) return [];
  const out = [];
  const seen = new Set(existing);
  seen.add(rootName);
  const queue = [rootName];
  while (queue.length) {
    const parent = queue.shift();
    for (const child of model.children.get(parent) || []) {
      if (seen.has(child) || !model.entities.has(child)) continue;
      seen.add(child);
      out.push({ entity: child, via: null, source: parent });
      queue.push(child);
    }
  }
  return out;
}

/* Build a dot-path string for the current focused attribute by walking the
   navigation history. Used by the "copy field path" button on graph nodes. */
function buildAttrPath(history, focusedName, model, entityName, attrName, attrIsList) {
  const entry = history.find(h => h.entity === entityName);
  let extraVia = null;
  let cursor = entry;
  if (!cursor) {
    extraVia = viaForTransition(model, focusedName, entityName);
    cursor = history[0];
  }
  const chain = [];
  while (cursor) {
    chain.unshift(cursor);
    const sourceName = cursor.source;
    cursor = sourceName ? history.find(h => h.entity === sourceName) : null;
  }
  const segments = [];
  if (chain.length > 0) {
    segments.push(chain[0].entity);
    for (let i = 1; i < chain.length; i++) {
      const c = chain[i];
      segments.push(c.via && c.via.field ? c.via.field + (c.via.isList ? "[]" : "") : c.entity);
    }
  }
  if (extraVia && extraVia.field) {
    segments.push(extraVia.field + (extraVia.isList ? "[]" : ""));
  } else if (extraVia === null && chain.length === 0) {
    segments.push(entityName);
  }
  segments.push(attrName + (attrIsList ? "[]" : ""));
  return segments.join(".");
}

function copyToClipboard(text) {
  try {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(text);
      return;
    }
  } catch (e) { /* fall through */ }
  const ta = document.createElement("textarea");
  ta.value = text;
  document.body.appendChild(ta);
  ta.select();
  try { document.execCommand("copy"); } catch (e) { /* ignore */ }
  document.body.removeChild(ta);
}

function diagramIdFromUrl() {
  return new URLSearchParams(location.search).get("id");
}

function setUrlDiagramId(id) {
  const u = new URL(location.href);
  if (id) u.searchParams.set("id", id);
  else u.searchParams.delete("id");
  u.hash = "";
  history.pushState(null, "", u.toString());
}

/* True once firebase-init.js has populated window.fb. Hooks below depend on it. */
function useFbReady() {
  const [ready, setReady] = useState(() => !!window.fb);
  useEffect(() => {
    if (ready) return;
    function onReady() { setReady(true); }
    window.addEventListener("fb-ready", onReady);
    if (window.fb) setReady(true);
    return () => window.removeEventListener("fb-ready", onReady);
  }, [ready]);
  return ready;
}

/* ---------- cloud library state ---------- */
function useCloudLibrary(user, fbReady) {
  const [diagrams, setDiagrams] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!fbReady || !user) {
      setDiagrams([]);
      return;
    }
    setLoading(true);
    const unsub = window.CloudLibrary.subscribeMine(
      user.uid,
      (rows) => { setDiagrams(rows); setLoading(false); },
      (err) => { console.error("subscribeMine error", err); setLoading(false); },
    );
    return () => unsub && unsub();
  }, [user?.uid, fbReady]);

  return { diagrams, loading };
}

/* ---------- current diagram + versions subscription ---------- */
function useCurrentDiagram(diagramId, fbReady) {
  const [diagram, setDiagram] = useState(null);
  const [notFound, setNotFound] = useState(false);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setDiagram(null);
    setNotFound(false);
    if (!fbReady || !diagramId) return;
    setLoading(true);
    const unsub = window.CloudLibrary.subscribeDiagram(
      diagramId,
      (d) => {
        setLoading(false);
        if (d) { setDiagram(d); setNotFound(false); }
        else { setDiagram(null); setNotFound(true); }
      },
      (err) => { console.error("subscribeDiagram error", err); setLoading(false); },
    );
    return () => unsub && unsub();
  }, [diagramId, fbReady]);

  return { diagram, notFound, loading };
}

function useVersions(diagramId, fbReady) {
  const [versions, setVersions] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setVersions([]);
    if (!fbReady || !diagramId) return;
    setLoading(true);
    const unsub = window.CloudVersions.subscribeVersions(
      diagramId,
      50,
      (rows) => { setVersions(rows); setLoading(false); },
      (err) => { console.error("subscribeVersions error", err); setLoading(false); },
    );
    return () => unsub && unsub();
  }, [diagramId, fbReady]);

  return { versions, loading };
}

/* ---------- app shell ---------- */
function App() {
  const fbReady = useFbReady();
  const auth = useAuth();
  const user = auth.user;
  const lib = useCloudLibrary(user, fbReady);

  const [diagramId, setDiagramId] = useState(() => diagramIdFromUrl());
  const { diagram: cloudDiagram, notFound: diagramNotFound, loading: diagramLoading } = useCurrentDiagram(diagramId, fbReady);

  // Working content — diverges from cloudDiagram.currentContent while typing.
  const [pumlSrc, setPumlSrc] = useState("");
  // Mirror of pumlSrc held in a ref so async callbacks (debounced save,
  // 5-min snapshot interval) always read the latest value rather than a
  // stale closure value.
  const pumlSrcRef = useRef("");
  useEffect(() => { pumlSrcRef.current = pumlSrc; }, [pumlSrc]);
  // The content we last persisted (so we can tell typed-but-not-saved apart).
  const lastSavedContentRef = useRef("");
  const [model, setModel] = useState(null);
  const [saveState, setSaveState] = useState(null); // 'saved' | 'saving' | 'error' | 'readonly' | null

  // Version preview state — when set, the model renders this content instead of pumlSrc.
  const [previewVersion, setPreviewVersion] = useState(null);

  // Versions panel
  const [versionsOpen, setVersionsOpen] = useState(false);
  const [checkpointOpen, setCheckpointOpen] = useState(false);
  const { versions, loading: versionsLoading } = useVersions(diagramId, fbReady);
  const lastSnapshotContentRef = useRef("");

  // Chat panel — chat history is persisted per-diagram in localStorage so it
  // doesn't leak to anyone with the share link (cf. plan: out-of-scope to put
  // chat in Firestore in v1).
  const [chatOpen, setChatOpen] = useState(false);
  const [chatMessages, setChatMessages] = useState([]);
  const [chatBusy, setChatBusy] = useState(false);
  const chatAbortRef = useRef(null);
  // Latest pumlSrc accessor for streaming callbacks (mirrors pumlSrcRef pattern).
  // Tracks whether we've already written a "Before AI edit" checkpoint for the
  // current AI turn so subsequent tool calls in the same turn don't checkpoint again.
  const aiTurnCheckpointRef = useRef(false);

  const [search, setSearch] = useState("");
  const [history, setHistory] = useState([]);
  const [forwardStack, setForwardStack] = useState([]);
  const [importOpen, setImportOpen] = useState(false);
  const focusedName = history[0]?.entity || null;

  const [theme, setTheme] = useState(
    () => localStorage.getItem(THEME_KEY)
      || document.documentElement.getAttribute("data-theme")
      || "light"
  );
  const [showAttrs, setShowAttrs] = useState(true);
  const [libOpen, setLibOpen] = useState(false);
  const [editMode, setEditMode] = useState(false);
  const listRef = useRef(null);
  const [toast, setToast] = useState(null);

  // Legacy migration prompt — only shown when signed in and old data exists.
  const [legacyCount, setLegacyCount] = useState(() =>
    window.LegacyLibrary && window.LegacyLibrary.shouldOfferMigration()
      ? window.LegacyLibrary.legacyCount() : 0
  );

  const canEdit = !!user && !!cloudDiagram && !previewVersion;
  const isCloudDiagram = !!cloudDiagram;

  // Initial library open: only when truly empty (no shared link, no current diagram).
  useEffect(() => {
    if (!auth.loading && !diagramId) setLibOpen(true);
  }, [auth.loading, diagramId]);

  // Record opened diagrams in localStorage so shared links don't get lost.
  // Dep on cloudDiagram?.name (not the whole doc) so we don't bump the timestamp
  // on every keystroke-driven snapshot.
  useEffect(() => {
    if (!cloudDiagram || !diagramId || !window.Recents) return;
    window.Recents.recordRecent({
      id: diagramId,
      name: cloudDiagram.name,
      lastEditorName: cloudDiagram.lastEditorName,
    });
  }, [diagramId, cloudDiagram?.name]);

  // If a diagram referenced from URL turns out to be deleted, drop it from recents.
  useEffect(() => {
    if (diagramId && diagramNotFound && window.Recents) {
      window.Recents.removeRecent(diagramId);
    }
  }, [diagramId, diagramNotFound]);

  // Recents are localStorage-backed; subscribe so the library modal stays fresh.
  const [recents, setRecents] = useState(() => window.Recents ? window.Recents.getRecents() : []);
  useEffect(() => {
    if (!window.Recents) return;
    const unsub = window.Recents.subscribe(setRecents);
    return () => unsub && unsub();
  }, []);

  // Reflect URL changes (back/forward buttons) into diagramId.
  useEffect(() => {
    function onPop() { setDiagramId(diagramIdFromUrl()); }
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);

  // Load chat messages from localStorage whenever the current diagram changes.
  useEffect(() => {
    if (!diagramId) { setChatMessages([]); return; }
    try {
      const raw = localStorage.getItem(CHAT_STORAGE_PREFIX + diagramId);
      setChatMessages(raw ? JSON.parse(raw) : []);
    } catch { setChatMessages([]); }
    // Cancel any in-flight chat from the previous diagram.
    if (chatAbortRef.current) { chatAbortRef.current.abort(); chatAbortRef.current = null; }
    setChatBusy(false);
    aiTurnCheckpointRef.current = false;
  }, [diagramId]);

  // Persist chat messages (trimmed) per-diagram. localStorage only.
  useEffect(() => {
    if (!diagramId) return;
    try {
      const trimmed = chatMessages.slice(-CHAT_MAX_MESSAGES);
      localStorage.setItem(CHAT_STORAGE_PREFIX + diagramId, JSON.stringify(trimmed));
    } catch { /* quota — fail silently */ }
  }, [chatMessages, diagramId]);

  // When cloudDiagram updates, adopt its content unless the user has unsaved local edits.
  useEffect(() => {
    if (!cloudDiagram) {
      if (!diagramId) {
        setPumlSrc("");
        lastSavedContentRef.current = "";
        lastSnapshotContentRef.current = "";
      }
      return;
    }
    const remote = cloudDiagram.currentContent || "";
    const local = pumlSrc;
    if (local === lastSavedContentRef.current) {
      // We're in sync — accept remote.
      if (remote !== local) {
        setPumlSrc(remote);
      }
      lastSavedContentRef.current = remote;
      if (!lastSnapshotContentRef.current) lastSnapshotContentRef.current = remote;
      setSaveState(user ? "saved" : "readonly");
    } else if (remote === lastSavedContentRef.current) {
      // Remote unchanged, local diverged — keep local, stay in dirty/saving state.
    } else {
      // Remote moved AND local diverged: last-write-wins on next save. Mark error so
      // the user knows their version may overwrite a concurrent edit.
      setSaveState("error");
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cloudDiagram?.currentContent]);

  // re-parse when displayed source changes. Preserve current focus across edits — only
  // fall back to pickInitialEntity when there's nothing to keep (initial load
  // or the focused entity was deleted).
  const displayedSrc = previewVersion ? previewVersion.content : pumlSrc;
  useEffect(() => {
    if (!displayedSrc) {
      setModel(null);
      setHistory([]);
      return;
    }
    try {
      const m = buildModel(parsePuml(displayedSrc));
      setModel(m);
      setHistory(prev => {
        const filtered = prev.filter(h => m.entities.has(h.entity));
        if (filtered.length === 0) {
          const first = pickInitialEntity(m);
          return first ? [{ entity: first, via: null }, ...descendantsOf(m, first, new Set())] : [];
        }
        // Pull in any new descendants (e.g. a subclass added via edit mode).
        const present = new Set(filtered.map(h => h.entity));
        const additions = [];
        for (const h of filtered) {
          const more = descendantsOf(m, h.entity, present);
          for (const a of more) present.add(a.entity);
          additions.push(...more);
        }
        return [...filtered, ...additions];
      });
      setForwardStack(prev => prev.filter(h => m.entities.has(h.entity)));
    } catch (err) {
      console.error("parse error", err);
    }
  }, [displayedSrc]);

  useEffect(() => {
    document.documentElement.setAttribute("data-theme", theme);
    try { localStorage.setItem(THEME_KEY, theme); } catch {}
  }, [theme]);

  // ---- debounced cloud autosave ----
  // pumlSrc is read via pumlSrcRef so the debounced timeout / interval
  // callbacks see the current value, not a stale closure capture.
  const saveTimerRef = useRef(null);
  const flushSave = useCallback(async () => {
    if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; }
    if (!diagramId || !user) return;
    const content = pumlSrcRef.current;
    if (content === lastSavedContentRef.current) { setSaveState("saved"); return; }
    if (content.length >= 500000) {
      setSaveState("error");
      setToast({ prefix: "error", msg: "Diagram exceeds 500 KB cloud limit." });
      return;
    }
    setSaveState("saving");
    try {
      await window.CloudLibrary.updateContent(diagramId, content, user);
      lastSavedContentRef.current = content;
      setSaveState("saved");
    } catch (e) {
      console.error("save failed", e);
      setSaveState("error");
      setToast({ prefix: "error", msg: e.message || "save failed" });
    }
  }, [diagramId, user]);

  const scheduleSave = useCallback(() => {
    if (!diagramId || !user) return;
    if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
    setSaveState("saving");
    saveTimerRef.current = setTimeout(() => { flushSave(); }, AUTOSAVE_DEBOUNCE_MS);
  }, [diagramId, user, flushSave]);

  // Flush pending writes on unmount / page hide.
  useEffect(() => {
    function onHide() {
      if (saveTimerRef.current && pumlSrcRef.current !== lastSavedContentRef.current) {
        // Best-effort sync write — not awaited.
        flushSave();
      }
    }
    window.addEventListener("beforeunload", onHide);
    document.addEventListener("visibilitychange", onHide);
    return () => {
      window.removeEventListener("beforeunload", onHide);
      document.removeEventListener("visibilitychange", onHide);
    };
  }, [flushSave]);

  // ---- auto-snapshot every N minutes if changed ----
  // pumlSrc is intentionally NOT in deps — the interval reads via the ref so
  // it isn't reset on every keystroke (which would mean it never fires).
  useEffect(() => {
    if (!diagramId || !user) return;
    const t = setInterval(async () => {
      const content = pumlSrcRef.current;
      if (!content) return;
      if (content === lastSnapshotContentRef.current) return;
      try {
        await window.CloudVersions.createVersion(diagramId, {
          content, message: "", isAuto: true, user,
        });
        lastSnapshotContentRef.current = content;
        // Don't toast for auto-snapshots — they're background noise.
      } catch (e) {
        console.warn("auto-snapshot failed", e);
      }
    }, AUTOSNAPSHOT_INTERVAL_MS);
    return () => clearInterval(t);
  }, [diagramId, user]);

  // ---- save state when there's no cloud doc / no user ----
  useEffect(() => {
    if (!cloudDiagram) {
      setSaveState(null);
      return;
    }
    if (!user) setSaveState("readonly");
  }, [cloudDiagram, user]);

  // navigation
  const handleSelect = useCallback((name, sourceEntity) => {
    if (!model || !model.entities.has(name)) return;
    setHistory(prev => {
      if (prev.some(c => c.entity === name)) return prev;
      const source = sourceEntity || prev[prev.length - 1]?.entity;
      const via = viaForTransition(model, source, name);
      const existing = new Set(prev.map(h => h.entity));
      const root = { entity: name, via, source };
      const next = [...prev, root, ...descendantsOf(model, name, existing)];
      if (next.length > MAX_HISTORY) next.splice(0, next.length - MAX_HISTORY);
      return next;
    });
    setForwardStack([]);
  }, [model]);

  const handleFocus = useCallback((name) => {
    if (!model || !model.entities.has(name)) return;
    const root = { entity: name, via: null };
    setHistory([root, ...descendantsOf(model, name, new Set())]);
    setForwardStack([]);
  }, [model]);

  const handleClearPath = useCallback(() => {
    const curr = history[0];
    if (!curr) return;
    setHistory([curr]);
    setForwardStack([]);
  }, [history]);

  const handleCloseEntity = useCallback((name) => {
    setHistory(prev => {
      const toRemove = new Set([name]);
      let grew = true;
      while (grew) {
        grew = false;
        for (const h of prev) {
          if (!toRemove.has(h.entity) && h.source && toRemove.has(h.source)) {
            toRemove.add(h.entity);
            grew = true;
          }
        }
      }
      return prev.filter(h => !toRemove.has(h.entity));
    });
    setForwardStack([]);
  }, []);

  const handleBack = useCallback(() => {
    setHistory(prev => {
      if (prev.length <= 1) return prev;
      const last = prev[prev.length - 1];
      setForwardStack(s => [...s, last]);
      return prev.slice(0, -1);
    });
  }, []);

  const handleForward = useCallback(() => {
    setForwardStack(prev => {
      if (!prev.length) return prev;
      const next = prev[prev.length - 1];
      setHistory(h => [...h, next]);
      return prev.slice(0, -1);
    });
  }, []);

  const handleJump = useCallback((idx) => {
    const e = history[idx]?.entity;
    if (e) handleFocus(e);
  }, [history, handleFocus]);

  const handleAttrClick = useCallback((entityName, attrName, attrIsList) => {
    if (!model) return;
    const text = buildAttrPath(history, focusedName, model, entityName, attrName, attrIsList);
    copyToClipboard(text);
    setToast({ msg: text, time: Date.now() });
  }, [history, model, focusedName]);

  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(null), 2200);
    return () => clearTimeout(t);
  }, [toast]);

  // ---- library actions ----
  const handleLoadDiagram = useCallback((id) => {
    if (!id) return;
    setHistory([]);
    setForwardStack([]);
    setPreviewVersion(null);
    setPumlSrc("");
    lastSavedContentRef.current = "";
    lastSnapshotContentRef.current = "";
    setUrlDiagramId(id);
    setDiagramId(id);
  }, []);

  const handleCreateDiagram = useCallback(async ({ name, content }) => {
    if (!user) return;
    try {
      const id = await window.CloudLibrary.createDiagram({ name, content: content || "", user });
      if (content) {
        // Capture the initial state as a non-auto version.
        try {
          await window.CloudVersions.createVersion(id, {
            content, message: "Initial version", isAuto: false, user,
          });
        } catch (e) { console.warn("initial version write failed", e); }
      }
      handleLoadDiagram(id);
      setLibOpen(false);
      setToast({ prefix: "created", msg: name });
    } catch (e) {
      console.error("create failed", e);
      setToast({ prefix: "error", msg: e.message || "create failed" });
    }
  }, [user, handleLoadDiagram]);

  // "Remove" no longer touches the cloud — it just drops the local recents
  // entry so the diagram stops showing up in this browser. The cloud copy
  // stays put so other users / future me-on-another-machine can still load it.
  const handleRemoveRecent = useCallback((id) => {
    window.Recents && window.Recents.removeRecent(id);
    if (id === diagramId) {
      setUrlDiagramId(null);
      setDiagramId(null);
      setPumlSrc("");
      lastSavedContentRef.current = "";
    }
  }, [diagramId]);

  const handleRenameDiagram = useCallback(async (id, name) => {
    if (!user) return;
    try {
      await window.CloudLibrary.renameDiagram(id, name, user);
    } catch (e) {
      setToast({ prefix: "error", msg: e.message || "rename failed" });
    }
  }, [user]);

  const handleDeleteDiagram = useCallback(async (id) => {
    if (!user || !id) return;
    try {
      await window.CloudLibrary.deleteDiagram(id);
      window.Recents && window.Recents.removeRecent(id);
      if (id === diagramId) {
        setUrlDiagramId(null);
        setDiagramId(null);
        setPumlSrc("");
        lastSavedContentRef.current = "";
        lastSnapshotContentRef.current = "";
      }
      setToast({ prefix: "deleted", msg: id.slice(0, 8) });
    } catch (e) {
      setToast({ prefix: "error", msg: e.message || "delete failed" });
    }
  }, [user, diagramId]);

  // ---- content edits ----
  const handleSourceChange = useCallback((next) => {
    if (previewVersion) {
      // Editing while previewing implicitly restores: copy preview into pumlSrc,
      // exit preview, and trigger save.
      setPreviewVersion(null);
    }
    setPumlSrc(next);
    if (user && diagramId) scheduleSave();
  }, [user, diagramId, scheduleSave, previewVersion]);

  /* Apply a structured edit intent from the graph popovers. */
  const handleEdit = useCallback((edit) => {
    const PE = window.PumlEditor;
    if (!PE) return;
    let next = previewVersion ? previewVersion.content : pumlSrc;
    try {
      if (edit.op === "addEntity") {
        next = PE.addEntity(next, { name: edit.name, kind: edit.kind, stereotype: edit.stereotype });
        if (edit.extendsFrom) {
          next = PE.addRelation(next, { from: edit.name, to: edit.extendsFrom, kind: "inheritance" });
        }
        setToast({ prefix: "added", msg: `${edit.kind} ${edit.name}` });
      } else if (edit.op === "addAttribute") {
        next = PE.addAttribute(next, edit.entity, { name: edit.name, type: edit.type, visibility: edit.visibility });
        setToast({ prefix: "added", msg: `${edit.entity}.${edit.name}` });
      } else if (edit.op === "addEnumValue") {
        next = PE.addEnumValue(next, edit.entity, edit.value);
        setToast({ prefix: "added", msg: `${edit.entity}.${edit.value}` });
      } else if (edit.op === "updateAttribute") {
        next = PE.updateAttribute(next, edit.entity, edit.oldName, { name: edit.name, type: edit.type, visibility: edit.visibility });
        setToast({ prefix: "updated", msg: `${edit.entity}.${edit.name}` });
      } else if (edit.op === "removeAttribute") {
        next = PE.removeAttribute(next, edit.entity, edit.name);
        setToast({ prefix: "removed", msg: `${edit.entity}.${edit.name}` });
      } else if (edit.op === "updateEnumValue") {
        next = PE.updateEnumValue(next, edit.entity, edit.oldValue, edit.value);
        setToast({ prefix: "updated", msg: `${edit.entity}.${edit.value}` });
      } else if (edit.op === "removeEnumValue") {
        next = PE.removeEnumValue(next, edit.entity, edit.value);
        setToast({ prefix: "removed", msg: `${edit.entity}.${edit.value}` });
      } else if (edit.op === "updateRelation") {
        next = PE.updateRelationAtLine(next, edit.lineNumber, edit.patch);
        setToast({ prefix: "updated", msg: `${edit.patch.from} → ${edit.patch.to}` });
      } else if (edit.op === "deleteRelation") {
        next = PE.deleteRelationAtLine(next, edit.lineNumber);
        setToast({ prefix: "removed", msg: `${edit.from} → ${edit.to}` });
      } else {
        return;
      }
      handleSourceChange(next);
    } catch (e) {
      setToast({ prefix: "error", msg: e.message || String(e) });
    }
  }, [pumlSrc, handleSourceChange, previewVersion]);

  // ---- AI chat ----

  /* Build a Gemini-shaped history array from our local chatMessages. We strip
     the tool-call metadata and just send the user/model text turns — the model
     re-derives intent from the prompt + current PUML in the system prompt. */
  const buildAiHistory = useCallback(() => {
    const out = [];
    for (const m of chatMessages) {
      if (m.role === "user" && m.text) {
        out.push({ role: "user", parts: [{ text: m.text }] });
      } else if (m.role === "assistant" && (m.text || (m.toolCalls && m.toolCalls.length))) {
        const parts = [];
        if (m.text) parts.push({ text: m.text });
        // Surface applied tool calls as plain text so the model has continuity
        // (we don't replay them as functionCall parts because we'd need matching
        // functionResponse parts and the apply happens client-side after stream).
        if (m.toolCalls && m.toolCalls.length) {
          parts.push({ text: m.toolCalls.map(tc => `[applied ${tc.name}: ${tc.summary}]`).join("\n") });
        }
        if (parts.length) out.push({ role: "model", parts });
      }
    }
    return out;
  }, [chatMessages]);

  const handleChatSend = useCallback(async (prompt) => {
    if (!user || !diagramId || chatBusy) return;
    const userMsg = { role: "user", text: prompt };
    const startingAssistant = { role: "assistant", text: "", toolCalls: [] };
    setChatMessages(prev => [...prev, userMsg, startingAssistant]);
    setChatBusy(true);
    aiTurnCheckpointRef.current = false;

    const controller = new AbortController();
    chatAbortRef.current = controller;

    function appendAssistantText(delta) {
      setChatMessages(prev => {
        const next = prev.slice();
        const last = next[next.length - 1];
        if (last && last.role === "assistant") {
          next[next.length - 1] = { ...last, text: (last.text || "") + delta };
        }
        return next;
      });
    }

    function appendToolCall(entry) {
      setChatMessages(prev => {
        const next = prev.slice();
        const last = next[next.length - 1];
        if (last && last.role === "assistant") {
          next[next.length - 1] = { ...last, toolCalls: [...(last.toolCalls || []), entry] };
        }
        return next;
      });
    }

    function setAssistantError(msg) {
      setChatMessages(prev => {
        const next = prev.slice();
        const last = next[next.length - 1];
        if (last && last.role === "assistant") {
          next[next.length - 1] = { ...last, error: msg };
        }
        return next;
      });
    }

    /* Write a single "Before AI edit" checkpoint right before the first
       mutating tool call lands. Lazy so non-mutating prompts (just asking
       questions about the model) don't pollute the version history. */
    async function ensureCheckpoint() {
      if (aiTurnCheckpointRef.current) return;
      aiTurnCheckpointRef.current = true;
      try {
        await flushSave(); // capture any in-flight typing edits first
        const trimmed = prompt.length > 80 ? prompt.slice(0, 77) + "…" : prompt;
        await window.CloudVersions.createVersion(diagramId, {
          content: pumlSrcRef.current,
          message: `Before AI edit: ${trimmed}`,
          isAuto: false,
          user,
        });
      } catch (e) {
        console.warn("AI pre-edit checkpoint failed", e);
      }
    }

    try {
      // auth.jsx stores a plain { uid, displayName, ... } in state; the live
      // Firebase user (with getIdToken) is on fb.auth.currentUser.
      const fbUser = window.fb && window.fb.auth && window.fb.auth.currentUser;
      if (!fbUser) throw new Error("not signed in");
      const idToken = await fbUser.getIdToken();
      await window.AIClient.chat({
        prompt,
        history: buildAiHistory(),
        currentPuml: pumlSrcRef.current,
        idToken,
        signal: controller.signal,
        onText: (delta) => appendAssistantText(delta),
        onToolCall: async ({ name, args }) => {
          try {
            await ensureCheckpoint();
            const summary = window.AITools.applyToolCall(name, args, {
              handleEdit,
              getCurrentSrc: () => pumlSrcRef.current,
              applyRawSource: (next) => handleSourceChange(next),
            });
            appendToolCall({ name, args, summary });
          } catch (e) {
            appendToolCall({ name, args, summary: window.AITools.describeToolCall(name, args), error: e.message || String(e) });
          }
        },
        onError: (msg) => setAssistantError(msg),
        onDone: () => {},
      });
    } catch (e) {
      if (e && e.name !== "AbortError") setAssistantError(e.message || "request failed");
    } finally {
      if (chatAbortRef.current === controller) chatAbortRef.current = null;
      setChatBusy(false);
    }
  }, [user, diagramId, chatBusy, buildAiHistory, handleEdit, handleSourceChange, flushSave]);

  const handleChatStop = useCallback(() => {
    if (chatAbortRef.current) chatAbortRef.current.abort();
    chatAbortRef.current = null;
    setChatBusy(false);
  }, []);

  const handleChatClear = useCallback(() => {
    if (!diagramId) return;
    if (chatAbortRef.current) { chatAbortRef.current.abort(); chatAbortRef.current = null; }
    setChatMessages([]);
    setChatBusy(false);
    try { localStorage.removeItem(CHAT_STORAGE_PREFIX + diagramId); } catch {}
  }, [diagramId]);

  // Opening chat closes versions (and vice versa) to keep the layout simple.
  const handleToggleChat = useCallback(() => {
    setChatOpen(v => {
      const next = !v;
      if (next) setVersionsOpen(false);
      return next;
    });
  }, []);
  const handleToggleVersions = useCallback(() => {
    setVersionsOpen(v => {
      const next = !v;
      if (next) setChatOpen(false);
      return next;
    });
  }, []);

  const handleImportPuml = useCallback((src) => {
    if (!src) return;
    setImportOpen(false);
    setHistory([]);
    setForwardStack([]);
    handleSourceChange(src);
    setToast({ prefix: "imported", msg: "diagram replaced" });
  }, [handleSourceChange]);

  const handleExportPuml = useCallback(() => {
    const src = previewVersion ? previewVersion.content : pumlSrc;
    if (!src) return;
    const name = (cloudDiagram?.name || "diagram").trim().replace(/\s+/g, "_") || "diagram";
    const blob = new Blob([src], { type: "text/plain;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `${name}.puml`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }, [pumlSrc, cloudDiagram, previewVersion]);

  // ---- versions ----
  const handlePreviewVersion = useCallback((v) => {
    if (!v) return;
    setPreviewVersion(v);
    setEditMode(false);
    setHistory([]);
    setForwardStack([]);
  }, []);

  const handleExitPreview = useCallback(() => {
    setPreviewVersion(null);
    setHistory([]);
    setForwardStack([]);
  }, []);

  const handleRestoreVersion = useCallback(async (v) => {
    if (!user || !diagramId) return;
    if (!confirm(`Restore version "${v.message || "auto-snapshot"}"? Your current working copy will be replaced.`)) return;
    setPreviewVersion(null);
    setPumlSrc(v.content);
    setHistory([]);
    setForwardStack([]);
    // Trigger save immediately rather than waiting for debounce.
    setSaveState("saving");
    try {
      await window.CloudLibrary.updateContent(diagramId, v.content, user);
      lastSavedContentRef.current = v.content;
      setSaveState("saved");
      setToast({ prefix: "restored", msg: v.message || "auto-snapshot" });
    } catch (e) {
      setSaveState("error");
      setToast({ prefix: "error", msg: e.message || "restore failed" });
    }
  }, [user, diagramId]);

  const handleOpenCheckpoint = useCallback(() => {
    if (!user || !diagramId) return;
    // Flush any pending save first so the checkpoint captures the latest state.
    flushSave();
    setCheckpointOpen(true);
  }, [user, diagramId, flushSave]);

  const handleSaveCheckpoint = useCallback(async (message) => {
    if (!user || !diagramId) return;
    try {
      await flushSave();
      const content = pumlSrc;
      const vid = await window.CloudVersions.createVersion(diagramId, {
        content, message, isAuto: false, user,
      });
      lastSnapshotContentRef.current = content;
      try { await window.CloudLibrary.setCurrentVersionId(diagramId, vid, user); } catch {}
      setCheckpointOpen(false);
      setToast({ prefix: "checkpoint", msg: message || "saved" });
    } catch (e) {
      setToast({ prefix: "error", msg: e.message || "checkpoint failed" });
      throw e;
    }
  }, [user, diagramId, pumlSrc, flushSave]);

  // ---- legacy migration ----
  const handleMigrate = useCallback(async () => {
    if (!user || !window.LegacyLibrary) return;
    try {
      const ids = await window.LegacyLibrary.migrateLocalToCloud(user);
      setLegacyCount(0);
      setToast({ prefix: "imported", msg: `${ids.length} diagram${ids.length === 1 ? "" : "s"}` });
    } catch (e) {
      setToast({ prefix: "error", msg: e.message || "import failed" });
    }
  }, [user]);

  const handleDeclineMigration = useCallback(() => {
    window.LegacyLibrary && window.LegacyLibrary.declineMigration();
    setLegacyCount(0);
  }, []);

  // ---- keyboard shortcuts ----
  useEffect(() => {
    function onKey(e) {
      if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
        if (e.key === "Escape" && e.target.id === "global-search") e.target.blur();
        return;
      }
      if (e.key === "/") {
        e.preventDefault();
        document.getElementById("global-search")?.focus();
      } else if (e.key === "Escape") {
        if (previewVersion) handleExitPreview();
        else setLibOpen(false);
      } else if (e.key.toLowerCase() === "l" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setLibOpen(true);
      } else if (e.key.toLowerCase() === "s" && (e.metaKey || e.ctrlKey) && e.shiftKey) {
        if (user && diagramId) { e.preventDefault(); handleOpenCheckpoint(); }
      } else if ((e.key === "[" && (e.metaKey || e.altKey)) || (e.key === "ArrowLeft" && e.altKey)) {
        e.preventDefault();
        handleBack();
      } else if ((e.key === "]" && (e.metaKey || e.altKey)) || (e.key === "ArrowRight" && e.altKey)) {
        e.preventDefault();
        handleForward();
      }
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [handleBack, handleForward, previewVersion, handleExitPreview, user, diagramId, handleOpenCheckpoint]);

  const hasModel = !!model;
  const readOnlyShared = !!cloudDiagram && !user;

  const previewBanner = previewVersion && (
    <div className="share-banner" style={{ margin: "0 14px 14px" }}>
      <Icon.history />
      <span>
        <span className="label">Previewing version</span> · {previewVersion.message || "auto-snapshot"} · {previewVersion.authorName}
      </span>
      {user && (
        <button className="btn primary" onClick={() => handleRestoreVersion(previewVersion)}>Restore this version</button>
      )}
      <button className="btn" onClick={handleExitPreview}>Exit preview</button>
    </div>
  );

  const sharedBanner = readOnlyShared && !previewVersion && (
    <div className="share-banner" style={{ margin: "0 14px 14px" }}>
      <Icon.share />
      <span>
        <span className="label">Shared diagram (read-only)</span> · Sign in to edit and create versions.
      </span>
      <button className="btn primary" onClick={auth.signIn}>
        <Icon.googleG /> Sign in
      </button>
    </div>
  );

  const notFoundCard = diagramId && diagramNotFound && (
    <div className="empty-state">
      <div className="empty-state-card">
        <Icon.close />
        <h2>Diagram not found</h2>
        <p>This diagram may have been deleted, or the link is incorrect.</p>
        <button className="btn primary" onClick={() => { setUrlDiagramId(null); setDiagramId(null); setLibOpen(true); }}>
          Open library
        </button>
      </div>
    </div>
  );

  return (
    <div className="app">
      <Header
        search={search}
        onSearch={setSearch}
        onPaste={() => setLibOpen(true)}
        theme={theme}
        onTheme={setTheme}
        history={history}
        canBack={history.length > 1}
        canForward={forwardStack.length > 0}
        onBack={handleBack}
        onForward={handleForward}
        onJump={handleJump}
        onClearPath={handleClearPath}
        hasModel={hasModel}
        diagramName={cloudDiagram?.name || ""}
        editMode={editMode}
        onToggleEditMode={canEdit ? () => setEditMode(v => !v) : null}
        onExport={handleExportPuml}
        onImport={canEdit ? () => setImportOpen(true) : null}
        user={user}
        onSignIn={auth.signIn}
        onSignOut={auth.signOut}
        isCloudDiagram={isCloudDiagram}
        canEdit={canEdit}
        versionsOpen={versionsOpen}
        onToggleVersions={isCloudDiagram ? handleToggleVersions : null}
        chatOpen={chatOpen}
        onToggleChat={isCloudDiagram ? handleToggleChat : null}
        onCheckpoint={canEdit ? handleOpenCheckpoint : null}
      />
      {previewBanner}
      {sharedBanner}
      {notFoundCard ? notFoundCard : hasModel ? (
        <div className={`main ${(versionsOpen || chatOpen) && isCloudDiagram ? "with-side-panel" : ""}`}>
          <div className="left-col">
            <ListPane
              model={model}
              search={search}
              onSearch={setSearch}
              focusedName={focusedName}
              onSelect={handleFocus}
              listRef={listRef}
            />
            <DetailPane model={model} focusedName={focusedName} onSelect={handleSelect} />
          </div>
          <div className="panel">
            <div className="panel-head">
              <h2>Graph</h2>
              {editMode && isCloudDiagram && saveState && (
                <SaveIndicator state={saveState} />
              )}
            </div>
            {focusedName ? (
              <Graph
                model={model}
                focusedName={focusedName}
                onSelect={handleSelect}
                onFocus={handleFocus}
                onClose={handleCloseEntity}
                showAttrs={showAttrs}
                onToggleAttrs={setShowAttrs}
                historyEntries={history}
                onAttrClick={handleAttrClick}
                editMode={editMode && canEdit}
                onToggleEditMode={canEdit ? () => setEditMode(v => !v) : null}
                onEdit={canEdit ? handleEdit : null}
              />
            ) : (
              <div className="detail-empty"># select an entity</div>
            )}
          </div>
          {versionsOpen && isCloudDiagram && (
            <VersionsPanel
              versions={versions}
              loading={versionsLoading}
              currentVersionId={cloudDiagram?.currentVersionId}
              previewVersionId={previewVersion?.id}
              onPreview={handlePreviewVersion}
              onRestore={handleRestoreVersion}
              canRestore={!!user}
              onClose={() => setVersionsOpen(false)}
            />
          )}
          {chatOpen && isCloudDiagram && (
            <ChatPanel
              open={chatOpen}
              messages={chatMessages}
              onSend={handleChatSend}
              onStop={handleChatStop}
              onClear={handleChatClear}
              onClose={() => setChatOpen(false)}
              busy={chatBusy}
              canEdit={canEdit}
              user={user}
              onSignIn={auth.signIn}
            />
          )}
        </div>
      ) : (
        <EmptyState
          onOpen={() => setLibOpen(true)}
          signedIn={!!user}
          onSignIn={auth.signIn}
        />
      )}
      <LibraryModal
        open={libOpen}
        onClose={() => setLibOpen(false)}
        user={user}
        onSignIn={auth.signIn}
        diagrams={lib.diagrams}
        loading={lib.loading}
        currentId={diagramId}
        recents={recents}
        onRemoveRecent={handleRemoveRecent}
        onLoad={handleLoadDiagram}
        onCreate={handleCreateDiagram}
        onRename={handleRenameDiagram}
        onDelete={handleDeleteDiagram}
        onMigrate={handleMigrate}
        onDeclineMigration={handleDeclineMigration}
        legacyCount={legacyCount}
      />
      <CheckpointDialog
        open={checkpointOpen}
        user={user}
        onCancel={() => setCheckpointOpen(false)}
        onSave={handleSaveCheckpoint}
      />
      <ImportDialog
        open={importOpen}
        onCancel={() => setImportOpen(false)}
        onImport={handleImportPuml}
      />
      {toast && (
        <div className="toast" role="status">
          <span className="toast-prefix">{toast.prefix || "copied"}</span>
          <code>{toast.msg}</code>
        </div>
      )}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
