/**
 * FlowCanvas - Workflow graph canvas with nodes and connection lines.
 * Nodes have 4 connection handles (one per side), drag nodes to add, connect with arrows.
 */

import React, {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';

import { useLanguage } from '../../../providers/language/LanguageContext';
import { AiBadge } from '../nodes/shared/AiBadge';

export interface CanvasNode {
  id: string;
  type: string;
  x: number;
  y: number;
  label?: string;
  title?: string;
  comment?: string;
  color?: string;
  inputs: number;
  outputs: number;
  parameters?: Record<string, unknown>;
  inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
  outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
}

export interface CanvasConnection {
  id: string;
  sourceId: string;
  sourceHandle: number;
  targetId: string;
  targetHandle: number;
}

/** Freie Benutzer-Notiz auf der Canvas; wird nicht in den Workflow/Graph persistiert. */
export interface CanvasStickyNote {
  id: string;
  x: number;
  y: number;
  width: number;
  /** Höhe des Textbereichs unter der Toolbar (Pixel). Standard: ``STICKY_NOTE_DEFAULT_HEIGHT``. */
  height?: number;
  text: string;
  /** Farbe aus ``STICKY_NOTE_PALETTE`` (Standard: ``yellow``). */
  colorId?: string;
}

const STICKY_NOTE_DEFAULT_WIDTH = 220;
export const STICKY_NOTE_DEFAULT_HEIGHT = 96;
const STICKY_NOTE_MIN_WIDTH = 120;
const STICKY_NOTE_MIN_HEIGHT = 48;

export const STICKY_NOTE_DEFAULT_COLOR_ID = 'yellow';

/** Vorgaben für Sticky-Hintergrund/-Rand (wie klassische Haftnotizen). */
export const STICKY_NOTE_PALETTE: ReadonlyArray<{
  id: string;
  bg: string;
  border: string;
  textareaBg: string;
}> = [
  {
    id: 'yellow',
    bg: 'rgba(255, 249, 196, 0.92)',
    border: 'rgba(180, 170, 90, 0.55)',
    textareaBg: 'rgba(255, 252, 220, 0.98)',
  },
  {
    id: 'pink',
    bg: 'rgba(255, 228, 238, 0.92)',
    border: 'rgba(200, 120, 150, 0.55)',
    textareaBg: 'rgba(255, 240, 245, 0.98)',
  },
  {
    id: 'mint',
    bg: 'rgba(220, 248, 230, 0.92)',
    border: 'rgba(100, 160, 110, 0.5)',
    textareaBg: 'rgba(235, 252, 238, 0.98)',
  },
  {
    id: 'sky',
    bg: 'rgba(220, 236, 255, 0.92)',
    border: 'rgba(100, 140, 200, 0.5)',
    textareaBg: 'rgba(235, 244, 255, 0.98)',
  },
  {
    id: 'lavender',
    bg: 'rgba(235, 228, 255, 0.92)',
    border: 'rgba(140, 120, 200, 0.5)',
    textareaBg: 'rgba(245, 240, 255, 0.98)',
  },
  {
    id: 'peach',
    bg: 'rgba(255, 236, 210, 0.92)',
    border: 'rgba(200, 140, 90, 0.5)',
    textareaBg: 'rgba(255, 245, 228, 0.98)',
  },
];

export function getStickyNotePaletteEntry(colorId?: string) {
  const id = colorId ?? STICKY_NOTE_DEFAULT_COLOR_ID;
  return STICKY_NOTE_PALETTE.find((p) => p.id === id) ?? STICKY_NOTE_PALETTE[0];
}

const NODE_WIDTH = 200;
const NODE_HEIGHT = 72;

export const FLOW_CANVAS_MIN_ZOOM = 0.25;
export const FLOW_CANVAS_MAX_ZOOM = 4;

export interface FlowCanvasViewportEditState {
  zoom: number;
  selectedNodeCount: number;
  connectionSelected: boolean;
  /** Canvas-Sticky-/Kommentarnote ausgewählt (nicht Workflow-Knoten). */
  stickyNoteSelected: boolean;
}

export type FlowCanvasHandle = {
  focusCanvas: () => void;
  zoomIn: () => void;
  zoomOut: () => void;
  setZoomPercent: (percent: number) => void;
  fitWindow: () => void;
  resetView: () => void;
  deleteSelection: () => void;
  duplicateSingleSelection: () => void;
  toggleConnectionTool: () => void;
  /** Fügt eine bearbeitbare Textnotiz in der Mitte der sichtbaren Canvas ein. */
  addCanvasComment: () => void;
  /** Raster-Anordnung: verschachtelte Rangpfade (4.1 / 4.2 …); Haftnotizen unberührt. */
  arrangeNodes: () => void;
};
const HANDLE_SIZE = 12;
const HANDLE_OFFSET = HANDLE_SIZE / 2;
const LAYOUT_V_GAP = 80;
const LAYOUT_H_GAP = 60;

/** Kanten-Rücklauf visuell links um die Knoten zur Loop oben. */
function isLoopFeedbackEdge(c: CanvasConnection, srcNode: CanvasNode, tgtNode: CanvasNode): boolean {
  if (tgtNode.type !== 'flow.loop' || c.targetHandle !== 0) return false;
  if (c.sourceId === c.targetId) return true;
  return srcNode.y > tgtNode.y + 4;
}

/** Für Layout-Schichtung: Graph ohne Loop-Rückkopplung (Sonst Pflicht-Schichtung senkrecht). */
function stripLoopFeedbackConnections(nodes: CanvasNode[], connections: CanvasConnection[]): CanvasConnection[] {
  const byId = new Map(nodes.map((n) => [n.id, n]));
  return connections.filter((c) => {
    const src = byId.get(c.sourceId);
    const tgt = byId.get(c.targetId);
    if (!src || !tgt) return false;
    return !isLoopFeedbackEdge(c, src, tgt);
  });
}

/** Reihenfolge links→rechts bei Kanten nur zwischen Knoten **dieser** Zeile (DAG/Kahn). */
function orderRowByIntraRowTopo(row: CanvasNode[], connections: CanvasConnection[]): CanvasNode[] {
  const idSet = new Set(row.map((n) => n.id));
  const nodeById = new Map(row.map((n) => [n.id, n]));
  const inDeg = new Map<string, number>();
  const outs = new Map<string, string[]>();
  for (const id of idSet) {
    inDeg.set(id, 0);
    outs.set(id, []);
  }
  for (const c of connections) {
    if (!idSet.has(c.sourceId) || !idSet.has(c.targetId)) continue;
    inDeg.set(c.targetId, (inDeg.get(c.targetId) ?? 0) + 1);
    outs.get(c.sourceId)!.push(c.targetId);
  }
  const ready = row
    .filter((n) => (inDeg.get(n.id) ?? 0) === 0)
    .sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)));
  const result: CanvasNode[] = [];
  const q = [...ready];
  while (q.length > 0) {
    q.sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)));
    const n = q.shift()!;
    result.push(n);
    for (const t of outs.get(n.id) ?? []) {
      if (!idSet.has(t)) continue;
      inDeg.set(t, (inDeg.get(t) ?? 1) - 1);
      if (inDeg.get(t) === 0) q.push(nodeById.get(t)!);
    }
  }
  const placed = new Set(result.map((r) => r.id));
  const rest = row.filter((n) => !placed.has(n.id)).sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)));
  return [...result, ...rest];
}

/** Verschachtelte Rasterposition ``[4,1]``, ``[4,2]``, ``[4,1,1]`` … für Zeilen vs. Spalten. */
function linearNestedChildPath(p: number[]): number[] {
  if (p.length === 1) return [p[0] + 1];
  return [Math.max(...p) + 1];
}

/** Zweig unter einem linearen Knoten ``[n]`` → ``[n+1,k]``, unter verschachteltem Pfad → ``[…p,k]``. */
function branchNestedChildPath(p: number[], branchIndex: number): number[] {
  if (p.length === 1) return [p[0] + 1, branchIndex];
  return [...p, branchIndex];
}

function longestPathDepthDown(
  nodeId: string,
  outgoingTargets: Map<string, string[]>,
  memo: Map<string, number>,
  visiting: Set<string>,
): number {
  if (memo.has(nodeId)) return memo.get(nodeId)!;
  if (visiting.has(nodeId)) return 0;
  visiting.add(nodeId);
  const outs = outgoingTargets.get(nodeId) ?? [];
  let best = 0;
  for (const t of outs) {
    best = Math.max(best, longestPathDepthDown(t, outgoingTargets, memo, visiting));
  }
  visiting.delete(nodeId);
  const d = outs.length === 0 ? 0 : 1 + best;
  memo.set(nodeId, d);
  return d;
}

function reachableForwardTargets(start: string, outgoingTargets: Map<string, string[]>): Set<string> {
  const seen = new Set<string>();
  const stack = [start];
  while (stack.length > 0) {
    const v = stack.pop()!;
    if (seen.has(v)) continue;
    seen.add(v);
    for (const t of outgoingTargets.get(v) ?? []) stack.push(t);
  }
  return seen;
}

/** Primärbaum: längster Pfad zuerst; Kurzschlüsse (z.B. Trigger→Schleife wenn Trigger→Upload→Schleife) werden nicht verdoppelt. */
function layoutTreeChildren(
  nodeId: string,
  outgoingTargets: Map<string, string[]>,
  depthMemo: Map<string, number>,
): string[] {
  const outs = outgoingTargets.get(nodeId) ?? [];
  if (outs.length <= 1) return outs;
  const scored = outs.map((t) => ({
    t,
    d: longestPathDepthDown(t, outgoingTargets, depthMemo, new Set()),
  }));
  scored.sort((a, b) => b.d - a.d || a.t.localeCompare(b.t));
  const primary = scored[0].t;
  const reach = reachableForwardTargets(primary, outgoingTargets);
  const branchOnly = outs.filter((t) => t !== primary && !reach.has(t));
  branchOnly.sort((a, b) => a.localeCompare(b));
  return [primary, ...branchOnly];
}

function assignNestedRankPaths(nodes: CanvasNode[], stripped: CanvasConnection[]): Map<string, number[]> {
  const nodeIds = new Set(nodes.map((n) => n.id));
  const outgoingTargets = new Map<string, string[]>();
  for (const n of nodes) outgoingTargets.set(n.id, []);
  const sortedStripped = [...stripped].sort(
    (a, b) =>
      a.sourceId.localeCompare(b.sourceId) ||
      a.sourceHandle - b.sourceHandle ||
      a.targetId.localeCompare(b.targetId),
  );
  for (const c of sortedStripped) {
    if (!nodeIds.has(c.sourceId) || !nodeIds.has(c.targetId)) continue;
    const arr = outgoingTargets.get(c.sourceId)!;
    if (!arr.includes(c.targetId)) arr.push(c.targetId);
  }

  const ranks = new Map<string, number[]>();
  const depthMemo = new Map<string, number>();
  let nextFallback = 1;

  function dfs(nodeId: string, path: number[]): void {
    if (ranks.has(nodeId)) return;
    ranks.set(nodeId, path);
    const children = layoutTreeChildren(nodeId, outgoingTargets, depthMemo);
    if (children.length === 0) return;
    if (children.length === 1) {
      dfs(children[0], linearNestedChildPath(path));
      return;
    }
    children.forEach((ch, idx) => {
      dfs(ch, branchNestedChildPath(path, idx + 1));
    });
  }

  const roots = nodes.filter((n) => !stripped.some((c) => c.targetId === n.id));
  roots.sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)));

  if (roots.length === 0) {
    const first = [...nodes].sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)))[0];
    if (first) dfs(first.id, [1]);
  } else {
    let seq = 1;
    for (const r of roots) dfs(r.id, [seq++]);
  }

  for (const n of nodes) {
    if (!ranks.has(n.id)) dfs(n.id, [nextFallback++]);
  }

  return ranks;
}

function nestedRankRowGroupKey(path: number[]): string {
  if (path.length <= 1) return `|L|${path.join('.')}`;
  return `|B|${path.slice(0, -1).join('.')}`;
}

function compareNestedRankPathLex(a: number[], b: number[]): number {
  const n = Math.max(a.length, b.length);
  for (let i = 0; i < n; i++) {
    const av = a[i];
    const bv = b[i];
    if (av === undefined && bv === undefined) return 0;
    if (av === undefined) return -1;
    if (bv === undefined) return 1;
    if (av !== bv) return av - bv;
  }
  return 0;
}

function minNestedRankPath(paths: number[][]): number[] {
  return paths.reduce((m, p) => (compareNestedRankPathLex(p, m) < 0 ? p : m));
}

/** Join-Knoten mit mehreren Vorgängern: einheitliche Zeilen-Stufe ``[max(pred)+1]``. */
function refineConvergenceNestedRanks(nodes: CanvasNode[], stripped: CanvasConnection[], ranks: Map<string, number[]>): void {
  const preds = new Map<string, string[]>();
  for (const n of nodes) preds.set(n.id, []);
  for (const c of stripped) {
    preds.get(c.targetId)!.push(c.sourceId);
  }
  const order = topologicalLayersIds(nodes, stripped).flat();
  for (const id of order) {
    const ps = preds.get(id) ?? [];
    if (ps.length <= 1) continue;
    let best = 0;
    for (const p of ps) {
      const rp = ranks.get(p);
      if (!rp || rp.length === 0) continue;
      best = Math.max(best, Math.max(...rp));
    }
    ranks.set(id, [best + 1]);
  }
}

/**
 * Topologische Schichten (Kahn): chronologisch von Quellen zu Senken.
 * Zyklen/notwendige Restknoten jeweils eigene Zeile wie bei klassischem Sugiyama-Setup.
 */
function topologicalLayersIds(nodes: CanvasNode[], connections: CanvasConnection[]): string[][] {
  if (nodes.length === 0) return [];

  const inDegree = new Map<string, number>();
  const children = new Map<string, string[]>();
  for (const n of nodes) {
    inDegree.set(n.id, 0);
    children.set(n.id, []);
  }
  for (const c of connections) {
    if (!inDegree.has(c.sourceId) || !inDegree.has(c.targetId)) continue;
    inDegree.set(c.targetId, (inDegree.get(c.targetId) ?? 0) + 1);
    children.get(c.sourceId)?.push(c.targetId);
  }

  const layers: string[][] = [];
  const layerOf = new Map<string, number>();
  const queue: string[] = [];
  for (const n of nodes) {
    if ((inDegree.get(n.id) ?? 0) === 0) queue.push(n.id);
  }

  while (queue.length > 0) {
    const batch: string[] = [...queue];
    queue.length = 0;
    const layerIdx = layers.length;
    layers.push(batch);
    for (const id of batch) {
      layerOf.set(id, layerIdx);
      for (const childId of children.get(id) ?? []) {
        const deg = (inDegree.get(childId) ?? 1) - 1;
        inDegree.set(childId, deg);
        if (deg === 0) queue.push(childId);
      }
    }
  }

  const placed = new Set(layerOf.keys());
  for (const n of nodes) {
    if (!placed.has(n.id)) {
      layers.push([n.id]);
      layerOf.set(n.id, layers.length - 1);
    }
  }

  return layers;
}

/**
 * Topological-sort based auto-layout: arranges nodes top-to-bottom in layers.
 * Disconnected nodes are appended as extra roots.
 */
export function computeAutoLayout(
  nodes: CanvasNode[],
  connections: CanvasConnection[],
): CanvasNode[] {
  if (nodes.length === 0) return nodes;

  const layers = topologicalLayersIds(nodes, connections);
  for (const layer of layers) {
    layer.sort((a, b) => a.localeCompare(b));
  }

  const layerOf = new Map<string, number>();
  layers.forEach((layer, li) => layer.forEach((id) => layerOf.set(id, li)));

  const startX = 40;
  const startY = 40;

  return nodes.map((n) => {
    const layer = layerOf.get(n.id) ?? 0;
    const siblings = layers[layer];
    const idxInLayer = siblings.indexOf(n.id);
    return {
      ...n,
      x: startX + idxInLayer * (NODE_WIDTH + LAYOUT_H_GAP),
      y: startY + layer * (NODE_HEIGHT + LAYOUT_V_GAP),
    };
  });
}

/**
 * Raster-Anordnung über **verschachtelte Rangpfade** (z.B. ``4.1`` und ``4.2`` dieselbe Zeile):
 * DFS auf einem Primärbaum (längster Pfad zuerst, ohne Loop-Rückkopplung für die Schichtung).
 * Ein-Stufen-Pfade ``[1],[2],[3]`` jeweils eigene Zeile; gemeinsamer Präfix bei Zweigen → gemeinsame Rasterzeile.
 * Zeilen untereinander unter der Mitte der darüberliegenden Zeile zentriert.
 */
export function computeGridTidyLayout(nodes: CanvasNode[], connections: CanvasConnection[]): CanvasNode[] {
  if (nodes.length === 0) return nodes;

  const stripped = stripLoopFeedbackConnections(nodes, connections);
  const ranks = assignNestedRankPaths(nodes, stripped);
  refineConvergenceNestedRanks(nodes, stripped, ranks);

  const rowBuckets = new Map<string, CanvasNode[]>();
  for (const n of nodes) {
    const path = ranks.get(n.id);
    if (!path) continue;
    const key = nestedRankRowGroupKey(path);
    if (!rowBuckets.has(key)) rowBuckets.set(key, []);
    rowBuckets.get(key)!.push(n);
  }

  const rowEntries = [...rowBuckets.values()].map((members) => {
    const paths = members.map((m) => ranks.get(m.id)!);
    return {
      members,
      rowOrderKey: minNestedRankPath(paths),
    };
  });

  rowEntries.sort((a, b) => compareNestedRankPathLex(a.rowOrderKey, b.rowOrderKey));

  const rows = rowEntries.map((e) =>
    orderRowByIntraRowTopo(
      [...e.members].sort((na, nb) =>
        compareNestedRankPathLex(ranks.get(na.id)!, ranks.get(nb.id)!),
      ),
      connections,
    ),
  );

  const rowSpanX = (count: number) =>
    count <= 0 ? 0 : count * NODE_WIDTH + (count - 1) * LAYOUT_H_GAP;

  const startX = 40;
  const startY = 40;
  const out = new Map<string, CanvasNode>();

  let prevLeft = startX;
  let prevCount = rows[0]?.length ?? 0;

  rows.forEach((r, ri) => {
    const nInRow = r.length;
    let left: number;
    if (ri === 0) {
      left = startX;
    } else {
      const centerAbove = prevLeft + rowSpanX(prevCount) / 2;
      left = centerAbove - rowSpanX(nInRow) / 2;
      if (left < 8) left = 8;
    }

    const y = startY + ri * (NODE_HEIGHT + LAYOUT_V_GAP);
    r.forEach((n, ci) => {
      out.set(n.id, {
        ...n,
        x: left + ci * (NODE_WIDTH + LAYOUT_H_GAP),
        y,
      });
    });

    prevLeft = left;
    prevCount = nInRow;
  });

  return nodes.map((n) => out.get(n.id) ?? n);
}

function _outputSchemaName(schema: string | GraphDefinedSchemaRef | undefined): string {
  if (typeof schema === 'string') return schema;
  if (schema && typeof schema === 'object' && schema.kind === 'fromGraph') return 'FormPayload';
  return '';
}

/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
function _checkConnectionCompatibility(
  sourceNode: CanvasNode,
  sourceOutputIdx: number,
  targetNode: CanvasNode,
  targetInputIdx: number,
  nodeTypes: NodeType[],
): 'ok' | 'warning' {
  const srcType = nodeTypes.find((nt) => nt.id === sourceNode.type);
  const tgtType = nodeTypes.find((nt) => nt.id === targetNode.type);
  if (!srcType?.outputPorts || !tgtType?.inputPorts) return 'ok';

  const srcPort = srcType.outputPorts[sourceOutputIdx];
  const tgtPort = tgtType.inputPorts[targetInputIdx];
  if (!srcPort || !tgtPort) return 'ok';

  const srcSchema = _outputSchemaName(srcPort.schema as string | GraphDefinedSchemaRef);
  const accepts = tgtPort.accepts;
  if (!accepts || accepts.length === 0) return 'ok';
  if (accepts.includes('Transit')) return 'ok';
  if (srcSchema && accepts.includes(srcSchema)) return 'ok';
  if (srcSchema?.startsWith('FormPayload') && accepts.includes('FormPayload')) return 'ok';
  return 'warning';
}

/** flow.loop Eingang 0: Hauptfluss + Schleifen-Rücklauf — mehrere Kanten pro Port. */
function allowsMultipleInboundOnInputPort(targetNode: CanvasNode, targetHandleIndex: number): boolean {
  return targetNode.type === 'flow.loop' && targetHandleIndex === 0;
}

const NODE_OBSTACLE_PAD = 12;

type Obstacle = { left: number; top: number; right: number; bottom: number };

function obstacleRects(allNodes: CanvasNode[], skipIds: Set<string>, pad: number): Obstacle[] {
  return allNodes
    .filter((n) => !skipIds.has(n.id))
    .map((n) => ({
      left: n.x - pad,
      top: n.y - pad,
      right: n.x + NODE_WIDTH + pad,
      bottom: n.y + NODE_HEIGHT + pad,
    }));
}

function pointInObstacle(x: number, y: number, o: Obstacle): boolean {
  return x >= o.left && x <= o.right && y >= o.top && y <= o.bottom;
}

function cubicCrossesObstacles(
  x0: number,
  y0: number,
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  x3: number,
  y3: number,
  obstacles: Obstacle[],
  tMargin = 0.08,
): boolean {
  const steps = 40;
  for (let i = 1; i < steps; i++) {
    const t = i / steps;
    if (t < tMargin || t > 1 - tMargin) continue;
    const u = 1 - t;
    const x = u * u * u * x0 + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3;
    const y = u * u * u * y0 + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3;
    for (const o of obstacles) {
      if (pointInObstacle(x, y, o)) return true;
    }
  }
  return false;
}

/**
 * Schleifen-Rücklauf — zwei Kubiken, C¹-stetig:
 *
 *   C1: M sx sy  C sx (sy+k)   laneX (sy+k)   laneX jY
 *   C2:          C laneX (tyIn-k)  tx (tyIn-k)  tx tyIn
 *
 * Tangente am Start = (0,+k) → senkrecht RUNTER aus dem Quell-Port  ✓
 * Tangente am Ende  = (0,+k) → senkrecht RUNTER in den Ziel-Port    ✓
 * Tangente an der Verbindungsstelle (laneX, jY): beide Seiten = (0, (tyIn-sy)/2-k) — gleich → kein Knick ✓
 * laneX wird per Sampling solange nach links verschoben, bis keine Kollision vorliegt.
 */
function feedbackConnectionPathD(
  src: { x: number; y: number },
  tgt: { x: number; y: number },
  srcNode: CanvasNode,
  tgtNode: CanvasNode,
  allNodes: CanvasNode[],
): string {
  const sx = src.x;
  const sy = src.y;
  const tx = tgt.x;
  const tyIn = tgt.y - HANDLE_OFFSET;

  const minNx = allNodes.length
    ? Math.min(...allNodes.map((n) => n.x))
    : Math.min(srcNode.x, tgtNode.x);

  const vert = Math.max(60, sy - tyIn);
  const k = Math.min(vert * 0.38, 130);
  const jY = (sy + tyIn) / 2;

  const skipIds = srcNode.id === tgtNode.id ? new Set([srcNode.id]) : new Set<string>();
  const obstacles = obstacleRects(allNodes, skipIds, NODE_OBSTACLE_PAD);

  for (let margin = 72; margin <= 640; margin += 24) {
    const laneX = Math.min(minNx - margin, Math.min(sx, tx) - margin);
    const ok =
      !cubicCrossesObstacles(sx, sy, sx, sy + k, laneX, sy + k, laneX, jY, obstacles) &&
      !cubicCrossesObstacles(laneX, jY, laneX, tyIn - k, tx, tyIn - k, tx, tyIn, obstacles);
    if (ok) {
      return (
        `M ${sx} ${sy}` +
        ` C ${sx} ${sy + k} ${laneX} ${sy + k} ${laneX} ${jY}` +
        ` C ${laneX} ${tyIn - k} ${tx} ${tyIn - k} ${tx} ${tyIn}`
      );
    }
  }
  const laneX = Math.min(minNx - 640, Math.min(sx, tx) - 640);
  return (
    `M ${sx} ${sy}` +
    ` C ${sx} ${sy + k} ${laneX} ${sy + k} ${laneX} ${jY}` +
    ` C ${laneX} ${tyIn - k} ${tx} ${tyIn - k} ${tx} ${tyIn}`
  );
}

function connectionPathD(
  src: { x: number; y: number },
  tgt: { x: number; y: number },
  srcNode: CanvasNode,
  tgtNode: CanvasNode,
  feedback: boolean,
  allNodes: CanvasNode[],
): string {
  if (!feedback) {
    const dy = tgt.y - src.y;
    return `M ${src.x} ${src.y} C ${src.x} ${src.y + Math.abs(dy) / 2}, ${tgt.x} ${tgt.y - Math.abs(dy) / 2}, ${tgt.x} ${tgt.y}`;
  }
  return feedbackConnectionPathD(src, tgt, srcNode, tgtNode, allNodes);
}

interface FlowCanvasProps {
  nodes: CanvasNode[];
  connections: CanvasConnection[];
  nodeTypes: NodeType[];
  onNodesChange: (nodes: CanvasNode[]) => void;
  onConnectionsChange: (connections: CanvasConnection[]) => void;
  onDropNodeType: (nodeTypeId: string, x: number, y: number) => void;
  getLabel: (node: CanvasNode) => string;
  getCategoryIcon: (category: string) => React.ReactNode;
  onSelectionChange?: (node: CanvasNode | null) => void;
  highlightedNodeIds?: Record<string, string>;
  /** Phase-4: per-node "required-but-unbound" param errors. The canvas renders
   *  a red error badge in the top-right of each node whose id is a key. */
  nodeErrors?: Record<string, Array<{ paramName: string; paramLabel: string }>>;
  /** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt
   * (z. B. ``application/json+workflow`` aus der UDB-FilesTab),
   * wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen.
   * Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */
  onExternalDrop?: (mime: string, payload: unknown) => Promise<boolean> | boolean;
  onViewportEditState?: (state: FlowCanvasViewportEditState) => void;
  /** Nach diskreten Canvas-Aktionen (Drop, Drag-Ende, Kante, Löschen …) für Undo. */
  onHistoryCheckpoint?: () => void;
  onConnectionToolActiveChange?: (active: boolean) => void;
  /** Nur Anzeige: Benutzer-Kommentare auf der Fläche (ohne Workflow-Daten). */
  stickyNotes?: CanvasStickyNote[];
  onStickyNotesChange?: (notes: CanvasStickyNote[]) => void;
}

const HIGHLIGHT_COLORS: Record<string, string> = {
  running: '#f0ad4e',
  completed: '#28a745',
  failed: '#dc3545',
  skipped: '#6c757d',
};

export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function FlowCanvas(
  {
    nodes,
    connections,
    nodeTypes,
    onNodesChange,
    onConnectionsChange,
    onDropNodeType,
    getLabel,
    getCategoryIcon,
    onSelectionChange,
    highlightedNodeIds,
    nodeErrors,
    onExternalDrop,
    onViewportEditState,
    onHistoryCheckpoint,
    onConnectionToolActiveChange,
    stickyNotes = [],
    onStickyNotesChange,
  },
  ref
) {
  const { t } = useLanguage();
  const tRef = useRef(t);
  tRef.current = t;
  const containerRef = useRef<HTMLDivElement>(null);
  const stickyNotesRef = useRef(stickyNotes);
  stickyNotesRef.current = stickyNotes;
  const onStickyNotesChangeRef = useRef(onStickyNotesChange);
  onStickyNotesChangeRef.current = onStickyNotesChange;
  const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
  const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
  const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
  const [connectionWarnings, setConnectionWarnings] = useState<Record<string, boolean>>({});
  const [selectionBox, setSelectionBox] = useState<{
    startX: number;
    startY: number;
    endX: number;
    endY: number;
  } | null>(null);
  const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
  const [editingField, setEditingField] = useState<'title' | null>(null);
  const [connectingFrom, setConnectingFrom] = useState<{
    nodeId: string;
    handleIndex: number;
    x: number;
    y: number;
  } | null>(null);
  const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
  const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null);
  const [dragOffset, setDragOffset] = useState({
    startClientX: 0,
    startClientY: 0,
    nodesInitial: {} as Record<string, { x: number; y: number }>,
  });
  const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
  const [zoom, setZoom] = useState(1);
  const panZoomRef = useRef({ x: 0, y: 0, zoom: 1 });
  panZoomRef.current = { x: panOffset.x, y: panOffset.y, zoom };
  const [connectionToolActive, setConnectionToolActive] = useState(false);
  const [pendingConnClickSource, setPendingConnClickSource] = useState<{
    nodeId: string;
    handleIndex: number;
  } | null>(null);
  const [panning, setPanning] = useState<{
    startX: number;
    startY: number;
    startPanX: number;
    startPanY: number;
  } | null>(null);

  const [editingStickyId, setEditingStickyId] = useState<string | null>(null);
  const [stickyFocusSelectAll, setStickyFocusSelectAll] = useState(false);
  const stickyTextareaRef = useRef<HTMLTextAreaElement | null>(null);
  const [stickyDragState, setStickyDragState] = useState<{
    id: string;
    startClientX: number;
    startClientY: number;
    noteInitial: { x: number; y: number };
  } | null>(null);
  const [stickyResizeState, setStickyResizeState] = useState<{
    id: string;
    startClientX: number;
    startClientY: number;
    startWidth: number;
    startHeight: number;
  } | null>(null);
  const [selectedStickyId, setSelectedStickyId] = useState<string | null>(null);

  useEffect(() => {
    if (selectedStickyId && !stickyNotes.some((s) => s.id === selectedStickyId)) {
      setSelectedStickyId(null);
    }
  }, [stickyNotes, selectedStickyId]);

  const nodeTypeMap = useMemo(() => {
    const m: Record<string, NodeType> = {};
    nodeTypes.forEach((nt) => {
      m[nt.id] = nt;
    });
    return m;
  }, [nodeTypes]);

  const onHistoryCheckpointRef = useRef(onHistoryCheckpoint);
  onHistoryCheckpointRef.current = onHistoryCheckpoint;

  const emitHistoryCheckpoint = useCallback(() => {
    onHistoryCheckpointRef.current?.();
  }, []);

  useEffect(() => {
    onViewportEditState?.({
      zoom,
      selectedNodeCount: selectedNodeIds.size,
      connectionSelected: !!selectedConnectionId,
      stickyNoteSelected: !!selectedStickyId,
    });
  }, [zoom, selectedNodeIds, selectedConnectionId, selectedStickyId, onViewportEditState]);

  useEffect(() => {
    onConnectionToolActiveChange?.(connectionToolActive);
  }, [connectionToolActive, onConnectionToolActiveChange]);

  useImperativeHandle(
    ref,
    () => ({
      focusCanvas: () => {
        containerRef.current?.focus();
      },
      zoomIn: () => {
        setZoom((z) =>
          Math.min(FLOW_CANVAS_MAX_ZOOM, Math.round((z * 1.1 + Number.EPSILON) * 1000) / 1000)
        );
      },
      zoomOut: () => {
        setZoom((z) =>
          Math.max(FLOW_CANVAS_MIN_ZOOM, Math.round((z / 1.1 + Number.EPSILON) * 1000) / 1000)
        );
      },
      setZoomPercent: (percent: number) => {
        const p = Math.min(400, Math.max(25, Number.isFinite(percent) ? percent : 100));
        setZoom(p / 100);
      },
      fitWindow: () => {
        const el = containerRef.current;
        if (!el) return;
        const rect = el.getBoundingClientRect();
        const cw = rect.width;
        const ch = rect.height;
        if (nodes.length === 0) {
          setZoom(1);
          setPanOffset({ x: 0, y: 0 });
          return;
        }
        let minX = Infinity;
        let minY = Infinity;
        let maxX = -Infinity;
        let maxY = -Infinity;
        for (const n of nodes) {
          minX = Math.min(minX, n.x);
          minY = Math.min(minY, n.y);
          maxX = Math.max(maxX, n.x + NODE_WIDTH);
          maxY = Math.max(maxY, n.y + NODE_HEIGHT);
        }
        const pad = 48;
        const bw = Math.max(maxX - minX, 1);
        const bh = Math.max(maxY - minY, 1);
        const scale = Math.min((cw - 2 * pad) / bw, (ch - 2 * pad) / bh);
        const newZoom = Math.min(FLOW_CANVAS_MAX_ZOOM, Math.max(FLOW_CANVAS_MIN_ZOOM, scale));
        const cx = (minX + maxX) / 2;
        const cy = (minY + maxY) / 2;
        setZoom(newZoom);
        setPanOffset({
          x: cw / 2 - cx * newZoom,
          y: ch / 2 - cy * newZoom,
        });
      },
      resetView: () => {
        setZoom(1);
        setPanOffset({ x: 0, y: 0 });
      },
      deleteSelection: () => {
        if (selectedConnectionId) {
          onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId));
          setSelectedConnectionId(null);
          emitHistoryCheckpoint();
          return;
        }
        if (selectedNodeIds.size > 0) {
          const ids = selectedNodeIds;
          onNodesChange(nodes.filter((n) => !ids.has(n.id)));
          onConnectionsChange(
            connections.filter((c) => !ids.has(c.sourceId) && !ids.has(c.targetId))
          );
          setSelectedNodeIds(new Set());
          setEditingNodeId(null);
          setEditingField(null);
          setSelectedStickyId(null);
          emitHistoryCheckpoint();
          return;
        }
        const changeSticky = onStickyNotesChangeRef.current;
        const sid = selectedStickyId;
        if (sid && changeSticky) {
          changeSticky(stickyNotesRef.current.filter((s) => s.id !== sid));
          setSelectedStickyId(null);
          setEditingStickyId(null);
          emitHistoryCheckpoint();
        }
      },
      duplicateSingleSelection: () => {
        if (selectedNodeIds.size !== 1) return;
        const id = [...selectedNodeIds][0];
        const node = nodes.find((n) => n.id === id);
        if (!node) return;
        const newId = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
        const clone: CanvasNode = {
          ...node,
          id: newId,
          x: node.x + 40,
          y: node.y + 40,
          parameters: node.parameters ? { ...node.parameters } : {},
        };
        onNodesChange([...nodes, clone]);
        setSelectedNodeIds(new Set([newId]));
        setSelectedStickyId(null);
        emitHistoryCheckpoint();
      },
      addCanvasComment: () => {
        const change = onStickyNotesChangeRef.current;
        if (!change) return;
        const el = containerRef.current;
        if (!el) return;
        const { x: panX, y: panY, zoom: z } = panZoomRef.current;
        const rect = el.getBoundingClientRect();
        const cx = rect.width / 2;
        const cy = rect.height / 2;
        const w = STICKY_NOTE_DEFAULT_WIDTH;
        const canvasX = (cx - panX) / z - w / 2;
        const canvasY = (cy - panY) / z - 32;
        const id = `sn_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
        const text = tRef.current('Kommentar eingeben …');
        const note: CanvasStickyNote = {
          id,
          x: Math.max(8, canvasX),
          y: Math.max(8, canvasY),
          width: w,
          height: STICKY_NOTE_DEFAULT_HEIGHT,
          text,
          colorId: STICKY_NOTE_DEFAULT_COLOR_ID,
        };
        change([...stickyNotesRef.current, note]);
        setEditingStickyId(id);
        setSelectedStickyId(id);
        setStickyFocusSelectAll(true);
      },
      arrangeNodes: () => {
        if (nodes.length === 0) return;
        onNodesChange(computeGridTidyLayout(nodes, connections));
        emitHistoryCheckpoint();
      },
      toggleConnectionTool: () => {
        setConnectionToolActive((p) => !p);
        setPendingConnClickSource(null);
        setConnectingFrom(null);
        setDragPos(null);
      },
    }),
    [
      connections,
      emitHistoryCheckpoint,
      nodes,
      onConnectionsChange,
      onNodesChange,
      selectedConnectionId,
      selectedNodeIds,
      selectedStickyId,
    ]
  );

  useEffect(() => {
    if (onSelectionChange) {
      const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
      onSelectionChange(node);
    }
  }, [selectedNodeId, nodes, onSelectionChange]);

  const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => {
    e.stopPropagation();
    setSelectedConnectionId(connId);
    setSelectedNodeIds(new Set());
    setSelectedStickyId(null);
  }, []);

  const handleDeleteConnection = useCallback(() => {
    if (!selectedConnectionId) return;
    onConnectionsChange(connections.filter((c) => c.id !== selectedConnectionId));
    setSelectedConnectionId(null);
    emitHistoryCheckpoint();
  }, [selectedConnectionId, connections, onConnectionsChange, emitHistoryCheckpoint]);

  const getHandlePosition = useCallback(
    (node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => {
      const isOutput = handleIndex >= node.inputs;
      const ioIndex = isOutput ? handleIndex - node.inputs : handleIndex;
      const ioCount = isOutput ? node.outputs : node.inputs;

      const w = NODE_WIDTH;
      const h = NODE_HEIGHT;
      const centerX = node.x + w / 2;

      if (isOutput) {
        if (ioCount === 1) return { x: centerX, y: node.y + h + HANDLE_OFFSET, side: 'bottom' };
        const step = w / (ioCount + 1);
        return { x: node.x + step * (ioIndex + 1), y: node.y + h + HANDLE_OFFSET, side: 'bottom' };
      } else {
        if (ioCount === 1) return { x: centerX, y: node.y, side: 'top' };
        const step = w / (ioCount + 1);
        return { x: node.x + step * (ioIndex + 1), y: node.y, side: 'top' };
      }
    },
    []
  );

  const getUsedTargetHandles = useMemo(() => {
    const used = new Set<string>();
    connections.forEach((c) => used.add(`${c.targetId}-${c.targetHandle}`));
    return used;
  }, [connections]);

  /** Mehrere Kanten auf denselben Eingang: leicht versetzte Ziel-X für sichtbare, getrennte Enden. */
  const inboundStacksByTarget = useMemo(() => {
    const m = new Map<string, CanvasConnection[]>();
    for (const c of connections) {
      const key = `${c.targetId}-${c.targetHandle}`;
      const list = m.get(key);
      if (list) list.push(c);
      else m.set(key, [c]);
    }
    for (const list of m.values()) {
      list.sort((a, b) => a.id.localeCompare(b.id));
    }
    return m;
  }, [connections]);

  const handleDrop = useCallback(
    async (e: React.DragEvent) => {
      e.preventDefault();
      // 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
      if (onExternalDrop) {
        const reservedMimes = new Set([
          'application/json',
          'application/tree-items',
          'application/group-file-ids',
          'application/file-id',
          'application/file-ids',
          'application/group-id',
        ]);
        for (const mime of Array.from(e.dataTransfer.types)) {
          if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue;
          const raw = e.dataTransfer.getData(mime);
          if (!raw) continue;
          try {
            const payload = JSON.parse(raw);
            const handled = await onExternalDrop(mime, payload);
            if (handled) return;
          } catch {
            // andere Drag-Source → ignorieren, Standard-Pfad versuchen
          }
        }
      }
      // 2) Standard: Node-Type aus der NodeSidebar
      const raw = e.dataTransfer.getData('application/json');
      if (!raw || !containerRef.current) return;
      try {
        const { type } = JSON.parse(raw);
        const el = containerRef.current;
        const rect = el.getBoundingClientRect();
        const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
        const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
        onDropNodeType(type, Math.max(0, x), Math.max(0, y));
        emitHistoryCheckpoint();
      } catch (_) {}
    },
    [onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
  );

  const handleHandleMouseDown = useCallback(
    (e: React.MouseEvent, nodeId: string, handleIndex: number, isOutput: boolean) => {
      e.stopPropagation();
      if (!isOutput) return;
      if (connectionToolActive) return;
      const node = nodes.find((n) => n.id === nodeId);
      if (!node) return;
      const pos = getHandlePosition(node, handleIndex);
      setConnectingFrom({ nodeId, handleIndex, x: pos.x, y: pos.y });
      setDragPos({ x: e.clientX, y: e.clientY });
    },
    [nodes, getHandlePosition, connectionToolActive]
  );

  const handleHandleMouseUp = useCallback(
    (e: React.MouseEvent, targetNodeId: string, targetHandleIndex: number) => {
      e.stopPropagation();
      const targetNode = nodes.find((n) => n.id === targetNodeId);
      if (!targetNode || targetHandleIndex >= targetNode.inputs) return;

      if (selectedConnectionId) {
        const sel = connections.find((c) => c.id === selectedConnectionId);
        if (sel) {
          const key = `${targetNodeId}-${targetHandleIndex}`;
          const currentTargetKey = `${sel.targetId}-${sel.targetHandle}`;
          if (key === currentTargetKey) {
            setSelectedConnectionId(null);
            return;
          }
          if (
            getUsedTargetHandles.has(key) &&
            !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex)
          ) {
            setSelectedConnectionId(null);
            return;
          }
          onConnectionsChange(
            connections.map((c) =>
              c.id === selectedConnectionId
                ? { ...c, targetId: targetNodeId, targetHandle: targetHandleIndex }
                : c
            )
          );
          setSelectedConnectionId(null);
          emitHistoryCheckpoint();
        }
        return;
      }

      const effectiveSource =
        connectionToolActive && pendingConnClickSource
          ? pendingConnClickSource
          : connectingFrom
            ? { nodeId: connectingFrom.nodeId, handleIndex: connectingFrom.handleIndex }
            : null;

      const allowLoopSelfFeedback =
        !!effectiveSource &&
        targetNode.type === 'flow.loop' &&
        targetHandleIndex === 0 &&
        effectiveSource.handleIndex >= targetNode.inputs;
      if (
        !effectiveSource ||
        (effectiveSource.nodeId === targetNodeId && !allowLoopSelfFeedback)
      ) {
        setConnectingFrom(null);
        setDragPos(null);
        return;
      }
      const key = `${targetNodeId}-${targetHandleIndex}`;
      if (
        getUsedTargetHandles.has(key) &&
        !allowsMultipleInboundOnInputPort(targetNode, targetHandleIndex)
      ) {
        setConnectingFrom(null);
        setDragPos(null);
        setPendingConnClickSource(null);
        return;
      }
      const newConn: CanvasConnection = {
        id: `c_${Date.now()}`,
        sourceId: effectiveSource.nodeId,
        sourceHandle: effectiveSource.handleIndex,
        targetId: targetNodeId,
        targetHandle: targetHandleIndex,
      };

      const srcNode = nodes.find((n) => n.id === effectiveSource.nodeId);
      const tgtNode = nodes.find((n) => n.id === targetNodeId);
      if (srcNode && tgtNode) {
        const sourceOutputIdx =
          effectiveSource.handleIndex >= srcNode.inputs
            ? effectiveSource.handleIndex - srcNode.inputs
            : 0;
        const compat = _checkConnectionCompatibility(
          srcNode,
          sourceOutputIdx,
          tgtNode,
          targetHandleIndex,
          nodeTypes
        );
        if (compat === 'warning') {
          setConnectionWarnings((prev) => ({ ...prev, [newConn.id]: true }));
        }
      }

      onConnectionsChange([...connections, newConn]);
      setConnectingFrom(null);
      setDragPos(null);
      setPendingConnClickSource(null);
      emitHistoryCheckpoint();
    },
    [
      connectingFrom,
      connectionToolActive,
      pendingConnClickSource,
      connections,
      nodes,
      getUsedTargetHandles,
      onConnectionsChange,
      selectedConnectionId,
      nodeTypes,
      emitHistoryCheckpoint,
    ]
  );

  React.useEffect(() => {
    if (!connectingFrom || !dragPos) return;
    const onMove = (e: MouseEvent) => setDragPos({ x: e.clientX, y: e.clientY });
    const onUp = (e: MouseEvent) => {
      const target = e.target as HTMLElement;
      if (target.closest(`.${styles.handleOutput}`)) {
        return;
      }
      if (target.closest(`.${styles.handleInput}`)) {
        return;
      }
      setConnectingFrom(null);
      setDragPos(null);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    return () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
  }, [connectingFrom, dragPos]);

  const handleNodeMouseDown = useCallback(
    (e: React.MouseEvent, nodeId: string) => {
      const node = nodes.find((n) => n.id === nodeId);
      if (!node) return;

      const idsToMove = selectedNodeIds.has(nodeId)
        ? selectedNodeIds
        : new Set([nodeId]);
      if (!selectedNodeIds.has(nodeId)) {
        setSelectedNodeIds(idsToMove);
      }

      const nodesInitial: Record<string, { x: number; y: number }> = {};
      nodes.forEach((n) => {
        if (idsToMove.has(n.id)) nodesInitial[n.id] = { x: n.x, y: n.y };
      });

      setDraggingNodeId(nodeId);
      setDragOffset({
        startClientX: e.clientX,
        startClientY: e.clientY,
        nodesInitial,
      });
    },
    [nodes, selectedNodeIds]
  );

  React.useEffect(() => {
    if (!draggingNodeId) return;
    const onMove = (e: MouseEvent) => {
      const dx = (e.clientX - dragOffset.startClientX) / zoom;
      const dy = (e.clientY - dragOffset.startClientY) / zoom;
      onNodesChange(
        nodes.map((n) => {
          const init = dragOffset.nodesInitial[n.id];
          if (!init) return n;
          return { ...n, x: init.x + dx, y: init.y + dy };
        })
      );
    };
    const onUp = () => {
      setDraggingNodeId(null);
      emitHistoryCheckpoint();
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    return () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
  }, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom, emitHistoryCheckpoint]);

  React.useEffect(() => {
    if (!stickyDragState) return;
    const drag = stickyDragState;
    const onMove = (e: MouseEvent) => {
      const dx = (e.clientX - drag.startClientX) / zoom;
      const dy = (e.clientY - drag.startClientY) / zoom;
      const change = onStickyNotesChangeRef.current;
      const notes = stickyNotesRef.current;
      if (!change) return;
      change(
        notes.map((s) =>
          s.id === drag.id
            ? { ...s, x: drag.noteInitial.x + dx, y: drag.noteInitial.y + dy }
            : s
        )
      );
    };
    const onUp = () => setStickyDragState(null);
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    return () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
  }, [stickyDragState, zoom]);

  React.useEffect(() => {
    if (!stickyResizeState) return;
    const r = stickyResizeState;
    const onMove = (e: MouseEvent) => {
      const dx = (e.clientX - r.startClientX) / zoom;
      const dy = (e.clientY - r.startClientY) / zoom;
      const change = onStickyNotesChangeRef.current;
      const notes = stickyNotesRef.current;
      if (!change) return;
      const nextW = Math.max(STICKY_NOTE_MIN_WIDTH, r.startWidth + dx);
      const nextH = Math.max(STICKY_NOTE_MIN_HEIGHT, r.startHeight + dy);
      change(notes.map((s) => (s.id === r.id ? { ...s, width: nextW, height: nextH } : s)));
    };
    const onUp = () => setStickyResizeState(null);
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    return () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
  }, [stickyResizeState, zoom]);

  const handleStickyResizeMouseDown = useCallback((e: React.MouseEvent, sn: CanvasStickyNote) => {
    e.stopPropagation();
    e.preventDefault();
    const h = sn.height ?? STICKY_NOTE_DEFAULT_HEIGHT;
    setStickyResizeState({
      id: sn.id,
      startClientX: e.clientX,
      startClientY: e.clientY,
      startWidth: sn.width,
      startHeight: h,
    });
  }, []);

  const handleStickyToolbarMouseDown = useCallback(
    (e: React.MouseEvent, sn: CanvasStickyNote) => {
      if ((e.target as HTMLElement).closest('button')) return;
      e.stopPropagation();
      e.preventDefault();
      setSelectedStickyId(sn.id);
      setSelectedNodeIds(new Set());
      setSelectedConnectionId(null);
      setEditingStickyId(null);
      setStickyDragState({
        id: sn.id,
        startClientX: e.clientX,
        startClientY: e.clientY,
        noteInitial: { x: sn.x, y: sn.y },
      });
    },
    []
  );

  const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 });
  React.useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const update = () => {
      const r = el.getBoundingClientRect();
      setContainerBounds({ left: r.left, top: r.top });
    };
    update();
    window.addEventListener('resize', update);
    return () => window.removeEventListener('resize', update);
  }, []);

  const clientToCanvas = useCallback(
    (clientX: number, clientY: number) => ({
      x: (clientX - containerBounds.left - panOffset.x) / zoom,
      y: (clientY - containerBounds.top - panOffset.y) / zoom,
    }),
    [containerBounds, panOffset, zoom]
  );

  const handleCanvasMouseDown = useCallback(
    (e: React.MouseEvent) => {
      const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`);
      const hitSticky = (e.target as HTMLElement).closest(`.${styles.canvasStickyNote}`);
      if (hitNode || hitSticky || connectingFrom) return;
      if (e.shiftKey) {
        e.preventDefault();
        setSelectedStickyId(null);
        const pt = clientToCanvas(e.clientX, e.clientY);
        setSelectionBox({ startX: pt.x, startY: pt.y, endX: pt.x, endY: pt.y });
        setSelectedNodeIds(new Set());
        setSelectedConnectionId(null);
      } else {
        setPanning({
          startX: e.clientX,
          startY: e.clientY,
          startPanX: panOffset.x,
          startPanY: panOffset.y,
        });
      }
    },
    [connectingFrom, panOffset, clientToCanvas]
  );

  const handleWheel = useCallback((e: WheelEvent) => {
    e.preventDefault();
    const delta = e.deltaY > 0 ? -0.1 : 0.1;
    setZoom((z) =>
      Math.min(FLOW_CANVAS_MAX_ZOOM, Math.max(FLOW_CANVAS_MIN_ZOOM, z + delta))
    );
  }, []);

  React.useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    el.addEventListener('wheel', handleWheel, { passive: false });
    return () => el.removeEventListener('wheel', handleWheel);
  }, [handleWheel]);

  React.useEffect(() => {
    if (!panning) return;
    const onMove = (e: MouseEvent) => {
      setPanOffset({
        x: panning.startPanX + (e.clientX - panning.startX),
        y: panning.startPanY + (e.clientY - panning.startY),
      });
    };
    const onUp = () => setPanning(null);
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    return () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
  }, [panning]);

  const selectionBoxRef = useRef<typeof selectionBox>(null);
  const marqueeJustEndedRef = useRef(false);
  selectionBoxRef.current = selectionBox;

  React.useEffect(() => {
    if (!selectionBox) return;
    const onMove = (e: MouseEvent) => {
      const pt = clientToCanvas(e.clientX, e.clientY);
      setSelectionBox((prev) =>
        prev ? { ...prev, endX: pt.x, endY: pt.y } : null
      );
    };
    const onUp = () => {
      const box = selectionBoxRef.current;
      setSelectionBox(null);
      marqueeJustEndedRef.current = true;
      if (!box) return;
      const minX = Math.min(box.startX, box.endX);
      const maxX = Math.max(box.startX, box.endX);
      const minY = Math.min(box.startY, box.endY);
      const maxY = Math.max(box.startY, box.endY);
      const ids = new Set<string>();
      nodes.forEach((n) => {
        const nx = n.x;
        const ny = n.y;
        const nRight = nx + NODE_WIDTH;
        const nBottom = ny + NODE_HEIGHT;
        const overlaps =
          nx < maxX && nRight > minX && ny < maxY && nBottom > minY;
        if (overlaps) ids.add(n.id);
      });
      setSelectedNodeIds(ids);
      setSelectedStickyId(null);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    return () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
  }, [selectionBox, nodes, clientToCanvas]);

  const CANVAS_SIZE = 8000;
  const svgBounds = useMemo(() => {
    if (nodes.length === 0) return { width: CANVAS_SIZE, height: CANVAS_SIZE };
    let maxX = 0;
    let maxY = 0;
    nodes.forEach((n) => {
      maxX = Math.max(maxX, n.x + NODE_WIDTH + 200);
      maxY = Math.max(maxY, n.y + NODE_HEIGHT + 320);
    });
    return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) };
  }, [nodes]);

  const handleDeleteNode = useCallback(() => {
    if (selectedNodeIds.size === 0) return;
    const ids = selectedNodeIds;
    onNodesChange(nodes.filter((n) => !ids.has(n.id)));
    onConnectionsChange(
      connections.filter(
        (c) => !ids.has(c.sourceId) && !ids.has(c.targetId)
      )
    );
    setSelectedNodeIds(new Set());
    setEditingNodeId(null);
    setEditingField(null);
    emitHistoryCheckpoint();
  }, [selectedNodeIds, nodes, connections, onNodesChange, onConnectionsChange, emitHistoryCheckpoint]);

  const handleDeleteSelectedStickyNote = useCallback(() => {
    const change = onStickyNotesChangeRef.current;
    if (!selectedStickyId || !change) return;
    change(stickyNotesRef.current.filter((s) => s.id !== selectedStickyId));
    setSelectedStickyId(null);
    setEditingStickyId(null);
    emitHistoryCheckpoint();
  }, [selectedStickyId, emitHistoryCheckpoint]);

  React.useEffect(() => {
    const onKeyDown = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement;
      if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
      if (e.key === 'Escape') {
        setConnectingFrom(null);
        setDragPos(null);
        setSelectedConnectionId(null);
        setPendingConnClickSource(null);
        setEditingStickyId(null);
        setStickyDragState(null);
        setStickyResizeState(null);
        setSelectedStickyId(null);
      }
      if (e.key === 'Delete' || e.key === 'Backspace') {
        if (selectedConnectionId) {
          e.preventDefault();
          handleDeleteConnection();
        } else if (selectedNodeIds.size > 0) {
          e.preventDefault();
          handleDeleteNode();
        } else if (selectedStickyId && onStickyNotesChangeRef.current) {
          e.preventDefault();
          handleDeleteSelectedStickyNote();
        }
      }
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [
    handleDeleteNode,
    handleDeleteConnection,
    handleDeleteSelectedStickyNote,
    selectedNodeIds.size,
    selectedConnectionId,
    selectedStickyId,
  ]);

  const handleNodeUpdate = useCallback(
    (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
      onNodesChange(
        nodes.map((n) => (n.id === nodeId ? { ...n, ...updates } : n))
      );
    },
    [nodes, onNodesChange]
  );

  const patchStickyNote = useCallback(
    (
      id: string,
      patch: Partial<Pick<CanvasStickyNote, 'text' | 'colorId' | 'width' | 'height'>>
    ) => {
      onStickyNotesChange?.(
        stickyNotes.map((s) => (s.id === id ? { ...s, ...patch } : s))
      );
    },
    [stickyNotes, onStickyNotesChange]
  );

  useLayoutEffect(() => {
    if (!stickyFocusSelectAll || !editingStickyId) return;
    const ta = stickyTextareaRef.current;
    if (!ta) return;
    ta.focus();
    ta.select();
    setStickyFocusSelectAll(false);
  }, [editingStickyId, stickyFocusSelectAll, stickyNotes]);

  return (
    <div
      ref={containerRef}
      className={`${styles.canvasDropZone} ${panning ? styles.canvasPanning : styles.canvasGrab} ${selectionBox || draggingNodeId || stickyDragState || stickyResizeState ? styles.canvasSelecting : ''} ${connectionToolActive ? styles.canvasDropZoneConnectionTool : ''}`}
      style={{
        backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
        backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
      }}
      onDragOver={(e) => e.preventDefault()}
      onDrop={handleDrop}
      onMouseDown={handleCanvasMouseDown}
      tabIndex={0}
      onClick={() => {
        if (marqueeJustEndedRef.current) {
          marqueeJustEndedRef.current = false;
          return;
        }
        setSelectedNodeIds(new Set());
        setSelectedConnectionId(null);
        setConnectingFrom(null);
        setDragPos(null);
        setPendingConnClickSource(null);
        setEditingStickyId(null);
        setSelectedStickyId(null);
      }}
    >
      {selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
        <div className={styles.connectionHint}>
          {selectedNodeIds.size} {t('Knoten ausgewählt')}
          {' · '}
          <kbd>Entf</kbd> {t('zum Löschen')}
          {' · '}
          {t('Ziehen zum Verschieben')}
          {' · '}
          <kbd>Shift</kbd>
          {t('+Klick zum Hinzufügen oder Entfernen')}
        </div>
      )}
      {connectingFrom && !selectedConnectionId && (
        <div className={styles.connectionHint}>
          {t('Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang')}
          {' · '}
          <kbd>Esc</kbd> {t('zum Abbrechen')}
        </div>
      )}
      {selectedConnectionId && (
        <div className={styles.connectionHint}>
          {t('Verbindungspfeil ausgewählt')}
          {' · '}
          <kbd>Entf</kbd> {t('zum Löschen')}
          {' · '}
          {t('Anderen Eingang anklicken zum Umleiten')}
        </div>
      )}
      {connectionToolActive && pendingConnClickSource && !selectedConnectionId && (
        <div className={styles.connectionHint}>
          {t('Klicken Sie auf einen Eingang, um die Verbindung zu erstellen')}
          {' · '}
          <kbd>Esc</kbd> {t('zum Abbrechen')}
        </div>
      )}
      {connectionToolActive &&
        !pendingConnClickSource &&
        !connectingFrom &&
        !selectedConnectionId &&
        selectedNodeIds.size <= 1 && (
          <div className={styles.connectionHint}>
            {t('Klicken Sie auf einen Ausgang, dann auf einen Eingang')}
            {' · '}
            <kbd>Esc</kbd> {t('zum Abbrechen')}
          </div>
        )}
      <div
        className={styles.canvasContent}
        style={{
          width: svgBounds.width,
          height: svgBounds.height,
          transform: `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom})`,
          transformOrigin: '0 0',
        }}
      >
      <svg
        className={styles.connectionsLayer}
        width={svgBounds.width}
        height={svgBounds.height}
        style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible' }}
      >
        <defs>
          <marker
            id="arrowhead"
            markerWidth="10"
            markerHeight="7"
            refX="9"
            refY="3.5"
            orient="auto"
          >
            <polygon points="0 0, 10 3.5, 0 7" fill="var(--text-secondary, #666)" />
          </marker>
          <marker
            id="arrowhead-selected"
            markerWidth="10"
            markerHeight="7"
            refX="9"
            refY="3.5"
            orient="auto"
          >
            <polygon points="0 0, 10 3.5, 0 7" fill="var(--primary-color, #007bff)" />
          </marker>
          <marker
            id="arrowhead-warning"
            markerWidth="10"
            markerHeight="7"
            refX="9"
            refY="3.5"
            orient="auto"
          >
            <polygon points="0 0, 10 3.5, 0 7" fill="#FF9800" />
          </marker>
        </defs>
        {connections.map((c) => {
          const srcNode = nodes.find((n) => n.id === c.sourceId);
          const tgtNode = nodes.find((n) => n.id === c.targetId);
          if (!srcNode || !tgtNode) return null;
          const src = getHandlePosition(srcNode, c.sourceHandle);
          const tgtBase = getHandlePosition(tgtNode, c.targetHandle);
          const stack = inboundStacksByTarget.get(`${c.targetId}-${c.targetHandle}`) ?? [c];
          const si = stack.findIndex((x) => x.id === c.id);
          const spread = 12;
          const tgt =
            stack.length > 1
              ? { ...tgtBase, x: tgtBase.x + (si - (stack.length - 1) / 2) * spread }
              : tgtBase;
          const feedback = isLoopFeedbackEdge(c, srcNode, tgtNode);
          const pathD = connectionPathD(src, tgt, srcNode, tgtNode, feedback, nodes);
          const isSelected = selectedConnectionId === c.id;
          const isWarning = connectionWarnings[c.id];
          const strokeColor = isSelected
            ? 'var(--primary-color, #007bff)'
            : isWarning
              ? '#FF9800'
              : 'var(--text-secondary, #666)';
          return (
            <g
              key={c.id}
              onClick={(e) => handleConnectionClick(e, c.id)}
              style={{ cursor: 'pointer' }}
              role="button"
              tabIndex={-1}
              aria-label={t('Verbindung auswählen, Entf zum Löschen')}
            >
              <path
                d={pathD}
                fill="none"
                stroke="transparent"
                strokeWidth="16"
                pointerEvents="stroke"
              />
              <path
                d={pathD}
                fill="none"
                stroke={strokeColor}
                strokeWidth={isSelected ? 3 : 2}
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeDasharray={isWarning && !isSelected ? '6 3' : undefined}
                markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
                pointerEvents="none"
              />
              {isWarning && !isSelected && (
                <title>{t('Typeninkompatibilität: Ausgabetyp')}</title>
              )}
            </g>
          );
        })}
        {connectingFrom && dragPos && (() => {
          const end = clientToCanvas(dragPos.x, dragPos.y);
          return (
            <path
              d={`M ${connectingFrom.x} ${connectingFrom.y} L ${end.x} ${end.y}`}
              fill="none"
              stroke="var(--primary-color, #007bff)"
              strokeWidth="2"
              strokeDasharray="4 4"
              pointerEvents="none"
            />
          );
        })()}
      </svg>
      {nodes.map((node) => {
        const nt = nodeTypeMap[node.type];
        const category = nt?.category ?? 'io';
        const color = node.color ?? nt?.meta?.color ?? '#00BCD4';
        const handles: Array<{ index: number; isOutput: boolean }> = [];
        for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false });
        for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });

        const wireSourceNode =
          !selectedConnectionId && connectingFrom
            ? nodes.find((n) => n.id === connectingFrom.nodeId)
            : !selectedConnectionId && connectionToolActive && pendingConnClickSource
              ? nodes.find((n) => n.id === pendingConnClickSource.nodeId)
              : null;

        const wireSourceHandleIdx =
          connectingFrom?.handleIndex ?? pendingConnClickSource?.handleIndex ?? -1;

        const isSelected = selectedNodeIds.has(node.id);
        const isEditingTitle = editingNodeId === node.id && editingField === 'title';
        const displayTitle = node.title ?? node.label ?? getLabel(node);
        const hlStatus = highlightedNodeIds?.[node.id];
        const hlColor = hlStatus ? HIGHLIGHT_COLORS[hlStatus] : null;

        return (
          <div
            key={node.id}
            className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''} ${hlStatus ? styles.canvasNodeHighlighted : ''}`}
            style={{
              left: node.x,
              top: node.y,
              width: NODE_WIDTH,
              height: NODE_HEIGHT,
              borderColor: hlColor || color,
              backgroundColor: hlColor ? `${hlColor}20` : `${color}15`,
              boxShadow: hlStatus === 'running' ? `0 0 12px ${hlColor}80` : undefined,
            }}
            onClick={(e) => e.stopPropagation()}
            onMouseDown={(e) => {
              e.stopPropagation();
              setSelectedConnectionId(null);
              setSelectedStickyId(null);
              if (e.shiftKey) {
                setSelectedNodeIds((prev) => {
                  const next = new Set(prev);
                  if (next.has(node.id)) next.delete(node.id);
                  else next.add(node.id);
                  return next;
                });
                return;
              }
              if (!selectedNodeIds.has(node.id)) {
                setSelectedNodeIds(new Set([node.id]));
              }
              handleNodeMouseDown(e, node.id);
            }}
          >
            {nt?.meta?.usesAi === true && (
              <AiBadge
                variant="canvas"
                title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
              />
            )}
            {nodeErrors?.[node.id]?.length ? (
              <div
                role="status"
                title={
                  t('Pflicht-Felder ohne Quelle: ') +
                  nodeErrors[node.id].map((e) => e.paramLabel).join(', ')
                }
                style={{
                  position: 'absolute',
                  top: -8,
                  right: -8,
                  minWidth: 20,
                  height: 20,
                  borderRadius: 10,
                  padding: '0 6px',
                  background: 'var(--danger-color, #dc3545)',
                  color: '#fff',
                  fontSize: 11,
                  fontWeight: 700,
                  display: 'flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                  boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
                  zIndex: 5,
                  pointerEvents: 'auto',
                }}
              >
                {nodeErrors[node.id].length}
              </div>
            ) : null}
            {handles.map(({ index, isOutput }) => {
              const pos = getHandlePosition(node, index);
              const used =
                !isOutput &&
                getUsedTargetHandles.has(`${node.id}-${index}`) &&
                !allowsMultipleInboundOnInputPort(node, index);
              const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null;
              const isCurrentTargetOfSelection =
                selConn && selConn.targetId === node.id && selConn.targetHandle === index;
              let wireTargetOk = true;
              if (!isOutput && wireSourceNode && wireSourceHandleIdx >= 0) {
                const sourceOutputIdx =
                  wireSourceHandleIdx >= wireSourceNode.inputs
                    ? wireSourceHandleIdx - wireSourceNode.inputs
                    : 0;
                wireTargetOk =
                  _checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok';
              }
              const canConnect =
                isOutput ||
                (!used &&
                  !!wireSourceNode &&
                  wireSourceHandleIdx >= 0 &&
                  (!selectedConnectionId ? wireTargetOk : true)) ||
                (!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
              const nt = nodeTypeMap[node.type];
              const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined;
              return (
                <div
                  key={index}
                  className={styles.handleWrapper}
                  style={{
                    top: pos.side === 'top' ? -HANDLE_OFFSET : undefined,
                    bottom: pos.side === 'bottom' ? -HANDLE_OFFSET : undefined,
                    left: pos.x - node.x - HANDLE_OFFSET,
                  }}
                >
                  {outputLabel && pos.side === 'bottom' && isOutput ? (
                    <>
                      <div
                        className={`${styles.handle} ${styles.handleOutput} ${canConnect ? styles.handleConnectable : ''}`}
                        style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
                        onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
                        onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)}
                        onClick={(e) => {
                          if (!connectionToolActive || !isOutput) return;
                          e.stopPropagation();
                          setPendingConnClickSource({ nodeId: node.id, handleIndex: index });
                          setConnectingFrom(null);
                          setDragPos(null);
                        }}
                        title={outputLabel}
                      />
                      <span className={styles.handleLabel}>{outputLabel}</span>
                    </>
                  ) : (
                    <>
                      <div
                        className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${canConnect ? styles.handleConnectable : ''}`}
                        style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
                        onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
                        onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)}
                        onClick={(e) => {
                          if (!connectionToolActive || !isOutput) return;
                          e.stopPropagation();
                          setPendingConnClickSource({ nodeId: node.id, handleIndex: index });
                          setConnectingFrom(null);
                          setDragPos(null);
                        }}
                        title={
                          outputLabel ??
                          (selectedConnectionId && !isOutput
                            ? used
                              ? t('Aktuelles Ziel klicken, um abzuwählen')
                              : t('Klicken zum Umleiten')
                            : undefined)
                        }
                      />
                      {outputLabel && pos.side === 'top' && (
                        <span className={styles.handleLabel}>{outputLabel}</span>
                      )}
                    </>
                  )}
                </div>
              );
            })}
            <div className={styles.canvasNodeContent}>
              <div
                className={styles.canvasNodeIcon}
                style={{ backgroundColor: `${color}40`, color }}
              >
                {getCategoryIcon(category)}
              </div>
              <div className={styles.canvasNodeText}>
                {isEditingTitle ? (
                  <input
                    type="text"
                    className={styles.canvasNodeInput}
                    value={node.title ?? displayTitle}
                    autoFocus
                    onClick={(e) => e.stopPropagation()}
                    onKeyDown={(e) => {
                      if (e.key === 'Enter') {
                        handleNodeUpdate(node.id, { title: (e.target as HTMLInputElement).value });
                        setEditingNodeId(null);
                        setEditingField(null);
                      }
                      if (e.key === 'Escape') {
                        setEditingNodeId(null);
                        setEditingField(null);
                      }
                    }}
                    onBlur={(e) => {
                      handleNodeUpdate(node.id, { title: e.target.value });
                      setEditingNodeId(null);
                      setEditingField(null);
                    }}
                    onChange={(e) => handleNodeUpdate(node.id, { title: e.target.value })}
                  />
                ) : (
                  <span
                    className={styles.canvasNodeTitle}
                    onDoubleClick={(e) => {
                      e.stopPropagation();
                      setEditingNodeId(node.id);
                      setEditingField('title');
                    }}
                  >
                    {displayTitle}
                  </span>
                )}
                {node.comment && (
                  <span className={styles.canvasNodeComment}>{node.comment}</span>
                )}
              </div>
            {node.comment && (
              <div className={styles.canvasNodeCommentTooltip}>{node.comment}</div>
            )}
            </div>
          </div>
        );
      })}
      {stickyNotes.map((sn) => {
        const pal = getStickyNotePaletteEntry(sn.colorId);
        const activeColorId = sn.colorId ?? STICKY_NOTE_DEFAULT_COLOR_ID;
        const contentH = sn.height ?? STICKY_NOTE_DEFAULT_HEIGHT;
        return (
          <div
            key={sn.id}
            className={`${styles.canvasStickyNote} ${selectedStickyId === sn.id ? styles.canvasStickyNoteSelected : ''}`}
            style={{
              position: 'absolute',
              left: sn.x,
              top: sn.y,
              width: sn.width,
              zIndex: 25,
            }}
            onMouseDown={(e) => {
              e.stopPropagation();
              setSelectedStickyId(sn.id);
              setSelectedNodeIds(new Set());
              setSelectedConnectionId(null);
            }}
            onClick={(e) => e.stopPropagation()}
          >
            <div
              className={styles.canvasStickyNoteToolbar}
              onMouseDown={(e) => handleStickyToolbarMouseDown(e, sn)}
            >
              <span
                className={styles.canvasStickyNoteGrip}
                title={t('Canvas-Notiz verschieben')}
                aria-hidden
              >
                ⋮⋮
              </span>
              {selectedStickyId === sn.id ? (
                <div
                  className={styles.canvasStickyNoteSwatches}
                  role="group"
                  aria-label={t('Notizfarbe')}
                >
                  {STICKY_NOTE_PALETTE.map((p) => (
                    <button
                      key={p.id}
                      type="button"
                      className={`${styles.canvasStickyNoteSwatch} ${
                        activeColorId === p.id ? styles.canvasStickyNoteSwatchActive : ''
                      }`}
                      style={{ backgroundColor: p.bg }}
                      aria-pressed={activeColorId === p.id}
                      onMouseDown={(e) => e.stopPropagation()}
                      onClick={(e) => {
                        e.stopPropagation();
                        patchStickyNote(sn.id, { colorId: p.id });
                      }}
                    />
                  ))}
                </div>
              ) : null}
            </div>
            {editingStickyId === sn.id ? (
              <textarea
                ref={stickyTextareaRef}
                className={styles.canvasStickyNoteTextarea}
                value={sn.text}
                placeholder={t('Kommentar eingeben …')}
                spellCheck
                style={{
                  height: contentH,
                  minHeight: 0,
                  overflow: 'auto',
                  boxSizing: 'border-box',
                  backgroundColor: pal.textareaBg,
                  borderColor: pal.border,
                }}
                onChange={(e) => patchStickyNote(sn.id, { text: e.target.value })}
                onBlur={() => setEditingStickyId(null)}
                onKeyDown={(e) => {
                  if (e.key === 'Escape') {
                    e.preventDefault();
                    setEditingStickyId(null);
                  }
                }}
                aria-label={t('Kommentar eingeben …')}
              />
            ) : (
              <div
                className={styles.canvasStickyNoteBody}
                role="note"
                tabIndex={0}
                title={t('Doppelklick zum Bearbeiten')}
                style={{
                  height: contentH,
                  minHeight: 0,
                  overflow: 'auto',
                  boxSizing: 'border-box',
                  backgroundColor: pal.bg,
                  borderColor: pal.border,
                }}
                onFocus={() => {
                  setSelectedStickyId(sn.id);
                  setSelectedNodeIds(new Set());
                  setSelectedConnectionId(null);
                }}
                onDoubleClick={(e) => {
                  e.stopPropagation();
                  setSelectedStickyId(sn.id);
                  setEditingStickyId(sn.id);
                  setStickyFocusSelectAll(true);
                }}
              >
                {sn.text}
              </div>
            )}
            {selectedStickyId === sn.id ? (
              <button
                type="button"
                className={styles.canvasStickyNoteResize}
                title={t('Notizgröße ändern')}
                aria-label={t('Notizgröße ändern')}
                onMouseDown={(e) => handleStickyResizeMouseDown(e, sn)}
              />
            ) : null}
          </div>
        );
      })}
      {selectionBox && (
        <div
          className={styles.selectionBox}
          style={{
            left: Math.min(selectionBox.startX, selectionBox.endX),
            top: Math.min(selectionBox.startY, selectionBox.endY),
            width: Math.abs(selectionBox.endX - selectionBox.startX),
            height: Math.abs(selectionBox.endY - selectionBox.startY),
          }}
        />
      )}
      {nodes.length === 0 && (
        <div className={styles.canvasPlaceholder}>
          <p>{t('Knoten aus der Liste links ziehen')}</p>
        </div>
      )}
      </div>
    </div>
  );
});
