/* Main App — canvas, pan/zoom, sidebar, tree management */

const { useState: uS, useEffect: uE, useRef: uR, useCallback: uC, useMemo: uM } = React;

function App() {
  const [workspace, setWorkspace] = uS(() => TVStore.loadWorkspace());
  const [selectedNodeId, setSelectedNodeId] = uS(null);
  const [selectedEdgeId, setSelectedEdgeId] = uS(null);
  const [ctxMenu, setCtxMenu] = uS(null); // { x, y, nodeId? }
  const [previewEdge, setPreviewEdge] = uS(null);
  const [sidebarOpen, setSidebarOpen] = uS(false);
  const [exportOpen, setExportOpen] = uS(false);

  // ---- Cloud sync state (optional — app fully works without it) ----
  const cloud = window.TVCloud;
  const [user, setUser] = uS(null);
  const [syncEnabled, setSyncEnabledState] = uS(() => (cloud ? cloud.isSyncEnabled() : false));
  const [syncStatus, setSyncStatus] = uS("");
  const [conflict, setConflict] = uS(null); // { local, cloud } when both have data on first sign-in
  const pushTimer = uR(null);
  const skipNextPush = uR(false);

  // ---- A workspace is "default" if it's just the untouched Welcome demo. ----
  const isDefaultWorkspace = (ws) => {
    if (!ws || !Array.isArray(ws.trees) || ws.trees.length !== 1) return false;
    const t = ws.trees[0];
    if (t.name !== "Welcome") return false;
    return Object.keys(t.nodes || {}).length === 5;
  };

  const canvasRef = uR(null);
  const dragState = uR(null);
  const panState = uR(null);
  const connectState = uR(null);
  const pointersRef = uR(new Map());   // pointerId -> {x, y} — live multi-touch tracking
  const pinchState = uR(null);          // pinch gesture (≥2 pointers)

  const activeTree = workspace.trees.find(t => t.id === workspace.activeId) || workspace.trees[0];
  const view = activeTree.view || { x: 0, y: 0, zoom: 1 };

  // ---- Persistence: always to localStorage; optionally mirrored to cloud ----
  uE(() => {
    TVStore.saveWorkspace(workspace);
    if (!cloud || !user || !syncEnabled) return;
    if (skipNextPush.current) { skipNextPush.current = false; return; }
    if (pushTimer.current) clearTimeout(pushTimer.current);
    pushTimer.current = setTimeout(async () => {
      try {
        setSyncStatus("Syncing…");
        await cloud.pushWorkspace(workspace);
        setSyncStatus("Synced just now");
      } catch (err) {
        setSyncStatus("Sync failed: " + (err.message || "unknown"));
      }
    }, 1200);
    return () => { if (pushTimer.current) clearTimeout(pushTimer.current); };
  }, [workspace, user, syncEnabled]);

  // ---- Auth subscription ----
  uE(() => {
    if (!cloud) return;
    return cloud.onAuthChange((u) => setUser(u));
  }, []);

  // ---- On sign-in: reconcile local vs cloud. Prompts only when both have real data. ----
  uE(() => {
    if (!cloud || !user || !syncEnabled) return;
    let cancelled = false;
    (async () => {
      try {
        setSyncStatus("Pulling…");
        const remote = await cloud.pullWorkspace();
        if (cancelled) return;
        const cloudHasData = !!(remote && remote.workspace && Array.isArray(remote.workspace.trees) && remote.workspace.trees.length);
        const localIsDefault = isDefaultWorkspace(workspace);
        if (cloudHasData && !localIsDefault) {
          // Both sides have real data — let the user choose.
          setConflict({ local: workspace, cloud: remote.workspace });
          setSyncStatus("Sync conflict — choose below");
        } else if (cloudHasData) {
          skipNextPush.current = true;
          setWorkspace(remote.workspace);
          setSyncStatus("Synced from cloud");
        } else {
          await cloud.pushWorkspace(workspace);
          setSyncStatus("Pushed local to cloud");
        }
      } catch (err) {
        if (!cancelled) setSyncStatus("Pull failed: " + (err.message || "unknown"));
      }
    })();
    return () => { cancelled = true; };
  }, [user?.id, syncEnabled]);

  // ---- Conflict resolution actions ----
  const resolveConflict = async (choice) => {
    if (!conflict) return;
    let next;
    if (choice === "cloud") {
      next = conflict.cloud;
    } else if (choice === "local") {
      next = conflict.local;
    } else if (choice === "merge") {
      const localTrees = conflict.local.trees || [];
      const cloudTrees = conflict.cloud.trees || [];
      // Dedupe by id; if collision, prefer the newer updatedAt.
      const byId = new Map();
      [...cloudTrees, ...localTrees].forEach(t => {
        const prev = byId.get(t.id);
        if (!prev || (t.updatedAt || 0) >= (prev.updatedAt || 0)) byId.set(t.id, t);
      });
      const trees = [...byId.values()];
      next = { trees, activeId: conflict.local.activeId || trees[0]?.id };
    }
    if (!next) { setConflict(null); return; }
    skipNextPush.current = (choice === "cloud"); // local/merge should push; cloud is already current
    setWorkspace(next);
    setConflict(null);
    if (choice !== "cloud") {
      try {
        setSyncStatus("Pushing chosen workspace…");
        await cloud.pushWorkspace(next);
        setSyncStatus(choice === "merge" ? "Merged & synced" : "Local kept & synced");
      } catch (err) {
        setSyncStatus("Push failed: " + (err.message || "unknown"));
      }
    } else {
      setSyncStatus("Cloud workspace loaded");
    }
  };

  const toggleSync = (v) => {
    if (!cloud) return;
    cloud.setSyncEnabled(v);
    setSyncEnabledState(v);
    if (!v) setSyncStatus("Sync paused");
  };

  const handleSignOut = async () => {
    if (!cloud) return;
    await cloud.signOut();
    setSyncStatus("Signed out — using local storage");
  };

  const setActiveTree = (mutator) => {
    setWorkspace(ws => ({
      ...ws,
      trees: ws.trees.map(t => t.id === ws.activeId ? { ...mutator(t), updatedAt: Date.now() } : t),
    }));
  };

  const setView = (v) => setActiveTree(t => ({ ...t, view: { ...t.view, ...v } }));

  // ---- Computed: hidden node set due to collapsed ancestors ----
  const childrenMap = uM(() => {
    const m = {};
    Object.keys(activeTree.nodes).forEach(id => { m[id] = []; });
    activeTree.edges.forEach(e => { (m[e.from] = m[e.from] || []).push(e.to); });
    return m;
  }, [activeTree.edges, activeTree.nodes]);

  const hiddenNodes = uM(() => {
    const hidden = new Set();
    const walk = (id) => {
      const kids = childrenMap[id] || [];
      kids.forEach(k => {
        if (!hidden.has(k)) {
          hidden.add(k);
          walk(k);
        }
      });
    };
    Object.values(activeTree.nodes).forEach(n => {
      if (n.collapsed) walk(n.id);
    });
    return hidden;
  }, [activeTree.nodes, childrenMap]);

  // ---- Recursive sums: a node's subtotal is the sum of:
  //   - its own value (if set)
  //   - each child's subtotal (recursively)
  // It is "complete" only if every leaf descendant has a numeric value.
  // We expose subtotal only when the node itself has children (i.e. acts as a parent).
  const subtotals = uM(() => {
    const cache = {}; // id -> { sum: number, complete: boolean, hasOwn: boolean }
    const visit = (id, stack) => {
      if (cache[id]) return cache[id];
      if (stack.has(id)) return { sum: 0, complete: false, hasOwn: false }; // cycle guard
      stack.add(id);
      const node = activeTree.nodes[id];
      const kids = (childrenMap[id] || []);
      const ownVal = (node && node.value !== null && node.value !== undefined && node.value !== "") ? Number(node.value) : null;
      let sum = ownVal !== null ? ownVal : 0;
      let complete = true;
      if (kids.length === 0) {
        complete = ownVal !== null;
      } else {
        for (const k of kids) {
          const s = visit(k, stack);
          sum += s.sum;
          if (!s.complete) complete = false;
        }
      }
      stack.delete(id);
      const res = { sum, complete, hasOwn: ownVal !== null, hasKids: kids.length > 0 };
      cache[id] = res;
      return res;
    };
    Object.keys(activeTree.nodes).forEach(id => visit(id, new Set()));
    return cache;
  }, [activeTree.nodes, childrenMap]);

  // ---- Coordinate transform ----
  const screenToCanvas = (sx, sy) => {
    const rect = canvasRef.current.getBoundingClientRect();
    return {
      x: (sx - rect.left - view.x) / view.zoom,
      y: (sy - rect.top - view.y) / view.zoom,
    };
  };

  // ---- Node ops ----
  const updateNode = (id, patch) => {
    setActiveTree(t => ({ ...t, nodes: { ...t.nodes, [id]: { ...t.nodes[id], ...patch } } }));
  };

  const deleteNode = (id) => {
    setActiveTree(t => {
      const { [id]: _, ...rest } = t.nodes;
      // Also strip any links pointing to it
      const cleaned = {};
      Object.values(rest).forEach(n => {
        cleaned[n.id] = { ...n, links: n.links.filter(x => x !== id) };
      });
      return {
        ...t,
        nodes: cleaned,
        edges: t.edges.filter(e => e.from !== id && e.to !== id),
      };
    });
    if (selectedNodeId === id) setSelectedNodeId(null);
    setCtxMenu(null);
  };

  const addNodeAt = (x, y, overrides = {}) => {
    const n = TVStore.makeNode({ x, y, title: "New node", ...overrides });
    setActiveTree(t => ({ ...t, nodes: { ...t.nodes, [n.id]: n } }));
    setSelectedNodeId(n.id);
    return n.id;
  };

  const addChildOf = (parentId) => {
    const parent = activeTree.nodes[parentId];
    if (!parent) return;
    const kids = childrenMap[parentId] || [];
    const kidsCount = kids.length;
    // Place the new child just below the parent, nudged slightly to avoid
    // sitting directly on top of an existing sibling.
    const NEAR_DOWN = 90;   // vertical drop for new child
    const NUDGE = 28;       // small horizontal nudge per existing sibling
    const dir = kidsCount % 2 === 0 ? 1 : -1;
    const step = Math.ceil(kidsCount / 2);
    const px = parent.x + dir * step * NUDGE;
    const py = parent.y + NEAR_DOWN;
    const id = addNodeAt(px, py, { title: "Child" });
    setActiveTree(t => ({ ...t, edges: [...t.edges, { id: TVStore.uid("e"), from: parentId, to: id }] }));
  };

  const duplicateNode = (id) => {
    const n = activeTree.nodes[id];
    if (!n) return;
    const copy = TVStore.makeNode({ ...n, id: undefined, x: n.x + 40, y: n.y + 40, title: n.title + " copy" });
    copy.id = TVStore.uid("n");
    setActiveTree(t => ({ ...t, nodes: { ...t.nodes, [copy.id]: copy } }));
    setSelectedNodeId(copy.id);
  };

  const toggleCollapse = (id) => updateNode(id, { collapsed: !activeTree.nodes[id].collapsed });

  // ---- Status setter ----
  const setStatus = (id, status) => updateNode(id, { status });

  // ---- Context menu (open / toggle) anchored to a node's bounding rect ----
  const openNodeMenu = (id) => {
    const el = document.querySelector(`.node[data-id="${id}"]`);
    const rect = el && el.getBoundingClientRect();
    const MENU_W = 220, MENU_H = 280, GAP = 8;
    let x = 0, y = 0;
    if (rect) {
      x = rect.right + GAP;
      if (x + MENU_W > window.innerWidth - 8) x = rect.left - MENU_W - GAP;
      if (x < 8) x = 8;
      y = rect.top;
      if (y + MENU_H > window.innerHeight - 8) y = Math.max(8, window.innerHeight - MENU_H - 8);
    } else {
      x = window.innerWidth / 2 - MENU_W / 2;
      y = window.innerHeight / 2 - MENU_H / 2;
    }
    setCtxMenu({ x, y, kind: "node", nodeId: id });
  };
  const toggleNodeMenu = (id) => {
    if (ctxMenu && ctxMenu.kind === "node" && ctxMenu.nodeId === id) {
      setCtxMenu(null);
    } else {
      openNodeMenu(id);
    }
  };

  // ---- Tidy children: arrange direct children in a tidy fan below the parent ----
  const tidyChildren = (parentId) => {
    const parent = activeTree.nodes[parentId];
    const kids = (childrenMap[parentId] || []).filter(id => activeTree.nodes[id]);
    if (!parent || !kids.length) return;
    const SPACING = 180;
    const ROW_DOWN = 130;
    const totalWidth = (kids.length - 1) * SPACING;
    const startX = parent.x - totalWidth / 2;
    setActiveTree(t => {
      const nodes = { ...t.nodes };
      kids.forEach((kid, i) => {
        const oldKid = nodes[kid];
        const newX = startX + i * SPACING;
        const newY = parent.y + ROW_DOWN;
        // Move descendants of each kid as a group
        const dx = newX - oldKid.x;
        const dy = newY - oldKid.y;
        nodes[kid] = { ...oldKid, x: newX, y: newY };
        const desc = collectDescendants(kid);
        desc.forEach(did => {
          if (nodes[did]) nodes[did] = { ...nodes[did], x: nodes[did].x + dx, y: nodes[did].y + dy };
        });
      });
      return { ...t, nodes };
    });
  };

  // ---- Center the view on a given node ----
  const centerOnNode = (id) => {
    const n = activeTree.nodes[id];
    if (!n || !canvasRef.current) return;
    const rect = canvasRef.current.getBoundingClientRect();
    const z = view.zoom;
    // Desktop: inspector is on the right (~336px wide + margin), so push node left.
    // Mobile (≤768px): inspector is a bottom sheet, push node up instead.
    const isMobile = window.innerWidth <= 768;
    const offsetX = isMobile ? 0 : 176;
    const offsetY = isMobile ? rect.height * 0.22 : 0;
    setView({
      x: (rect.width / 2) - offsetX - n.x * z,
      y: (rect.height / 2) - offsetY - n.y * z,
    });
  };

  // ---- Edge ops ----
  const addEdge = (from, to) => {
    if (from === to) return;
    // Block exact duplicates in the same direction; allow anything else
    // (including reverse edges and cross-links that form graph cycles).
    if (activeTree.edges.some(e => e.from === from && e.to === to)) return;
    setActiveTree(t => ({ ...t, edges: [...t.edges, { id: TVStore.uid("e"), from, to }] }));
  };

  const deleteEdge = (id) => {
    setActiveTree(t => ({ ...t, edges: t.edges.filter(e => e.id !== id) }));
    if (selectedEdgeId === id) setSelectedEdgeId(null);
  };

  // ---- Descendants helper (used by group drag + halo + tidy) ----
  const collectDescendants = (rootId) => {
    const out = new Set();
    const stack = [rootId];
    const visited = new Set();
    while (stack.length) {
      const id = stack.pop();
      if (visited.has(id)) continue;
      visited.add(id);
      const kids = childrenMap[id] || [];
      kids.forEach(k => {
        if (k !== rootId) out.add(k);
        stack.push(k);
      });
    }
    return out;
  };

  // ---- Pointer: node drag ----
  const onNodePointerDown = (e, id) => {
    if (e.button !== 0) return;
    e.stopPropagation();
    setSelectedEdgeId(null);
    const n = activeTree.nodes[id];
    const start = screenToCanvas(e.clientX, e.clientY);
    // Capture descendant offsets for group drag
    const descendants = collectDescendants(id);
    const groupOffsets = {};
    descendants.forEach(did => {
      const dn = activeTree.nodes[did];
      if (dn) groupOffsets[did] = { dx: dn.x - n.x, dy: dn.y - n.y };
    });
    dragState.current = {
      id,
      dx: n.x - start.x, dy: n.y - start.y,
      moved: false,
      groupOffsets,
    };
    e.target.setPointerCapture?.(e.pointerId);
  };

  // ---- Pointer: connect from handle ----
  const onStartConnect = (e, fromId) => {
    if (e.button !== 0) return;
    const start = screenToCanvas(e.clientX, e.clientY);
    const fromNode = activeTree.nodes[fromId];
    connectState.current = { fromId, last: start };
    setPreviewEdge({ from: { x: fromNode.x, y: fromNode.y + 20 }, to: start });
    window.addEventListener("pointermove", onConnectMove);
    window.addEventListener("pointerup", onConnectUp);
  };

  const onConnectMove = (e) => {
    if (!connectState.current) return;
    const p = screenToCanvas(e.clientX, e.clientY);
    connectState.current.last = p;
    const fromNode = activeTree.nodes[connectState.current.fromId];
    setPreviewEdge({ from: { x: fromNode.x, y: fromNode.y + 20 }, to: p });
  };

  const onConnectUp = (e) => {
    window.removeEventListener("pointermove", onConnectMove);
    window.removeEventListener("pointerup", onConnectUp);
    if (!connectState.current) { setPreviewEdge(null); return; }
    // Hit test: is pointer on a node?
    const target = document.elementFromPoint(e.clientX, e.clientY);
    const nodeEl = target?.closest?.(".node");
    if (nodeEl) {
      const idx = [...nodeEl.parentElement.querySelectorAll(".node")].indexOf(nodeEl);
      // We need a more reliable lookup; tag nodes with data-id
      const targetId = nodeEl.dataset.id;
      if (targetId && targetId !== connectState.current.fromId) {
        addEdge(connectState.current.fromId, targetId);
      }
    } else {
      // Drop on empty space → create new node + connect
      const p = screenToCanvas(e.clientX, e.clientY);
      const fromId = connectState.current.fromId;
      const newId = addNodeAt(p.x, p.y, { title: "New node" });
      setActiveTree(t => ({ ...t, edges: [...t.edges, { id: TVStore.uid("e"), from: fromId, to: newId }] }));
    }
    connectState.current = null;
    setPreviewEdge(null);
  };

  // ---- Pointer: pan canvas (1 finger) / pinch-zoom (2 fingers) ----
  const onCanvasPointerDown = (e) => {
    pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });

    // Two pointers down → start pinch-zoom and cancel any single-pointer gestures.
    if (pointersRef.current.size === 2) {
      const pts = [...pointersRef.current.values()];
      const dx = pts[1].x - pts[0].x, dy = pts[1].y - pts[0].y;
      pinchState.current = {
        startDist: Math.hypot(dx, dy) || 1,
        startZoom: view.zoom,
        startView: { x: view.x, y: view.y },
        startCenter: { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 },
      };
      if (panState.current) { canvasRef.current?.classList.remove("panning"); panState.current = null; }
      dragState.current = null;
      return;
    }

    if (e.pointerType !== "mouse" || e.button === 1 ||
        (e.button === 0 && (e.target === canvasRef.current || e.target.classList.contains("canvas-inner")))) {
      panState.current = { sx: e.clientX, sy: e.clientY, vx: view.x, vy: view.y };
      canvasRef.current.classList.add("panning");
      setSelectedNodeId(null);
      setSelectedEdgeId(null);
      setCtxMenu(null);
    }
  };

  uE(() => {
    const onMove = (e) => {
      if (pointersRef.current.has(e.pointerId)) {
        pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
      }
      if (pinchState.current && pointersRef.current.size >= 2) {
        const pts = [...pointersRef.current.values()].slice(0, 2);
        const dx = pts[1].x - pts[0].x, dy = pts[1].y - pts[0].y;
        const dist = Math.hypot(dx, dy) || 1;
        const factor = dist / pinchState.current.startDist;
        const newZoom = Math.max(0.2, Math.min(2.5, pinchState.current.startZoom * factor));
        const center = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };
        const rect = canvasRef.current.getBoundingClientRect();
        // Keep the pinch centroid anchored in canvas space as it changes.
        const cx = center.x - rect.left, cy = center.y - rect.top;
        const wx = (pinchState.current.startCenter.x - rect.left - pinchState.current.startView.x) / pinchState.current.startZoom;
        const wy = (pinchState.current.startCenter.y - rect.top  - pinchState.current.startView.y) / pinchState.current.startZoom;
        setView({ zoom: newZoom, x: cx - wx * newZoom, y: cy - wy * newZoom });
        return;
      }
      if (panState.current) {
        const dx = e.clientX - panState.current.sx;
        const dy = e.clientY - panState.current.sy;
        setView({ x: panState.current.vx + dx, y: panState.current.vy + dy });
      }
      if (dragState.current) {
        const p = screenToCanvas(e.clientX, e.clientY);
        const nx = p.x + dragState.current.dx;
        const ny = p.y + dragState.current.dy;
        dragState.current.moved = true;
        const groupOffsets = dragState.current.groupOffsets || {};
        setActiveTree(t => {
          const nodes = { ...t.nodes };
          nodes[dragState.current.id] = { ...nodes[dragState.current.id], x: nx, y: ny };
          for (const did in groupOffsets) {
            const off = groupOffsets[did];
            if (nodes[did]) nodes[did] = { ...nodes[did], x: nx + off.dx, y: ny + off.dy };
          }
          return { ...t, nodes };
        });
      }
    };
    const onUp = (e) => {
      pointersRef.current.delete(e.pointerId);
      if (pointersRef.current.size < 2) pinchState.current = null;
      if (panState.current) {
        canvasRef.current?.classList.remove("panning");
        panState.current = null;
      }
      if (dragState.current) dragState.current = null;
    };
    const onCancel = (e) => onUp(e);
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onCancel);
    return () => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onCancel);
    };
  });

  // ---- Wheel: zoom or pan ----
  const onWheel = (e) => {
    e.preventDefault();
    if (e.ctrlKey || e.metaKey) {
      const factor = Math.exp(-e.deltaY * 0.0018);
      zoomAt(e.clientX, e.clientY, view.zoom * factor);
    } else {
      setView({ x: view.x - e.deltaX, y: view.y - e.deltaY });
    }
  };

  const zoomAt = (sx, sy, newZoom) => {
    const z = Math.max(0.2, Math.min(2.5, newZoom));
    const rect = canvasRef.current.getBoundingClientRect();
    const cx = sx - rect.left;
    const cy = sy - rect.top;
    const wx = (cx - view.x) / view.zoom;
    const wy = (cy - view.y) / view.zoom;
    setView({ zoom: z, x: cx - wx * z, y: cy - wy * z });
  };

  const fitToView = () => {
    const nodes = Object.values(activeTree.nodes);
    if (!nodes.length) { setView({ x: 0, y: 0, zoom: 1 }); return; }
    const xs = nodes.map(n => n.x), ys = nodes.map(n => n.y);
    const minX = Math.min(...xs) - 150, maxX = Math.max(...xs) + 150;
    const minY = Math.min(...ys) - 100, maxY = Math.max(...ys) + 100;
    const rect = canvasRef.current.getBoundingClientRect();
    const zX = rect.width / (maxX - minX);
    const zY = rect.height / (maxY - minY);
    const z = Math.max(0.3, Math.min(1.4, Math.min(zX, zY)));
    setView({
      zoom: z,
      x: rect.width / 2 - ((minX + maxX) / 2) * z,
      y: rect.height / 2 - ((minY + maxY) / 2) * z,
    });
  };

  const resetView = () => {
    fitToView();
  };

  // ---- Keyboard ----
  uE(() => {
    const onKey = (e) => {
      const tag = (e.target.tagName || "").toLowerCase();
      if (tag === "input" || tag === "textarea") return;
      if (e.key === "Backspace" || e.key === "Delete") {
        if (selectedNodeId) { e.preventDefault(); deleteNode(selectedNodeId); }
        else if (selectedEdgeId) { e.preventDefault(); deleteEdge(selectedEdgeId); }
      } else if (e.key === "Tab" && selectedNodeId) {
        e.preventDefault();
        addChildOf(selectedNodeId);
      } else if (e.key === "Escape") {
        setSelectedNodeId(null); setSelectedEdgeId(null); setCtxMenu(null);
      } else if ((e.metaKey || e.ctrlKey) && e.key === "d" && selectedNodeId) {
        e.preventDefault();
        duplicateNode(selectedNodeId);
      } else if ((e.metaKey || e.ctrlKey) && e.key === "0") {
        e.preventDefault(); fitToView();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  });

  // ---- Workspace ops ----
  const newTree = () => {
    const t = TVStore.emptyTree("New tree");
    setWorkspace(ws => ({ trees: [...ws.trees, t], activeId: t.id }));
    setSelectedNodeId(null);
  };
  const switchTree = (id) => {
    setWorkspace(ws => ({ ...ws, activeId: id }));
    setSelectedNodeId(null);
    setSelectedEdgeId(null);
  };
  const renameTree = (id, name) => {
    setWorkspace(ws => ({ ...ws, trees: ws.trees.map(t => t.id === id ? { ...t, name } : t) }));
  };
  const deleteTree = (id) => {
    setWorkspace(ws => {
      const trees = ws.trees.filter(t => t.id !== id);
      if (trees.length === 0) {
        const fresh = TVStore.emptyTree("New tree");
        return { trees: [fresh], activeId: fresh.id };
      }
      return { trees, activeId: ws.activeId === id ? trees[0].id : ws.activeId };
    });
  };

  // ---- Export / Import ----
  const downloadBlob = (blob, filename) => {
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url; a.download = filename;
    a.click();
    URL.revokeObjectURL(url);
  };

  const exportJSON = () => {
    const blob = new Blob([JSON.stringify(activeTree, null, 2)], { type: "application/json" });
    downloadBlob(blob, `${(activeTree.name || "tree").replace(/[^a-z0-9-_]+/gi, "_")}.json`);
  };

  const exportAllJSON = () => {
    const bundle = {
      kind: "tree-view.workspace",
      version: 1,
      exportedAt: new Date().toISOString(),
      trees: workspace.trees,
    };
    const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: "application/json" });
    downloadBlob(blob, `tree-view-all_${new Date().toISOString().slice(0,10)}.json`);
  };

  const exportPNG = async () => {
    // Render the tree's nodes + edges + halos to a canvas, fit to bounding box.
    const tree = activeTree;
    const nodes = Object.values(tree.nodes);
    if (!nodes.length) return;
    const xs = nodes.map(n => n.x), ys = nodes.map(n => n.y);
    const PAD = 120;
    const minX = Math.min(...xs) - PAD, maxX = Math.max(...xs) + PAD;
    const minY = Math.min(...ys) - PAD, maxY = Math.max(...ys) + PAD;
    const w = Math.ceil(maxX - minX), h = Math.ceil(maxY - minY);
    const scale = 2; // hi-dpi
    const canvas = document.createElement("canvas");
    canvas.width = w * scale; canvas.height = h * scale;
    const ctx = canvas.getContext("2d");
    ctx.scale(scale, scale);
    // White dotted background
    ctx.fillStyle = "#fafaf7";
    ctx.fillRect(0, 0, w, h);
    ctx.fillStyle = "#d6d6cf";
    for (let gx = 0; gx < w; gx += 22) for (let gy = 0; gy < h; gy += 22) {
      ctx.beginPath(); ctx.arc(gx, gy, 1.1, 0, Math.PI * 2); ctx.fill();
    }
    const tx = (px) => px - minX, ty = (py) => py - minY;
    // Halo rects
    const haloClusters = window.computeClusters
      ? window.computeClusters(tree.nodes, childrenMap, hiddenNodes)
      : [];
    ctx.fillStyle = "rgba(26,26,26,0.035)";
    ctx.strokeStyle = "rgba(26,26,26,0.12)";
    ctx.lineWidth = 1; ctx.setLineDash([3, 4]);
    haloClusters.forEach(c => {
      const r = 42, x = tx(c.x), y = ty(c.y);
      ctx.beginPath();
      if (ctx.roundRect) ctx.roundRect(x, y, c.w, c.h, r);
      else { ctx.rect(x, y, c.w, c.h); }
      ctx.fill(); ctx.stroke();
    });
    ctx.setLineDash([]);
    // Edges (bezier curves)
    ctx.strokeStyle = "#1a1a1a"; ctx.lineWidth = 1.4;
    tree.edges.forEach(e => {
      if (hiddenNodes.has(e.from) || hiddenNodes.has(e.to)) return;
      const a = tree.nodes[e.from], b = tree.nodes[e.to];
      if (!a || !b) return;
      const cy = Math.abs(b.y - a.y) * 0.5;
      ctx.beginPath();
      ctx.moveTo(tx(a.x), ty(a.y));
      ctx.bezierCurveTo(tx(a.x), ty(a.y + cy), tx(b.x), ty(b.y - cy), tx(b.x), ty(b.y));
      ctx.stroke();
    });
    // Nodes
    ctx.font = "500 13px ui-sans-serif, -apple-system, BlinkMacSystemFont, Inter, Helvetica Neue, Arial, sans-serif";
    ctx.textAlign = "center"; ctx.textBaseline = "middle";
    nodes.forEach(n => {
      if (hiddenNodes.has(n.id)) return;
      const x = tx(n.x), y = ty(n.y);
      const text = n.title || "Untitled";
      const tw = ctx.measureText(text).width;
      const padX = 14, padY = 9;
      const isCard = n.shape === "card";
      const isRect = n.shape === "rect";
      const w0 = Math.max(96, tw + padX * 2), h0 = isCard ? 60 : (isRect ? 40 : 36);
      const radius = isCard ? 12 : isRect ? 10 : 999;
      let stroke = "#1a1a1a";
      if (n.status === "red") stroke = "#d4533a";
      if (n.status === "yellow") stroke = "#cca434";
      if (n.status === "green") stroke = "#2f8a4f";
      ctx.fillStyle = "#fff";
      ctx.strokeStyle = stroke; ctx.lineWidth = 1.6;
      ctx.beginPath();
      if (ctx.roundRect) ctx.roundRect(x - w0/2, y - h0/2, w0, h0, radius);
      else ctx.rect(x - w0/2, y - h0/2, w0, h0);
      ctx.fill(); ctx.stroke();
      ctx.fillStyle = "#1a1a1a";
      ctx.fillText(text, x, y - (n.value != null && n.value !== "" ? 8 : 0));
      // Value or subtotal badge
      const hasVal = n.value !== null && n.value !== undefined && n.value !== "";
      const sub = subtotals[n.id];
      const showSub = !hasVal && sub && sub.hasKids && sub.complete;
      const valTxt = hasVal
        ? `${n.valueCurrency || "$"}${Number(n.value).toLocaleString(undefined,{maximumFractionDigits:2})}${n.valueSuffix||""}`
        : showSub
          ? `Σ ${n.valueCurrency || "$"}${Number(sub.sum).toLocaleString(undefined,{maximumFractionDigits:2})}${n.valueSuffix||""}`
          : null;
      if (valTxt) {
        ctx.font = "600 11px ui-sans-serif, Inter, sans-serif";
        ctx.fillStyle = showSub ? "#1a1a1a" : "#1a1a1a";
        ctx.fillText(valTxt, x, y + 12);
        ctx.font = "500 13px ui-sans-serif, Inter, sans-serif";
      }
    });
    canvas.toBlob((blob) => {
      downloadBlob(blob, `${(tree.name || "tree").replace(/[^a-z0-9-_]+/gi, "_")}.png`);
    }, "image/png");
  };

  const importJSON = (file) => {
    const reader = new FileReader();
    reader.onload = (e) => {
      try {
        const obj = JSON.parse(e.target.result);
        // Bundle (export all)?
        if (obj && obj.kind === "tree-view.workspace" && Array.isArray(obj.trees)) {
          const reIdTrees = obj.trees.map(t => ({
            ...t,
            id: TVStore.uid("t"),
            view: t.view || { x: 0, y: 0, zoom: 1 },
          }));
          setWorkspace(ws => ({
            trees: [...ws.trees, ...reIdTrees],
            activeId: reIdTrees[0]?.id || ws.activeId,
          }));
          return;
        }
        if (!obj.nodes || !obj.edges) throw new Error("Invalid file");
        const imported = { ...obj, id: TVStore.uid("t"), name: obj.name || "Imported tree", view: obj.view || { x: 0, y: 0, zoom: 1 } };
        setWorkspace(ws => ({ trees: [...ws.trees, imported], activeId: imported.id }));
      } catch (err) {
        alert("Couldn't import: " + err.message);
      }
    };
    reader.readAsText(file);
  };

  // ---- Context menu ----
  const onCanvasContextMenu = (e) => {
    e.preventDefault();
    if (e.target === canvasRef.current || e.target.classList.contains("canvas-inner") || e.target.tagName === "svg" || e.target.classList.contains("edges")) {
      setCtxMenu({ x: e.clientX, y: e.clientY, kind: "canvas", canvasPos: screenToCanvas(e.clientX, e.clientY) });
    }
  };

  // ---- Edge context menu via click ----
  const onEdgeClick = (id) => {
    setSelectedEdgeId(id);
    setSelectedNodeId(null);
  };

  const selectedNode = selectedNodeId ? activeTree.nodes[selectedNodeId] : null;

  // ---- Stats ----
  const nodeCount = Object.keys(activeTree.nodes).length;

  return (
    <div className={`app ${sidebarOpen ? 'sidebar-open' : ''} ${ctxMenu ? 'menu-open' : ''}`}>
      <div className="topbar">
        <button
          className="btn icon sidebar-toggle"
          title={sidebarOpen ? "Close trees" : "Open trees"}
          onClick={() => setSidebarOpen(o => !o)}
        >
          <Icon name="bars" size={15} />
        </button>
        <div className="brand">
          <Icon name="logo" size={16} />
          <span className="brand-text">Tree View</span>
        </div>
        <div style={{ width: 1, height: 20, background: 'var(--line-soft)', margin: '0 6px' }} />
        <input
          className="tree-name-input"
          value={activeTree.name}
          onChange={(e) => renameTree(activeTree.id, e.target.value)}
        />
        <div className="meta">{nodeCount} {nodeCount === 1 ? 'node' : 'nodes'} · {activeTree.edges.length} {activeTree.edges.length === 1 ? 'edge' : 'edges'}</div>
        <div className="spacer" />
        <div className="btn-row">
          <button className="btn outline" onClick={() => {
            const rect = canvasRef.current.getBoundingClientRect();
            const p = screenToCanvas(rect.left + rect.width / 2, rect.top + rect.height / 2);
            addNodeAt(p.x, p.y);
          }}>
            <Icon name="plus" size={13} /> Add node
          </button>
          <div className="divider" />
          <div className={`export-menu ${exportOpen ? 'open' : ''}`}>
            <button className="btn" title="Export options" onClick={() => setExportOpen(o => !o)}>
              <Icon name="download" size={13} /> Export
            </button>
            {exportOpen && (
              <>
                <div className="export-menu-scrim" onClick={() => setExportOpen(false)} />
                <div className="export-menu-pop">
                  <div className="export-menu-item" onClick={() => { exportJSON(); setExportOpen(false); }}>
                    <Icon name="download" size={12} /> Current tree (JSON)
                  </div>
                  <div className="export-menu-item" onClick={() => { exportPNG(); setExportOpen(false); }}>
                    <Icon name="card" size={12} /> Current tree (PNG)
                  </div>
                  <div className="export-menu-item" onClick={() => { exportAllJSON(); setExportOpen(false); }}>
                    <Icon name="duplicate" size={12} /> All trees (JSON)
                  </div>
                </div>
              </>
            )}
          </div>
          <label className="btn" title="Import JSON tree or bundle">
            <Icon name="upload" size={13} /> Import
            <input type="file" accept="application/json" className="sr"
              onChange={(e) => { if (e.target.files[0]) { importJSON(e.target.files[0]); e.target.value = ""; } }} />
          </label>
          {cloud && (
            <>
              <div className="divider" />
              <AuthMenu
                user={user}
                syncEnabled={syncEnabled}
                onToggleSync={toggleSync}
                onSignOut={handleSignOut}
                syncStatus={syncStatus}
              />
            </>
          )}
        </div>
      </div>

      <div className="sidebar-backdrop" onClick={() => setSidebarOpen(false)} />
      <div className="sidebar">
        <div className="sidebar-header">
          <span>Trees</span>
          <button className="btn icon" title="New tree" onClick={() => { newTree(); setSidebarOpen(false); }}>
            <Icon name="plus" size={13} />
          </button>
        </div>
        <div className="sidebar-list">
          {workspace.trees.map(t => (
            <TreeListItem key={t.id} tree={t}
              active={t.id === workspace.activeId}
              onClick={() => { switchTree(t.id); setSidebarOpen(false); }}
              onRename={(name) => renameTree(t.id, name)}
              onDelete={() => { if (confirm(`Delete "${t.name}"?`)) deleteTree(t.id); }}
            />
          ))}
        </div>
        <div className="sidebar-footer">
          <button className="btn outline" onClick={() => { newTree(); setSidebarOpen(false); }}>
            <Icon name="plus" size={13} /> New tree
          </button>
        </div>
      </div>

      <div className="canvas-wrap"
        ref={canvasRef}
        onPointerDown={onCanvasPointerDown}
        onWheel={onWheel}
        onContextMenu={onCanvasContextMenu}
        onClick={() => setCtxMenu(null)}
      >
        <div className="canvas-inner"
          style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.zoom})` }}
        >
          <HaloLayer
            nodes={activeTree.nodes}
            childrenMap={childrenMap}
            hiddenNodes={hiddenNodes}
          />
          <EdgesLayer
            edges={activeTree.edges}
            nodes={activeTree.nodes}
            hiddenNodes={hiddenNodes}
            previewEdge={previewEdge}
            onEdgeClick={onEdgeClick}
            selectedEdgeId={selectedEdgeId}
          />
          {Object.values(activeTree.nodes).map(n => {
            if (hiddenNodes.has(n.id)) return null;
            const allKids = (childrenMap[n.id] || []);
            const sub = subtotals[n.id];
            // Show subtotal only when node has children, all descendants are valued,
            // and either node has no own value OR has children that contribute beyond it.
            const showSubtotal = !!sub && sub.hasKids && sub.complete;
            return (
              <NodeView
                key={n.id}
                node={n}
                subtotal={showSubtotal ? sub.sum : null}
                selected={selectedNodeId === n.id}
                hasChildren={allKids.length > 0}
                childCount={allKids.length}
                onPointerDown={onNodePointerDown}
                onClick={(id) => openNodeMenu(id)}
                onDoubleClickNode={(id) => { setSelectedNodeId(id); setSelectedEdgeId(null); setTimeout(() => centerOnNode(id), 0); }}
                onTitleChange={(id, title) => updateNode(id, { title })}
                onStartConnect={onStartConnect}
                onToggleCollapse={toggleCollapse}
                onAddChild={addChildOf}
                onDuplicate={duplicateNode}
                onDelete={deleteNode}
                onEdit={(id) => { setSelectedNodeId(id); setSelectedEdgeId(null); }}
                onTidyChildren={tidyChildren}
                onSetStatus={setStatus}
                onToggleMenu={(id) => toggleNodeMenu(id)}
                onContextMenu={(e, id) => openNodeMenu(id)}
              />
            );
          })}
        </div>

        {nodeCount === 0 && (
          <div className="empty-state">
            <div className="empty-title">Empty tree</div>
            <div className="empty-sub">Right-click anywhere or use the Add node button to begin.</div>
          </div>
        )}

        <div className="canvas-hint">
          <span><kbd>Tab</kbd>add child</span>
          <span><kbd>Drag</kbd>handle to connect</span>
          <span><kbd>Double-click</kbd>edit title</span>
          <span><kbd>⌘0</kbd>fit</span>
        </div>

        <div className="zoom-controls">
          <button className="btn icon" onClick={() => zoomAt(window.innerWidth/2, window.innerHeight/2, view.zoom / 1.2)} title="Zoom out">
            <Icon name="zoomOut" size={13} />
          </button>
          <div className="zoom-readout">{Math.round(view.zoom * 100)}%</div>
          <button className="btn icon" onClick={() => zoomAt(window.innerWidth/2, window.innerHeight/2, view.zoom * 1.2)} title="Zoom in">
            <Icon name="zoomIn" size={13} />
          </button>
          <button className="btn icon" onClick={resetView} title="Fit to screen">
            <Icon name="fit" size={13} />
          </button>
        </div>

        {selectedNode && (
          <Inspector
            node={selectedNode}
            allNodes={activeTree.nodes}
            onUpdate={updateNode}
            onDelete={deleteNode}
            onClose={() => setSelectedNodeId(null)}
            onJumpTo={(id) => setSelectedNodeId(id)}
          />
        )}

        {ctxMenu && (
          <div className="ctx-menu" style={{ left: ctxMenu.x, top: ctxMenu.y }} onClick={(e) => e.stopPropagation()}>
            {ctxMenu.kind === "canvas" && (
              <>
                <div className="ctx-item" onClick={() => { addNodeAt(ctxMenu.canvasPos.x, ctxMenu.canvasPos.y); setCtxMenu(null); }}>
                  <Icon name="plus" size={12} /> Add node here
                </div>
                <div className="ctx-sep" />
                <div className="ctx-item" onClick={() => { fitToView(); setCtxMenu(null); }}>
                  <Icon name="fit" size={12} /> Fit to screen
                </div>
              </>
            )}
            {ctxMenu.kind === "node" && (() => {
              const cn = activeTree.nodes[ctxMenu.nodeId];
              const kidCount = (childrenMap[ctxMenu.nodeId] || []).length;
              if (!cn) return null;
              return (
                <>
                  <div className="ctx-item" onClick={() => {
                    setSelectedNodeId(ctxMenu.nodeId);
                    setSelectedEdgeId(null);
                    setTimeout(() => centerOnNode(ctxMenu.nodeId), 0);
                    setCtxMenu(null);
                  }}>
                    <Icon name="card" size={12} /> Edit details
                  </div>
                  <div className="ctx-item" onClick={() => {
                    window.dispatchEvent(new CustomEvent("tv:start-edit", { detail: { nodeId: ctxMenu.nodeId } }));
                    setCtxMenu(null);
                  }}>
                    <Icon name="menu" size={12} /> Rename
                  </div>
                  <div className="ctx-sep" />
                  <div className="ctx-item" onClick={() => { addChildOf(ctxMenu.nodeId); setCtxMenu(null); }}>
                    <Icon name="child" size={12} /> Add child
                    <span className="shortcut">Tab</span>
                  </div>
                  {kidCount > 0 && (
                    <div className="ctx-item" onClick={() => { tidyChildren(ctxMenu.nodeId); setCtxMenu(null); }}>
                      <Icon name="fit" size={12} /> Tidy children
                    </div>
                  )}
                  {kidCount > 0 && (
                    <div className="ctx-item" onClick={() => { toggleCollapse(ctxMenu.nodeId); setCtxMenu(null); }}>
                      <Icon name={cn.collapsed ? "plus" : "minus"} size={12} />
                      {cn.collapsed ? "Expand branch" : "Collapse branch"}
                    </div>
                  )}
                  <div className="ctx-sep" />
                  <div className="ctx-color-row">
                    <span className="ctx-color-label">Color</span>
                    {["red", "yellow", "green"].map(c => (
                      <button
                        key={c}
                        className={`ctx-color color-${c} ${cn.status === c ? 'active' : ''}`}
                        title={cn.status === c ? `Clear ${c}` : `Set ${c}`}
                        onClick={() => setStatus(ctxMenu.nodeId, cn.status === c ? null : c)}
                      />
                    ))}
                    <button
                      className={`ctx-color clear ${!cn.status ? 'active' : ''}`}
                      title="No color"
                      onClick={() => setStatus(ctxMenu.nodeId, null)}
                    >
                      <Icon name="x" size={10} />
                    </button>
                  </div>
                  <div className="ctx-sep" />
                  <div className="ctx-item" onClick={() => { duplicateNode(ctxMenu.nodeId); setCtxMenu(null); }}>
                    <Icon name="duplicate" size={12} /> Duplicate
                    <span className="shortcut">⌘D</span>
                  </div>
                  <div className="ctx-item danger" onClick={() => { deleteNode(ctxMenu.nodeId); setCtxMenu(null); }}>
                    <Icon name="trash" size={12} /> Delete
                    <span className="shortcut">Del</span>
                  </div>
                </>
              );
            })()}
          </div>
        )}
      </div>

      {conflict && (
        <ConflictModal
          local={conflict.local}
          cloud={conflict.cloud}
          onResolve={resolveConflict}
        />
      )}
    </div>
  );
}

window.App = App;
