// Main App
function App(){
  const initial = {
    title: '',
    subtitle: '',
    showTitleOnCanvas: false,
    bgPattern: 'bg-dots',
    bgColor: '#161922',
    nodes: [],
    edges: [],
    groups: [],
    notes: [],          // card annotation boxes (PC-only feature)
    showNotes: true,    // global toggle: hide all note boxes + connectors
    // Flow animation runs on every arrow. Default OFF; users opt in via the
    // "背景・装飾" popover. Sample demo turns it on explicitly so first-time
    // users see the effect at least once.
    showFlow: false,
    // User-learned action vocabulary. Auto-populated when users type a custom
    // label — the label/color/stamp combo gets saved as a convenience button
    // for future arrows. Stored per-project (travels with .combo.json).
    // Items: { id, label, color, stamp }
    customRelations: [],
  };

  const hist = useHistory(initial);
  const { state, set } = hist;
  const [selection, setSelection] = React.useState(null);
  const [view, setView] = React.useState({ zoom: 1, tx: 200, ty: 120 });
  const [editingEdgeId, setEditingEdgeId] = React.useState(null);
  const [toast, setToast] = React.useState(null);
  const [shotMode, setShotMode] = React.useState(false);
  // Group compose mode: when non-null, clicking a node toggles its membership in that group.
  const [composingGroupId, setComposingGroupId] = React.useState(null);
  // Branch-node mode: when true, clicking an edge places a small branch node
  // at that spot. Exits via Esc or toggling the toolbar button again.
  const [branchNodeMode, setBranchNodeMode] = React.useState(false);
  const shotPrevViewRef = React.useRef(null);
  const canvasRef = React.useRef(null);

  // ---- toast helper
  const flash = (msg)=>{ setToast(msg); setTimeout(()=>setToast(null), 1800); };

  // ---- mutations
  const nextId = (prefix) => prefix + '_' + Math.random().toString(36).slice(2,8);

  const addNode = (partial={}) => {
    // Place near center of current view
    const stage = canvasRef.current;
    const rect = stage?.getBoundingClientRect();
    const cx = rect ? (rect.width/2 - view.tx)/view.zoom : 400;
    const cy = rect ? (rect.height/2 - view.ty)/view.zoom : 300;
    const color = NODE_COLORS[state.nodes.length % NODE_COLORS.length];
    const id = nextId('n');
    set(s => ({
      ...s,
      nodes:[...s.nodes, {
        id, x: cx + (Math.random()-0.5)*80, y: cy + (Math.random()-0.5)*80,
        name:'新しいカード', color, emoji: null, image: null, shape:'square',
        ...partial,
      }]
    }));
    setSelection({type:'node', id});
  };

  const updateNode = (id, patch, opts) => {
    set(s => ({
      ...s,
      nodes: s.nodes.map(n => n.id===id ? { ...n, ...patch } : n),
    }), opts);
  };

  // Add a small branch node attached to an edge at parameter t along the curve.
  // The branch stays glued to the arrow: moving the arrow's endpoints drags
  // the branch along, and the user can slide it by dragging. x/y are stored
  // as fallback for when the attached edge later gets deleted — in that case
  // the branch detaches and remains at its last rendered position.
  const addBranchNodeOnEdge = (edgeId, t, x, y) => {
    const id = nextId('n');
    set(s => ({
      ...s,
      nodes:[...s.nodes, {
        id, x, y, variant:'branch', shape:'circle',
        name:'', subtitle:'', color:'#8A8D9E', emoji:null, image:null,
        attachedEdgeId: edgeId, t: t,
      }]
    }));
    setSelection({type:'node', id});
  };

  // Add a branch node placed inside a zone — registered as a regular member
  // of that group's nodeIds, so the metaball blob naturally extends to include
  // the branch (no special projection / inset math needed). The branch's
  // initial color snapshots the group color at placement time; subsequent edits
  // are independent (group recolors don't follow). Branches are exclusive to
  // a single group — they have no separate existence outside it.
  const addBranchNodeOnGroup = (groupId, x, y) => {
    const id = nextId('n');
    set(s => {
      const g = (s.groups || []).find(gg => gg.id === groupId);
      const groupColor = g?.color || '#8A8D9E';
      return {
        ...s,
        nodes:[...s.nodes, {
          id, x, y, variant:'branch', shape:'circle',
          name:'', subtitle:'', color: groupColor, emoji:null, image:null,
        }],
        groups: (s.groups || []).map(gg => gg.id === groupId
          ? { ...gg, nodeIds: [...(gg.nodeIds || []), id] }
          : gg),
      };
    });
    setSelection({type:'node', id});
  };

  // Cascade-delete helper: given a state and a set of explicit victims (nodes,
  // edges, or groups), iterate to a fixed point and return a new state with
  // every dependent removal applied. Rules:
  //   • dying node     → its incident edges die
  //   • dying edge     → edge-attached branches die, edge-targeted notes die
  //   • dying group    → its branch members die (branches can't exist alone)
  //   • group whose non-branch members hit 0 → group dies (then its branches)
  // Pass victims as { nodes:[], edges:[], groups:[] }.
  const applyCascadeDelete = (s, victims) => {
    const dyingNodes = new Set(victims.nodes || []);
    const dyingEdges = new Set(victims.edges || []);
    const dyingGroups = new Set(victims.groups || []);

    // Seed: branches in explicitly deleted groups are already dying.
    dyingGroups.forEach(gid => {
      const g = (s.groups || []).find(gg => gg.id === gid);
      (g?.nodeIds || []).forEach(nid => {
        const n = s.nodes.find(nn => nn.id === nid);
        if (n && n.variant === 'branch') dyingNodes.add(n.id);
      });
    });

    let changed = true;
    while (changed) {
      changed = false;

      // dying nodes → incident edges die
      s.edges.forEach(e => {
        if (dyingEdges.has(e.id)) return;
        if (dyingNodes.has(e.from) || dyingNodes.has(e.to)) {
          dyingEdges.add(e.id);
          changed = true;
        }
      });

      // dying edges → edge-attached branches die
      s.nodes.forEach(n => {
        if (dyingNodes.has(n.id)) return;
        if (n.variant === 'branch' && n.attachedEdgeId && dyingEdges.has(n.attachedEdgeId)) {
          dyingNodes.add(n.id);
          changed = true;
        }
      });

      // groups whose non-branch members hit 0 → group dies (then its branches)
      (s.groups || []).forEach(g => {
        if (dyingGroups.has(g.id)) return;
        const liveNonBranch = (g.nodeIds || []).filter(nid => {
          if (dyingNodes.has(nid)) return false;
          const n = s.nodes.find(nn => nn.id === nid);
          return n && n.variant !== 'branch';
        });
        if (liveNonBranch.length === 0) {
          dyingGroups.add(g.id);
          (g.nodeIds || []).forEach(nid => {
            const n = s.nodes.find(nn => nn.id === nid);
            if (n && n.variant === 'branch' && !dyingNodes.has(n.id)) {
              dyingNodes.add(n.id);
            }
          });
          changed = true;
        }
      });
    }

    return {
      ...s,
      nodes: s.nodes.filter(n => !dyingNodes.has(n.id)),
      edges: s.edges.filter(e => !dyingEdges.has(e.id)),
      groups: (s.groups || [])
        .filter(g => !dyingGroups.has(g.id))
        .map(g => ({ ...g, nodeIds: (g.nodeIds || []).filter(nid => !dyingNodes.has(nid)) })),
      notes: (s.notes || []).filter(n =>
        !dyingNodes.has(n.nodeId) && !dyingEdges.has(n.targetEdgeId)),
    };
  };

  // Duplicate a node: copies every property except id, offsets position so the
  // new card is visibly separate. For regular cards, attached edges / groups /
  // notes are NOT copied (the user likely wants a fresh instance to wire up
  // differently). For BRANCH nodes, the copy MUST stay anchored — branches
  // can't exist alone — so group memberships ARE preserved (edge-attach is
  // already preserved by the spread). Toast is variant-aware.
  const duplicateNode = (id) => {
    const src = state.nodes.find(n => n.id === id);
    if (!src) return;
    const newId = nextId('n');
    const copy = { ...src, id: newId, x: (src.x || 0) + 30, y: (src.y || 0) + 30 };
    const isBranch = src.variant === 'branch';
    set(s => {
      const groups = isBranch
        ? (s.groups || []).map(g =>
            (g.nodeIds || []).includes(id)
              ? { ...g, nodeIds: [...g.nodeIds, newId] }
              : g)
        : (s.groups || []);
      return { ...s, nodes: [...s.nodes, copy], groups };
    });
    setSelection({ type: 'node', id: newId });
    flash(isBranch ? '分岐ノードを複製したよ 📋' : 'カードを複製したよ 📋');
  };

  const deleteNode = (id) => {
    set(s => applyCascadeDelete(s, { nodes: [id] }));
    setSelection(null);
  };

  const addEdge = (fromId, toId, partial={}) => {
    const id = nextId('e');
    set(s => ({
      ...s,
      edges:[...s.edges, {
        id, from:fromId, to:toId,
        relation:'summon', style:'oneway',
        color: RELATIONS[0].color, label: null, stamp: RELATIONS[0].stamp,
        ...partial,
      }]
    }));
    setSelection({type:'edge', id});
  };

  const updateEdge = (id, patch, opts) => {
    set(s => ({
      ...s,
      edges: s.edges.map(e => e.id===id ? { ...e, ...patch } : e),
    }), opts);
  };

  const deleteEdge = (id) => {
    // Cascade-delete edge-attached branches + edge-attached notes via the
    // shared cascade helper. Toast surfaces the side effects so deletion
    // isn't silently destructive.
    //
    // Compute side-effect counts from CURRENT state BEFORE calling set() —
    // React 18 batches the updater, so any closure mutation inside the set()
    // callback would run AFTER the synchronous side-effect dispatches below
    // (the toast wouldn't fire).
    const attachedBranchCount = state.nodes.filter(n => n.attachedEdgeId === id).length;
    const attachedNoteCount = (state.notes || []).filter(n => n.targetEdgeId === id).length;
    set(s => applyCascadeDelete(s, { edges: [id] }));
    setSelection(null);
    const parts = [];
    if (attachedBranchCount > 0) parts.push(`分岐ノード ${attachedBranchCount}`);
    if (attachedNoteCount > 0) parts.push(`解説 ${attachedNoteCount}`);
    if (parts.length > 0) flash(`矢印を削除(紐づく${parts.join(' / ')} も削除)`);
  };

  // -------- Groups (cluster of nodes with a blob visual) --------
  const GROUP_COLORS = ['#E9C46A','#F4A261','#6DD3F0','#7FE3A9','#FF6B9D','#C77DFF','#B39DDB'];

  const createGroupFromNode = (nodeId) => {
    const id = nextId('g');
    const usedColors = (state.groups || []).map(g=>g.color);
    const color = GROUP_COLORS.find(c => !usedColors.includes(c)) || GROUP_COLORS[0];
    set(s => ({
      ...s,
      groups: [...(s.groups || []), { id, name:'', color, showName:true, nodeIds:[nodeId] }],
    }));
    return id;
  };

  const updateGroup = (id, patch, opts) => {
    set(s => ({
      ...s,
      groups: (s.groups || []).map(g => g.id === id ? { ...g, ...patch } : g),
    }), opts);
  };

  const deleteGroup = (id) => {
    // Group deletion cascades to its branch members (which can't exist alone),
    // and from there to any edges incident to those branches plus notes on
    // those edges. Regular card members survive (they may be in other groups).
    //
    // Compute label/count synchronously from current state — see deleteEdge
    // for the React 18 batching reason.
    const grp = (state.groups || []).find(x => x.id === id);
    const groupLabel = grp?.name || '';
    const branchMemberCount = (grp?.nodeIds || []).filter(nid => {
      const n = state.nodes.find(nn => nn.id === nid);
      return n && n.variant === 'branch';
    }).length;
    set(s => applyCascadeDelete(s, { groups: [id] }));
    setSelection(null);
    if (composingGroupId === id) setComposingGroupId(null);
    if (branchMemberCount > 0) {
      flash(`「${groupLabel || 'ゾーン'}」を削除(紐づく分岐ノード ${branchMemberCount} 個も削除)`);
    }
  };

  // Remove a regular card from a group via the inspector ×. If this leaves
  // the group with only branch members (or empty), the group itself
  // cascade-deletes — branches can't survive alone, and a branches-only zone
  // is invalid by design. Toast surfaces the cascade so it isn't surprising.
  const removeNodeFromGroup = (groupId, nodeId) => {
    // Compute side-effect signals from CURRENT state BEFORE calling set() —
    // React 18 batches the updater, so closure mutations inside set() would
    // run AFTER the sync setSelection / flash calls below, leaving the
    // Inspector stuck on a stale group reference (blank panel).
    const grp = (state.groups || []).find(g => g.id === groupId);
    if (!grp) return;
    const willHaveNonBranch = (grp.nodeIds || []).some(nid => {
      if (nid === nodeId) return false;
      const n = state.nodes.find(nn => nn.id === nid);
      return n && n.variant !== 'branch';
    });
    const groupDied = !willHaveNonBranch;
    const dyingBranchCount = groupDied
      ? (grp.nodeIds || []).filter(nid => {
          const n = state.nodes.find(nn => nn.id === nid);
          return n && n.variant === 'branch';
        }).length
      : 0;

    set(s => {
      const updated = {
        ...s,
        groups: (s.groups || []).map(g => g.id === groupId
          ? { ...g, nodeIds: (g.nodeIds || []).filter(nid => nid !== nodeId) }
          : g),
      };
      // Empty victims set: applyCascadeDelete still runs its auto-detect loop
      // for "groups whose non-branch members hit 0" → branches in those groups
      // die → incident edges & notes die.
      return applyCascadeDelete(updated, {});
    });

    if (groupDied) {
      if (selection?.type === 'group' && selection.id === groupId) setSelection(null);
      if (composingGroupId === groupId) setComposingGroupId(null);
      if (dyingBranchCount > 0) {
        flash(`「${grp.name || 'ゾーン'}」を削除(残った分岐ノード ${dyingBranchCount} 個も削除)`);
      }
    }
  };

  // Toggle a node's membership in a group during compose mode.
  // Regular cards can be in any number of groups; BRANCH nodes are exclusive
  // to a single group — toggling them off would orphan them, so we no-op
  // (with a toast hint) instead.
  const toggleGroupMember = (groupId, nodeId) => {
    const node = state.nodes.find(n => n.id === nodeId);
    if (node && node.variant === 'branch') {
      flash('分岐ノードはここから外せません(削除のみ可能)');
      return;
    }
    set(s => {
      let groups = (s.groups || []).map(g => {
        if (g.id !== groupId) return g;
        const has = (g.nodeIds || []).includes(nodeId);
        const nodeIds = has
          ? g.nodeIds.filter(x => x !== nodeId)
          : [...(g.nodeIds || []), nodeId];
        return { ...g, nodeIds };
      });
      // Auto-delete groups whose non-branch members dropped to 0 (except the
      // one we're actively composing — keep it open while the user works).
      const dyingGroupIds = groups
        .filter(g => g.id !== groupId)
        .filter(g => {
          const liveNonBranch = (g.nodeIds || []).filter(nid => {
            const n = s.nodes.find(nn => nn.id === nid);
            return n && n.variant !== 'branch';
          });
          return liveNonBranch.length === 0;
        })
        .map(g => g.id);
      if (dyingGroupIds.length > 0) {
        // applyCascadeDelete handles the branch-cascade for those groups.
        return applyCascadeDelete({ ...s, groups }, { groups: dyingGroupIds });
      }
      return { ...s, groups };
    });
  };

  // ---- note (card annotation) CRUD
  const addNote = (nodeId) => {
    const id = nextId('note');
    // Offset by slight increments so stacking multiple notes on the same card
    // fans them out instead of piling directly on top of each other.
    const siblingCount = (state.notes || []).filter(n => n.nodeId === nodeId).length;
    const fanOffset = siblingCount * 24;
    set(s => ({
      ...s,
      notes: [...(s.notes || []), {
        id, nodeId, text: '',
        offset: { dx: 140 + fanOffset, dy: 60 + fanOffset },
      }],
    }));
    setSelection({type:'note', id});
  };
  // Add an annotation attached to an edge's midpoint instead of a node.
  // Resolved position = bezier midpoint + offset, recomputed every render so
  // the note follows when the edge curve / endpoints change.
  const addNoteOnEdge = (edgeId) => {
    const id = nextId('note');
    const siblingCount = (state.notes || []).filter(n => n.targetEdgeId === edgeId).length;
    const fanOffset = siblingCount * 24;
    set(s => ({
      ...s,
      notes: [...(s.notes || []), {
        id, targetEdgeId: edgeId, text: '',
        offset: { dx: 80 + fanOffset, dy: -80 + fanOffset },
      }],
    }));
    setSelection({type:'note', id});
  };
  const updateNote = (id, patch, opts) => {
    set(s => ({
      ...s,
      notes: (s.notes || []).map(n => n.id===id ? {...n, ...patch} : n),
    }), opts);
  };
  const deleteNote = (id) => {
    set(s => ({ ...s, notes: (s.notes || []).filter(n => n.id !== id) }));
    setSelection(null);
  };

  // ---- Auto-learned action vocabulary. Whenever the user types a custom
  // label on an edge, we remember it (along with the current color/stamp) as a
  // convenience button for future arrows. No explicit "register" UI — it just
  // accumulates. Users can prune with the × button on each custom chip.
  const learnRelation = (label, color, stamp) => {
    const normalized = (label || '').trim();
    if (!normalized) return;
    // Skip if the label already matches a built-in preset (no point duplicating).
    if (RELATIONS.some(r => r.label.trim() === normalized)) return;
    const customs = state.customRelations || [];
    // Case-insensitive dedup against existing custom entries.
    if (customs.some(r => r.label.trim().toLowerCase() === normalized.toLowerCase())) return;
    const id = 'c_' + Math.random().toString(36).slice(2, 8);
    set(s => ({
      ...s,
      customRelations: [...(s.customRelations || []),
        { id, label: normalized, color: color || '#DDDDDD', stamp: stamp || '' }],
    }));
    // Visual feedback is handled by the chip's gold-pulse animation in Inspector,
    // so no toast noise is needed here (works silently for repeat additions too).
  };
  const removeCustomRelation = (id) => {
    set(s => ({ ...s, customRelations: (s.customRelations || []).filter(r => r.id !== id) }));
  };

  // Swap two edges' positions in state.edges — later edges render on top,
  // so this effectively swaps their z-order. Triggered by Shift+click.
  const swapEdgeZ = (id1, id2) => {
    set(s => {
      const i1 = s.edges.findIndex(e => e.id === id1);
      const i2 = s.edges.findIndex(e => e.id === id2);
      if (i1 < 0 || i2 < 0 || i1 === i2) return s;
      const edges = [...s.edges];
      [edges[i1], edges[i2]] = [edges[i2], edges[i1]];
      return {...s, edges};
    });
  };

  const clearAll = () => {
    if (!confirm('すべてのカードと矢印を削除します。よろしい？')) return;
    set(s => ({...s, nodes:[], edges:[], groups:[], notes:[], customRelations:[], title:'', subtitle:'', showTitleOnCanvas:false, showFlow:false, bgPattern:'bg-dots', bgColor:'#161922'}));
    setSelection(null);
    setComposingGroupId(null);
    try {
      // Must match the key in useHistory — see history.jsx for the rationale.
      localStorage.removeItem('combo_saved_data');
    } catch(e) {}
  };

  // ---- demo data
  const loadDemo = () => {
    const demo = buildDemoData();
    set(s => ({
      ...s,
      nodes: demo.nodes, edges: demo.edges, groups: demo.groups || [],
      notes: demo.notes || [], showNotes: true,
      // Demo explicitly enables flow so first-time users see the animation
      // in action. Empty/new projects still start with flow off (default).
      showFlow: demo.showFlow ?? true,
      title: '1 枚初動コンボ例', subtitle: '初動 1 枚から最終盤面まで',
      showTitleOnCanvas: true,
    }));
    // Center the demo in whatever canvas viewport the user currently has —
    // a hardcoded tx/ty would leave the layout left-aligned on wide monitors.
    const zoom = 0.85;
    const rect = canvasRef.current?.getBoundingClientRect();
    const vw = rect ? rect.width : 1200;
    const vh = rect ? rect.height : 700;
    const xs = demo.nodes.map(n => n.x);
    const ys = demo.nodes.map(n => n.y);
    const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
    const cy = (Math.min(...ys) + Math.max(...ys)) / 2;
    // Nudge content up a bit so the canvas title banner has headroom.
    const tx = vw / 2 - cx * zoom;
    const ty = vh / 2 - cy * zoom - 40;
    setView({ zoom, tx, ty });
    setSelection(null);
    flash('サンプルを読み込んだよ 💎');
  };

  // ---- setters bound to state via set()
  const setTitle = (t)=> set(s=>({...s,title:t}));
  const setSubtitle = (t)=> set(s=>({...s,subtitle:t}));
  const setShowTitleOnCanvas = (v)=> set(s=>({...s,showTitleOnCanvas:v}));
  const setShowNotes = (v)=> set(s=>({...s,showNotes:v}));
  const setShowFlow  = (v)=> set(s=>({...s,showFlow:v}));
  const setBgPattern = (v)=> set(s=>({...s,bgPattern:v}));
  const setBgColor = (v)=> set(s=>({...s,bgColor:v}));

  // ---- project file save / load
  const saveProject = () => {
    const data = JSON.stringify(state, null, 2);
    const blob = new Blob([data], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = (state.title || '展開ルート') + '.combo.json';
    a.click();
    URL.revokeObjectURL(url);
    flash('作業ファイルを保存したよ 💾');
  };

  const loadProjectFileRef = React.useRef(null);
  const loadProject = () => {
    loadProjectFileRef.current?.click();
  };
  const onLoadProjectFile = (e) => {
    const f = e.target.files?.[0];
    if (!f) return;
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const data = JSON.parse(reader.result);
        if (!confirm('現在の作業内容は上書きされます。よろしいですか？')) return;
        set(s => ({
          ...s,
          title: data.title ?? '',
          subtitle: data.subtitle ?? '',
          showTitleOnCanvas: data.showTitleOnCanvas ?? false,
          bgPattern: data.bgPattern ?? 'bg-dots',
          bgColor: data.bgColor ?? '#161922',
          nodes: data.nodes ?? [],
          edges: data.edges ?? [],
          groups: data.groups ?? [],
          notes: data.notes ?? [],
          showNotes: data.showNotes ?? true,
          showFlow: data.showFlow ?? false,
          customRelations: data.customRelations ?? [],
        }));
        setSelection(null);
        setComposingGroupId(null);
        setView({ zoom: 1, tx: 200, ty: 120 });
        flash('作業ファイルを読み込んだよ 📂');
      } catch(err) {
        flash('ファイルの読み込みに失敗しました');
        console.error(err);
      }
    };
    reader.readAsText(f);
    e.target.value = '';
  };

  // ---- screenshot mode
  const enterShotMode = () => {
    if (state.nodes.length === 0) { flash('まずカードを追加してね'); return; }
    setSelection(null);
    const pad = 80;
    let minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;
    state.nodes.forEach(n=>{
      minX = Math.min(minX, n.x-70); minY = Math.min(minY, n.y-70);
      maxX = Math.max(maxX, n.x+70); maxY = Math.max(maxY, n.y+70);
    });
    if (state.showTitleOnCanvas) minY = Math.min(minY, 0);
    minX-=pad; minY-=pad; maxX+=pad; maxY+=pad;
    const bw = maxX-minX, bh = maxY-minY;
    const vw = window.innerWidth, vh = window.innerHeight;
    const zoom = Math.min(1.5, Math.max(0.3, Math.min(vw/bw, vh/bh)));
    const tx = (vw - bw*zoom)/2 - minX*zoom;
    const ty = (vh - bh*zoom)/2 - minY*zoom;
    shotPrevViewRef.current = view;
    setView({ zoom, tx, ty });
    setShotMode(true);
    document.body.classList.add('is-shot-mode');
  };
  const exitShotMode = () => {
    setShotMode(false);
    document.body.classList.remove('is-shot-mode');
    if (shotPrevViewRef.current) {
      setView(shotPrevViewRef.current);
      shotPrevViewRef.current = null;
    }
  };

  // ---- keyboard
  React.useEffect(()=>{
    const onKey = (e) => {
      if (shotMode && e.key==='Escape') { exitShotMode(); return; }
      if (e.key==='Escape' && composingGroupId) { setComposingGroupId(null); return; }
      if (e.key==='Escape' && branchNodeMode) { setBranchNodeMode(false); return; }
      if (e.target.matches('input,textarea')) return;
      if ((e.metaKey||e.ctrlKey) && e.key==='z' && !e.shiftKey){ e.preventDefault(); hist.undo(); }
      else if ((e.metaKey||e.ctrlKey) && (e.key==='y' || (e.shiftKey && e.key==='z'))){ e.preventDefault(); hist.redo(); }
      else if (e.key==='Delete' || e.key==='Backspace'){
        if (selection?.type==='node') deleteNode(selection.id);
        if (selection?.type==='edge') deleteEdge(selection.id);
        if (selection?.type==='group') deleteGroup(selection.id);
        if (selection?.type==='note') deleteNote(selection.id);
      }
    };
    window.addEventListener('keydown', onKey);
    return ()=> window.removeEventListener('keydown', onKey);
  }, [selection, hist, shotMode, composingGroupId, branchNodeMode]);

  // ---- edge label inline editor
  const editingEdge = state.edges.find(e=>e.id===editingEdgeId);

  // Branch placement requires SOMETHING to drop the branch onto — at least one
  // arrow OR one zone with non-branch members. When there's nothing valid, the
  // toolbar button is disabled so the user can't enter a no-op mode.
  const branchModeAvailable = React.useMemo(()=>{
    if (state.edges.length > 0) return true;
    return (state.groups || []).some(g => (g.nodeIds || []).some(nid => {
      const n = state.nodes.find(nn => nn.id === nid);
      return n && n.variant !== 'branch';
    }));
  }, [state.edges, state.groups, state.nodes]);
  // Auto-exit branch mode if all valid drop targets disappear while it's
  // active (e.g., the user deleted the last arrow / zone via undo).
  React.useEffect(()=>{
    if (branchNodeMode && !branchModeAvailable) setBranchNodeMode(false);
  }, [branchNodeMode, branchModeAvailable]);

  return (
    <div className="app">
      <Toolbar
        title={state.title} setTitle={setTitle}
        subtitle={state.subtitle} setSubtitle={setSubtitle}
        showTitleOnCanvas={state.showTitleOnCanvas} setShowTitleOnCanvas={setShowTitleOnCanvas}
        showNotes={state.showNotes !== false} setShowNotes={setShowNotes}
        showFlow={state.showFlow === true} setShowFlow={setShowFlow}
        bgPattern={state.bgPattern} setBgPattern={setBgPattern}
        bgColor={state.bgColor} setBgColor={setBgColor}
        canUndo={hist.canUndo} canRedo={hist.canRedo}
        onUndo={hist.undo} onRedo={hist.redo}
        onAddNode={()=>addNode()}
        branchNodeMode={branchNodeMode}
        branchModeAvailable={branchModeAvailable}
        onToggleBranchMode={()=>{
          if (!branchNodeMode && !branchModeAvailable){
            // No valid drop targets exist yet — refuse to enter the mode so
            // the user isn't stuck in a no-op state. Hint via toast.
            flash('先に矢印かゾーンを作ってね');
            return;
          }
          setBranchNodeMode(v=>!v);
        }}
        onShotMode={enterShotMode}
        onSaveProject={saveProject}
        onLoadProject={loadProject}
        onLoadDemo={loadDemo}
        onClear={clearAll}
      />
      <input ref={loadProjectFileRef} className="file-hidden" type="file" accept=".combo.json,.json" onChange={onLoadProjectFile}/>

      <div className="canvas-wrap">
        <Canvas
          nodes={state.nodes} edges={state.edges} groups={state.groups || []}
          notes={state.notes || []} showNotes={state.showNotes !== false}
          showFlow={state.showFlow === true}
          updateNote={updateNote}
          branchNodeMode={branchNodeMode}
          addBranchNodeOnEdge={addBranchNodeOnEdge}
          addBranchNodeOnGroup={addBranchNodeOnGroup}
          selection={selection} setSelection={setSelection}
          updateNode={updateNode} deleteNode={deleteNode} addEdge={addEdge} updateEdge={updateEdge}
          swapEdgeZ={swapEdgeZ} flash={flash}
          composingGroupId={composingGroupId} setComposingGroupId={setComposingGroupId}
          toggleGroupMember={toggleGroupMember}
          updateGroup={updateGroup}
          view={view} setView={setView} canvasRef={canvasRef}
          bgPattern={state.bgPattern} bgColor={state.bgColor}
          title={state.title} subtitle={state.subtitle}
          showTitleOnCanvas={state.showTitleOnCanvas}
          onEditEdge={(id)=>setEditingEdgeId(id)}
        />
        {state.nodes.length === 0 && (
          <div className="welcome">
            <div className="card">
              <div style={{fontSize:48}}>🃏</div>
              <h1>CARD COMBO</h1>
              <div style={{color:'var(--gold)', fontSize:12, fontWeight:700, letterSpacing:'.08em', marginTop:-6, marginBottom:10, whiteSpace:'nowrap'}}>カードゲーム展開ジェネレーター</div>
              <p>カードを追加して、矢印で<br/>プレイ・召喚・効果発動の流れを可視化しよう。</p>
              <div className="row">
                <button className="btn primary" onClick={()=>addNode()}>＋ 最初のカードを追加</button>
                <button className="btn" onClick={loadDemo}>💎 サンプルを見る</button>
              </div>
            </div>
          </div>
        )}
      </div>

      <Inspector
        selection={selection} setSelection={setSelection}
        nodes={state.nodes} edges={state.edges} groups={state.groups || []}
        notes={state.notes || []}
        customRelations={state.customRelations || []}
        learnRelation={learnRelation} removeCustomRelation={removeCustomRelation}
        addNote={addNote} addNoteOnEdge={addNoteOnEdge}
        updateNote={updateNote} deleteNote={deleteNote}
        updateNode={updateNode} updateEdge={updateEdge}
        deleteNode={deleteNode} deleteEdge={deleteEdge}
        duplicateNode={duplicateNode}
        addNode={()=>addNode()} clearAll={clearAll}
        onLoadDemo={loadDemo}
        createGroupFromNode={createGroupFromNode}
        updateGroup={updateGroup} deleteGroup={deleteGroup}
        removeNodeFromGroup={removeNodeFromGroup}
        composingGroupId={composingGroupId} setComposingGroupId={setComposingGroupId}
      />

      {toast && <div className="toast">{toast}</div>}

      {shotMode && (
        <>
          <button className="shot-exit" onClick={exitShotMode} title="スクショモードを終了">✕</button>
          <div className="shot-hint" key={Date.now()}>
            📸 スクリーンショットを撮ってください —
            {navigator.platform?.includes('Mac')
              ? <><kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>4</kbd></>
              : <><kbd>Win</kbd>+<kbd>Shift</kbd>+<kbd>S</kbd></>}
          </div>
        </>
      )}
    </div>
  );
}

// ---------- demo data builder ----------
function buildDemoData(){
  // Generic card-game combo demo with a clean left-to-right narrative:
  //   hand (nA, nB) → field (nC, nD, nG) → finisher (nF) → target (nE).
  // Showcases every major feature so first-time users see what's possible:
  //   - damage / buff effects (nE / nF)
  //   - zone grouping (手札 / 場)
  //   - two-way flow (nD ⇄ nF "リンク先")
  //   - cracked style (妨害)
  //   - thick arrow (finishing attack)
  //   - branch node on nC→nD arrow that forks the effect to nG (PC-only feature)
  // Positions are spread out more than the first draft so nothing crowds.
  const nodes = [
    { id:'nA', name:'初動モンスター', subtitle:'初手 / 1 枚',   emoji:'🃏', color:'#E9C46A', shape:'square', x:140, y:320, labelPos:'right' },
    { id:'nB', name:'サーチ先',       subtitle:'サーチで加える', emoji:'➲', color:'#7FE3A9', shape:'square', x:140, y:620 },
    { id:'nC', name:'展開要員',       subtitle:'場に特殊召喚',   emoji:'✦', color:'#6DD3F0', shape:'square', x:500, y:320, labelPos:'right' },
    { id:'nD', name:'効果で呼び出し', subtitle:'場 / リンク先', emoji:'⚡', color:'#F4A261', shape:'square', x:880, y:200, labelPos:'right' },
    // New: second effect target — fed by the branch node on nC→nD.
    { id:'nG', name:'サブ効果先',     subtitle:'同時発動',       emoji:'✺', color:'#FF6B9D', shape:'square', x:880, y:460, labelPos:'right' },
    // Opponent's disruption card — damage effect showcases the burst aura.
    { id:'nE', name:'相手の妨害',     subtitle:'ハンドトラップ', emoji:'🛡', color:'#C77DFF', shape:'square', x:880, y:720, effect:'damage', labelPos:'left' },
    // Final ace, powered up — buff effect shows rising particles.
    { id:'nF', name:'最終エース',     subtitle:'フィニッシャー', emoji:'🐉', color:'#FF4545', shape:'square', x:1240, y:320, effect:'buff' },
    // Branch node attached to e3 (nC→nD). t=0.3 places it left of the arrow's
    // midpoint so it doesn't overlap the "効果発動" label which sits at t≈0.5.
    // Resolver recomputes x/y every render — stored values are only a fallback
    // if e3 gets deleted.
    { id:'nBr', variant:'branch', shape:'circle', color:'#E9C46A',
      name:'', subtitle:'効果が2方向に分岐',
      attachedEdgeId:'e3', t:0.3,
      x:615, y:285, labelPos:'top' },
  ];
  const edges = [
    { id:'e1', from:'nA',  to:'nB', relation:'search',  style:'oneway',  color:'#7FE3A9', label:'サーチ',     stamp:'➲' },
    { id:'e2', from:'nB',  to:'nC', relation:'special', style:'oneway',  color:'#6DD3F0', label:'特殊召喚',   stamp:'✦' },
    // Effect arrow — branch node (nBr) is pinned to this edge and forks to nG.
    { id:'e3', from:'nC',  to:'nD', relation:'effect',  style:'oneway',  color:'#E9C46A', label:'効果発動',   stamp:'⚡' },
    // customOffset bends the branch→nG arc so its label sits in the upper-right
    // space, away from the other arrows converging near nG.
    { id:'e3b',from:'nBr', to:'nG', relation:'effect',  style:'oneway',  color:'#FF6B9D', label:'同時に',     stamp:'✺', customOffset:-70 },
    { id:'e4', from:'nD',  to:'nF', relation:'cost',    style:'twoway',  color:'#B39DDB', label:'リンク先',   stamp:'◆' },
    { id:'e5', from:'nE',  to:'nC', relation:'destroy', style:'cracked', color:'#FF4545', label:'ケア必要',   stamp:'⚠' },
    { id:'e6', from:'nF',  to:'nE', relation:'attack',  style:'oneway',  color:'#FF4545', label:'攻撃',       stamp:'⚔', thickness:'thick' },
  ];
  const groups = [
    { id:'g1', name:'手札',    color:'#E9C46A', nodeIds:['nA','nB'],       labelOffset:{ dx:0, dy:-70 } },
    // 場 / 盤面 sits higher (nD at y=200) so pull the label closer to the
    // group blob to avoid collision with the canvas title banner.
    { id:'g2', name:'場 / 盤面', color:'#7FE3A9', nodeIds:['nC','nD','nG'], labelOffset:{ dx:0, dy:-20 } },
  ];
  const notes = [
    {
      id:'note_1', nodeId:'nA',
      text:'1枚で展開スタート。\n初手にこれを引ければ OK。',
      offset:{ dx:-200, dy:20 },
    },
    {
      id:'note_2', nodeId:'nF',
      text:'打点上昇＋耐性持ち。\n相手の妨害をケアしつつ\n最終打点を担当。',
      offset:{ dx:180, dy:-10 },
    },
    {
      id:'note_3', nodeId:'nBr',
      text:'分岐ノードで\n1 つの効果が 2 方向に作用\nする展開を可視化。',
      // Moved to upper-left of the branch node — out of the bottom-right where
      // the fixed credit / link overlay sits on wide screens.
      offset:{ dx:-200, dy:-110 },
    },
  ];
  return { nodes, edges, groups, notes };
}

window.App = App;
window.buildDemoData = buildDemoData;
