// Edge rendering for the tour/transit tool.
//
// Features:
//   - Curve / rail / wavy / orth styles
//   - Lucide-icon "stamp" inside the label pill (no emoji)
//   - L-shape labels stay at the polyline geometric midpoint regardless of R
//   - Annotation chip (duration / distance / cost) is rendered as a SEPARATE
//     element with its own offset (edge.annoOffset). It's draggable via
//     pointerdown and connected back to its anchor with a dotted line whenever
//     it's been pulled away from the default position.

// Estimate rendered text width per character — CJK glyphs render about 1.15×
// font size wide, Latin (Inter) ~0.6×. Used as the INITIAL estimate before
// getBBox() refines (and as a fallback if getBBox doesn't fire / returns 0).
function estimateTextWidth(text, fontSize){
  if (!text) return 0;
  const s = String(text);
  const fs = fontSize || 11;
  let w = 0;
  for (let i = 0; i < s.length; i++) {
    const code = s.charCodeAt(i);
    if (code >= 0x2E80 && code <= 0x9FFF)      w += fs * 1.15;  // CJK
    else if (code >= 0xFF00 && code <= 0xFFEF) w += fs * 1.05;  // fullwidth
    else if (code >= 0x3000 && code <= 0x303F) w += fs * 1.05;  // CJK punctuation
    else                                       w += fs * 0.62;  // Latin / digit
  }
  return Math.ceil(w);
}

// ── Curve helpers ────────────────────────────────────────────────
function edgePointOnCircle(cx,cy,tx,ty,r){
  const dx = tx-cx, dy = ty-cy;
  const L = Math.hypot(dx,dy) || 1;
  return { x: cx + dx/L*r, y: cy + dy/L*r };
}

function buildCurve(p1, p2, offset){
  const mx = (p1.x+p2.x)/2, my = (p1.y+p2.y)/2;
  const dx = p2.x-p1.x, dy = p2.y-p1.y;
  const L = Math.hypot(dx,dy) || 1;
  const nx = -dy/L, ny = dx/L;
  const cx = mx + nx*offset;
  const cy = my + ny*offset;
  return { d: `M ${p1.x} ${p1.y} Q ${cx} ${cy} ${p2.x} ${p2.y}`, cx, cy };
}

function sampleQuad(p1, c, p2, n=60){
  const pts=[];
  for(let i=0;i<=n;i++){
    const t=i/n, it=1-t;
    pts.push({
      x: it*it*p1.x + 2*it*t*c.x + t*t*p2.x,
      y: it*it*p1.y + 2*it*t*c.y + t*t*p2.y,
      t,
    });
  }
  return pts;
}
function polyToPath(pts){
  return pts.map((p,i)=> (i?'L':'M') + p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' ');
}
function wavePathAlong(p1, c, p2, amp=7, freq=0.09){
  const samples = sampleQuad(p1,c,p2,120);
  const out=[];
  for(let i=0;i<samples.length;i++){
    const s = samples[i];
    const prev = samples[Math.max(0,i-1)];
    const nx = -(s.y - prev.y), ny = (s.x - prev.x);
    const L = Math.hypot(nx,ny) || 1;
    const t = i / (samples.length - 1);
    const fade = Math.min(1, t / 0.15, (1 - t) / 0.15);
    const off = Math.sin(i*freq*Math.PI) * amp * fade;
    out.push({x: s.x + nx/L*off, y: s.y + ny/L*off});
  }
  return polyToPath(out);
}

// ── Orthogonal (L-shape) helpers ─────────────────────────────────
function buildOrthogonalPath(p1, p2, mode, cornerR){
  const sx = Math.sign(p2.x - p1.x) || 1;
  const sy = Math.sign(p2.y - p1.y) || 1;
  const horizFirst = (mode === 'orth-h');
  const corner = horizFirst ? { x: p2.x, y: p1.y } : { x: p1.x, y: p2.y };
  const legA = horizFirst ? Math.abs(corner.x - p1.x) : Math.abs(corner.y - p1.y);
  const legB = horizFirst ? Math.abs(p2.y - corner.y) : Math.abs(p2.x - corner.x);
  const maxR = Math.max(0, Math.min(legA, legB) - 1);
  const R = Math.max(0, Math.min(cornerR ?? 8, maxR));

  let d;
  if (R <= 0.5){
    d = `M ${p1.x} ${p1.y} L ${corner.x} ${corner.y} L ${p2.x} ${p2.y}`;
  } else if (horizFirst){
    const enterX = corner.x - sx * R;
    const exitY  = corner.y + sy * R;
    d = `M ${p1.x} ${p1.y} L ${enterX} ${corner.y} Q ${corner.x} ${corner.y} ${corner.x} ${exitY} L ${p2.x} ${p2.y}`;
  } else {
    const enterY = corner.y - sy * R;
    const exitX  = corner.x + sx * R;
    d = `M ${p1.x} ${p1.y} L ${corner.x} ${enterY} Q ${corner.x} ${corner.y} ${exitX} ${corner.y} L ${p2.x} ${p2.y}`;
  }

  const ibx = horizFirst ? -sx : sx;
  const iby = horizFirst ?  sy : -sy;
  const ibL = Math.hypot(ibx, iby) || 1;
  const inwardBisector = { x: ibx / ibL, y: iby / ibL };

  return { d, corner, sx, sy, legA, legB, R, maxR, inwardBisector, horizFirst };
}

// Geometric midpoint of the orth polyline. Walks the path in three phases
// (leg1 → corner arc → leg2) so the label sits at the TRUE midpoint of the
// rendered path — including arc length. Without arc-awareness, the label
// drifts toward the start as R grows because the arc was being skipped.
function orthMidpoint({ horizFirst, p1, p2, corner, R, sx, sy }){
  const legAFull = horizFirst ? Math.abs(corner.x - p1.x) : Math.abs(corner.y - p1.y);
  const legBFull = horizFirst ? Math.abs(p2.y - corner.y) : Math.abs(p2.x - corner.x);
  const legA = Math.max(0, legAFull - R);
  const legB = Math.max(0, legBFull - R);
  // Arc length of the rendered rounded corner. We render via quadratic Bezier,
  // not a true quarter circle, but they're close enough for label placement.
  // Using the circular-arc length keeps the math simple.
  const arc  = R > 0 ? R * Math.PI / 2 : 0;
  const total = legA + arc + legB;
  if (total <= 0) return { x: corner.x, y: corner.y };
  const half = total / 2;

  if (half <= legA){
    // Midpoint sits on the first straight leg.
    if (horizFirst) return { x: p1.x + sx * half, y: p1.y };
    return                 { x: p1.x, y: p1.y + sy * half };
  }
  if (half <= legA + arc){
    // Midpoint sits on the rounded corner. Sample the same quadratic Bezier
    // we drew — that way the label tracks the visible curve, not an idealised
    // arc that diverges from it slightly.
    const t = (half - legA) / arc;            // 0..1 progress through arc
    let P0, P1, P2;
    if (horizFirst){
      P0 = { x: corner.x - sx*R, y: corner.y };
      P1 = corner;
      P2 = { x: corner.x,        y: corner.y + sy*R };
    } else {
      P0 = { x: corner.x,        y: corner.y - sy*R };
      P1 = corner;
      P2 = { x: corner.x + sx*R, y: corner.y };
    }
    const it = 1 - t;
    return {
      x: it*it*P0.x + 2*it*t*P1.x + t*t*P2.x,
      y: it*it*P0.y + 2*it*t*P1.y + t*t*P2.y,
    };
  }
  // Midpoint sits on the second straight leg.
  const past = half - legA - arc;
  if (horizFirst) return { x: corner.x, y: corner.y + sy * (R + past) };
  return                 { x: corner.x + sx * (R + past), y: corner.y };
}

// Resolve where an edge's LABEL pill is rendered, given the same `nodes`
// dictionary Edge.jsx receives. Used by Note.jsx to anchor a memo's dotted
// link line at (or just below) the label, not the geometric midpoint of the
// from/to chord. `labelOffsetY` shifts the returned point downward so notes
// can attach to the BOTTOM of the pill (the user's preferred direction).
function computeEdgeLabelPosition(edge, nodesById, edgeOffset, labelOffsetY){
  const rawA = nodesById[edge.from];
  const rawB = nodesById[edge.to];
  if (!rawA || !rawB) return { x: 0, y: 0 };
  // Mirror Edge.jsx's auto-flip so note anchors track the flipped curve.
  let a = rawA, b = rawB;
  let flipped = false;
  const aT = rawA.departure || rawA.arrival;
  const bT = rawB.departure || rawB.arrival;
  if (aT && bT && aT > bT) { a = rawB; b = rawA; flipped = true; }
  const offY = (typeof labelOffsetY === 'number') ? labelOffsetY : 0;
  const style = edge.style || 'oneway';
  const nrA = 40, nrB = 40;

  if (style === 'orth-h' || style === 'orth-v'){
    let p1, p2;
    if (style === 'orth-h'){
      const sx = Math.sign(b.x - a.x) || 1;
      const sy = Math.sign(b.y - a.y) || 1;
      p1 = { x: a.x + sx * nrA, y: a.y };
      p2 = { x: b.x,            y: b.y - sy * nrB };
    } else {
      const sx = Math.sign(b.x - a.x) || 1;
      const sy = Math.sign(b.y - a.y) || 1;
      p1 = { x: a.x,            y: a.y + sy * nrA };
      p2 = { x: b.x - sx * nrB, y: b.y };
    }
    const cornerR = (typeof edge.cornerR === 'number') ? edge.cornerR : 8;
    const orth = buildOrthogonalPath(p1, p2, style, cornerR);
    const m = orthMidpoint({
      horizFirst: orth.horizFirst, p1, p2,
      corner: orth.corner, R: orth.R, sx: orth.sx, sy: orth.sy,
    });
    return { x: m.x, y: m.y + offY };
  }
  // Free curve / rail / wavy — same formula Edge.jsx uses (mirror the flip).
  const baseOffset = edgeOffset || 0;
  const actualOffset = edge.customOffset !== undefined
    ? (flipped ? -edge.customOffset : edge.customOffset)
    : (flipped ? -baseOffset : baseOffset);
  const c0 = buildCurve({x:a.x,y:a.y}, {x:b.x,y:b.y}, actualOffset);
  const p1 = edgePointOnCircle(a.x, a.y, c0.cx, c0.cy, nrA);
  const p2 = edgePointOnCircle(b.x, b.y, c0.cx, c0.cy, nrB);
  const cv = buildCurve(p1, p2, actualOffset * 0.6);
  return {
    x: 0.25*p1.x + 0.5*cv.cx + 0.25*p2.x,
    y: 0.25*p1.y + 0.5*cv.cy + 0.25*p2.y + offY,
  };
}

// ── Stamp icon helpers (Lucide rendered inside the label pill) ───
function stampSvgChildren(name, ink){
  if (!name || !window.tourIconAsSvgChildren) return null;
  return window.tourIconAsSvgChildren(name, { stroke: ink, strokeWidth: 2.4 });
}

// ── Label pill ───────────────────────────────────────────────────
// Width is measured from the rendered <text> via getBBox so long labels
// (esp. CJK) don't visually spill out of the rounded rect. The Lucide stamp
// icon (if any) sits inside the pill, on the left, color-matched to the text.
function EdgeLabelBox({ text, stamp, x, y, color, selected, onSelect, onEdit, onDragStart }){
  const textRef = React.useRef(null);
  const hasIcon = !!stamp;
  const ICON = 13;
  const PAD_L = 10;
  const PAD_R = 12;
  const GAP = 5;
  // Smart per-char initial estimate (CJK vs Latin). getBBox refines after
  // the text is rendered. The smarter estimate avoids wide right padding on
  // English labels (which were getting ~7px/char × len, an overestimate)
  // and avoids spillover on long Japanese labels (chars are ~1.15× font).
  const [tw, setTw] = React.useState(() => Math.max(20, estimateTextWidth(text, 11)));
  React.useLayoutEffect(()=>{
    if (textRef.current) {
      try {
        const bb = textRef.current.getBBox();
        if (bb.width > 0.5 && Math.abs(bb.width - tw) > 0.5) setTw(bb.width);
      } catch(_){}
    }
  }, [text]);
  const w = Math.max(36, PAD_L + (hasIcon ? ICON + GAP : 0) + tw + PAD_R);
  const ink = darkenForWhitePill(color);
  const textX = -w/2 + PAD_L + (hasIcon ? ICON + GAP : 0);

  return (
    <g
      transform={`translate(${x},${y})`}
      className={`edge-label-g${selected?' selected':''}`}
      onClick={(e)=>{e.stopPropagation(); onSelect && onSelect(e.shiftKey);}}
      onDoubleClick={(e)=>{e.stopPropagation(); onEdit && onEdit();}}
      onPointerDown={(e)=>{
        e.stopPropagation();
        if (e.shiftKey) return;
        onSelect && onSelect(false);
        if(onDragStart) onDragStart(e);
      }}
      style={{cursor:'pointer'}}
    >
      <rect
        x={-w/2} y={-13} width={w} height={26} rx={6}
        fill="#FFFFFF"
        stroke={selected ? '#10A37F' : '#E5E7EB'}
        strokeWidth={1.5}
      />
      {hasIcon && (
        <g transform={`translate(${-w/2 + PAD_L},${-ICON/2}) scale(${ICON/24})`}
           style={{pointerEvents:'none'}}>
          {stampSvgChildren(stamp, ink)}
        </g>
      )}
      <text
        ref={textRef}
        x={textX} y={1}
        textAnchor="start"
        dominantBaseline="middle"
        fontFamily='"Noto Sans JP","Inter",system-ui,sans-serif'
        fontWeight={700}
        fontSize={12}
        fill={ink}
        style={{pointerEvents:'none'}}
      >{text}</text>
    </g>
  );
}

// Slightly darken a hex color so it reads against a white pill.
function darkenForWhitePill(hex){
  if (!hex || typeof hex !== 'string') return '#10A37F';
  const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
  if (!m) return hex;
  const r = parseInt(m[1].slice(0,2),16);
  const g = parseInt(m[1].slice(2,4),16);
  const b = parseInt(m[1].slice(4,6),16);
  const lum = (0.2126*r + 0.7152*g + 0.0722*b) / 255;
  if (lum <= 0.55) return hex;
  const mix = ((lum - 0.55) / 0.45) * 0.45;
  const blend = c => Math.round(c * (1 - mix));
  const toHex = c => blend(c).toString(16).padStart(2,'0');
  return '#' + toHex(r) + toHex(g) + toHex(b);
}

// Sequence-number badge — small filled circle drawn near the edge's source.
function SeqBadge({ x, y, n, color }){
  if (!n && n !== 0) return null;
  const r = 11;
  return (
    <g transform={`translate(${x},${y})`} style={{pointerEvents:'none'}}>
      <circle cx={0} cy={0} r={r} fill={color || '#10A37F'} stroke="#fff" strokeWidth={2}/>
      <text className="edge-seq" x={0} y={0}>{n}</text>
    </g>
  );
}

// ── Annotation chip (duration / cost / etc.) ────────────────────
// Stand-alone draggable chip rendered as a sibling of the edge label. Items
// are { icon, text } pairs — icon is a Lucide name. The chip's own offset
// (edge.annoOffset / node.annoOffset) is updated live while dragging.
//
// Width is computed with getBBox so CJK + punctuation like "1時間30分"
// or "¥18,000" don't get clipped or run together — the previous
// `text.length * 7` approximation overlapped on dense entries.
function AnnoChip({ items, anchorX, anchorY, offsetX, offsetY,
                   onPointerDown, selected }){
  if (!items?.length) return null;
  const ICON = 11;
  const GAP_ICON_TEXT = 3;
  const GAP_BETWEEN_ITEMS = 10; // includes the · separator's room
  const PAD_X = 9;
  const FONT = 10.5;
  const H = 18;
  // Initial estimate (before first measure) uses the per-char CJK / Latin
  // helper; getBBox refines after layout.
  const initialEst = items.map(it => Math.max(18, estimateTextWidth(it.text || '', FONT)));
  const [textWidths, setTextWidths] = React.useState(initialEst);
  const refs = React.useRef([]);

  React.useLayoutEffect(()=>{
    const next = items.map((it, i) => {
      try {
        const bb = refs.current[i]?.getBBox();
        return bb ? Math.max(8, bb.width) : initialEst[i];
      } catch(_){
        return initialEst[i];
      }
    });
    if (next.some((w, i) => Math.abs(w - (textWidths[i] || 0)) > 0.5)){
      setTextWidths(next);
    }
  }, [items.map(i => i.text + '|' + i.icon).join('\n')]);

  // Total width = sum of (icon + gap + measured text) + separators + padding.
  const itemWidths = items.map((_, i) => ICON + GAP_ICON_TEXT + (textWidths[i] || 0));
  const totalW = itemWidths.reduce((a,b)=>a+b, 0)
               + Math.max(0, items.length - 1) * GAP_BETWEEN_ITEMS
               + PAD_X * 2;

  const x = anchorX + offsetX;
  const y = anchorY + offsetY;

  // Layout cursor — walk left → right placing icon, then text, then separator dot.
  let cur = -totalW/2 + PAD_X;
  const children = [];
  items.forEach((it, i) => {
    const tw = textWidths[i] || initialEst[i];
    children.push(
      <g key={`ic${i}`}
         transform={`translate(${cur},${-ICON/2}) scale(${ICON/24})`}
         style={{pointerEvents:'none'}}>
        {window.tourIconAsSvgChildren(it.icon, { stroke:'#475569', strokeWidth: 2.5 })}
      </g>
    );
    cur += ICON + GAP_ICON_TEXT;
    children.push(
      <text key={`tx${i}`}
            ref={el => { refs.current[i] = el; }}
            x={cur} y={1}
            fontSize={FONT} fontWeight={600}
            fill="#334155"
            dominantBaseline="middle" textAnchor="start"
            fontFamily='"Noto Sans JP","Inter",system-ui,sans-serif'
            style={{pointerEvents:'none', whiteSpace:'pre'}}>{it.text}</text>
    );
    cur += tw;
    if (i < items.length - 1){
      children.push(
        <text key={`sp${i}`} x={cur + GAP_BETWEEN_ITEMS/2} y={1}
              fontSize={FONT} fill="#94A3B8"
              dominantBaseline="middle" textAnchor="middle"
              style={{pointerEvents:'none'}}>·</text>
      );
      cur += GAP_BETWEEN_ITEMS;
    }
  });

  return (
    <g transform={`translate(${x},${y})`}
       className={`anno-chip${selected?' selected':''}`}
       onPointerDown={onPointerDown}
       style={{cursor:'grab'}}>
      <rect x={-totalW/2} y={-H/2} width={totalW} height={H} rx={4}
            fill="#F8FAFC"
            stroke={selected ? '#10A37F' : '#CBD5E1'}
            strokeWidth={1}/>
      {children}
    </g>
  );
}

// ── Edge ─────────────────────────────────────────────────────────
function Edge({ edge, nodes, offset, selected, onSelect, onEdit, onDragStart,
                seqNumber, showAnnotations, updateEdge, worldZoom, whoosh, defaultCurrency }){
  const rawA = nodes[edge.from];
  const rawB = nodes[edge.to];
  if (!rawA || !rawB) return null;

  // Auto-flip rendering direction by time:
  // If the from-node's departure is LATER than the to-node's arrival, the
  // user drew the arrow "backward in time". We render it flipped (so the
  // arrowhead points in the time-flow direction) — but the underlying
  // edge.from/edge.to data stays untouched, so re-targeting and other
  // graph operations are unaffected. The perpendicular curve offset
  // negates so the bulge stays on the same world-space side after flip.
  let a = rawA, b = rawB;
  let visualOffset = offset;
  const aT = rawA.departure || rawA.arrival;
  const bT = rawB.departure || rawB.arrival;
  if (aT && bT && aT > bT){
    a = rawB; b = rawA;
    visualOffset = -(offset || 0);
  }
  // Effective offset that feeds the rest of the curve math.
  const offsetForCurve = visualOffset;

  const rel = RELATIONS.find(r => r.id === edge.relation) || RELATIONS[0];
  const color = edge.color || rel.color;
  const style = edge.style || rel.defaultStyle || 'oneway';
  // Tiered stroke widths:
  //   - 新幹線 (rail-double): 8px — visually distinct as the "fastest/biggest" route
  //   - oneway-thick / air  : 6px
  //   - other               : 3.5px
  const stroke = (style === 'rail-double') ? 8
               : (style === 'oneway-thick' || style === 'air') ? 6
               : 3.5;
  const isThick = stroke >= 6;
  const nrA = 40, nrB = 40;

  // Build the anno items.
  //   ⏱ 移動時間 — auto-derived from the from-node's departure → to-node's
  //       arrival (datetime model). Read-only; the user fills these on the
  //       node inspector. The chip simply omits the time if either side
  //       isn't filled in.
  //   💴 旅費    — stored on the edge.
  const annoItems = [];
  const dt = window.tourDateTime;
  if (dt && a.departure && b.arrival){
    const m = dt.minutesBetween(a.departure, b.arrival);
    if (m != null && m > 0){
      annoItems.push({ icon:'clock', text: dt.formatDuration(m) });
    }
  }
  // Display normalisation: bare numbers ("300") get the trip's default
  // currency prefixed for the chip ("¥300"). Inputs already containing
  // a currency marker are preserved as-is.
  if (edge.cost) {
    const fmtDisp = window.formatCostDisplay || ((s) => s);
    annoItems.push({ icon:'banknote', text: fmtDisp(edge.cost, defaultCurrency || '¥') });
  }
  const hasAnno = annoItems.length > 0 && (showAnnotations !== false);

  // ── ORTHOGONAL (L-shape) ────────────────────────────────────
  if (style === 'orth-h' || style === 'orth-v'){
    let p1, p2;
    if (style === 'orth-h'){
      const sx = Math.sign(b.x - a.x) || 1;
      const sy = Math.sign(b.y - a.y) || 1;
      p1 = { x: a.x + sx * nrA, y: a.y };
      p2 = { x: b.x,            y: b.y - sy * nrB };
    } else {
      const sx = Math.sign(b.x - a.x) || 1;
      const sy = Math.sign(b.y - a.y) || 1;
      p1 = { x: a.x,            y: a.y + sy * nrA };
      p2 = { x: b.x - sx * nrB, y: b.y };
    }

    const cornerR = (typeof edge.cornerR === 'number') ? edge.cornerR : 8;
    const orth = buildOrthogonalPath(p1, p2, style, cornerR);

    // Arrow head along the final segment direction.
    let arrDx, arrDy;
    if (style === 'orth-h'){
      const sy = Math.sign(p2.y - orth.corner.y) || 1;
      arrDx = 0; arrDy = sy;
    } else {
      const sx = Math.sign(p2.x - orth.corner.x) || 1;
      arrDx = sx; arrDy = 0;
    }
    const arrowSize = (style === 'rail-double') ? 22 : (isThick ? 18 : 12);
    const endArrow = buildArrowheadFromDir(p2, arrDx, arrDy, arrowSize, stroke);

    // Path geometric midpoint — independent of R, so the label stays glued
    // to the actual line even when the corner is heavily rounded.
    const labelPos = orthMidpoint({
      horizFirst: orth.horizFirst,
      p1, p2,
      corner: orth.corner,
      R: orth.R,
      sx: orth.sx, sy: orth.sy,
    });

    // Sequence badge a bit into the first leg.
    const seqAt = (style === 'orth-h')
      ? { x: p1.x + Math.sign(p2.x-p1.x)*22, y: p1.y }
      : { x: p1.x, y: p1.y + Math.sign(p2.y-p1.y)*22 };

    const labelText = (edge.label ?? rel.label) || '';
    const stamp = (edge.stamp !== undefined) ? edge.stamp : rel.stamp;

    // Anno chip default offset depends on which leg the label is on.
    // Place it perpendicular to the path so it doesn't collide with the line.
    const ao = edge.annoOffset || defaultAnnoOffsetForOrth(style, labelPos, p1, p2, orth.corner);
    const linkVisible = Math.hypot(ao.dx, ao.dy) > 28;

    return (
      <g className={`edge-group${selected?' selected':''}${whoosh?' whoosh':''}`}>
        <path className="edge-hit" d={orth.d}
              data-edge-id={edge.id}
              onClick={(e)=>onSelect(edge.id, e.shiftKey)}
              onDoubleClick={()=>onEdit(edge.id)}
              onPointerDown={(e)=>{
                if(onDragStart && !e.shiftKey) onDragStart(edge.id, e);
              }}/>
        <path className={`edge-path${whoosh?' whoosh':''}`} d={orth.d}
              pathLength={whoosh ? 100 : undefined}
              stroke={color} strokeWidth={stroke}
              strokeLinecap="round" strokeLinejoin="round"
              fill="none"/>
        {/* Anno-link rendered BEFORE the label so the label pill (white fill)
            and chip rect (light fill) cover the line at each end — only the
            gap between them is visible. Without this ordering the dotted line
            paints ON TOP of the label, making text hard to read. */}
        {hasAnno && linkVisible && (
          <line className="anno-link"
                x1={labelPos.x} y1={labelPos.y}
                x2={labelPos.x + ao.dx} y2={labelPos.y + ao.dy}
                stroke="#94A3B8" strokeWidth={1} strokeDasharray="2 4"
                pointerEvents="none" opacity={0.65}/>
        )}
        {endArrow && <polygon points={endArrow} fill={color} className={whoosh?'edge-arrowhead whoosh':'edge-arrowhead'}/>}
        {(seqNumber !== undefined && seqNumber !== null) && (
          <SeqBadge x={seqAt.x} y={seqAt.y} n={seqNumber} color={color}/>
        )}
        {(labelText || stamp) ? (
          <EdgeLabelBox
            text={labelText}
            stamp={stamp}
            x={labelPos.x} y={labelPos.y}
            color={color}
            selected={selected}
            onSelect={(shiftKey)=>onSelect(edge.id, shiftKey)}
            onEdit={()=>onEdit(edge.id)}
            onDragStart={onDragStart ? (e)=>onDragStart(edge.id, e) : null}
          />
        ) : null}
        {hasAnno && (
          <AnnoChip
            items={annoItems}
            anchorX={labelPos.x} anchorY={labelPos.y}
            offsetX={ao.dx} offsetY={ao.dy}
            selected={selected}
            onPointerDown={(e)=>annoChipDrag(e, edge, ao, updateEdge, worldZoom, onSelect)}/>
        )}
      </g>
    );
  }

  // ── FREE / RAIL / WAVY paths ────────────────────────────────
  const p1full = { x:a.x, y:a.y };
  const p2full = { x:b.x, y:b.y };
  // When the edge is rendered flipped (auto-flip above), the customOffset
  // also negates so the perpendicular curve bulges on the same world-space
  // side after the flip. Note: rawA/rawB are the data-side endpoints.
  const flipped = a !== rawA;
  const actualOffset = edge.customOffset !== undefined
    ? (flipped ? -edge.customOffset : edge.customOffset)
    : offsetForCurve;
  const { cx, cy } = buildCurve(p1full, p2full, actualOffset);
  const p1 = edgePointOnCircle(a.x,a.y, cx, cy, nrA);
  const p2 = edgePointOnCircle(b.x,b.y, cx, cy, nrB);
  const curve = buildCurve(p1, p2, actualOffset * 0.6);

  let pathD = curve.d;
  let dash;
  if (style === 'dotted') dash = '0.1 8';
  else if (style === 'dashed') dash = '10 6';
  // Air route — slightly wider gap so the dashes read as "intermittent",
  // suggesting a flight path rather than a continuous line.
  else if (style === 'air') dash = '14 10';
  else if (style === 'wavy') pathD = wavePathAlong(p1, {x:curve.cx,y:curve.cy}, p2, 6, 0.12);

  if (style === 'air' && (edge.customOffset === undefined)){
    const airCurve = buildCurve(p1, p2,
      (offset || 0) + Math.min(80, Math.hypot(p2.x-p1.x, p2.y-p1.y) * 0.18));
    pathD = airCurve.d;
  }

  const arrowSize = (style === 'rail-double') ? 22 : (isThick ? 18 : 14);
  const needEnd = (style !== 'rail-stripe');
  const needStart = (style === 'twoway');
  const endArrow = needEnd ? buildArrowhead(p1, {x:curve.cx,y:curve.cy}, p2, 1.0, arrowSize, stroke) : null;
  const startArrow = needStart ? buildArrowhead(p1, {x:curve.cx,y:curve.cy}, p2, 0.0, arrowSize, stroke) : null;

  const labelX = 0.25*p1.x + 0.5*curve.cx + 0.25*p2.x;
  const labelY = 0.25*p1.y + 0.5*curve.cy + 0.25*p2.y;
  const labelText = (edge.label ?? rel.label) || '';
  const stamp = (edge.stamp !== undefined) ? edge.stamp : rel.stamp;
  const seqAt = sampleAtT(p1, {x:curve.cx,y:curve.cy}, p2, 0.15);

  const railDouble = (style === 'rail-double');
  const railStripe = (style === 'rail-stripe');
  let stripes = null;
  if (railStripe){
    const samples = sampleQuad(p1, {x:curve.cx,y:curve.cy}, p2, 30);
    const out = [];
    const tickHalf = 6;
    for (let i = 2; i < samples.length - 2; i += 3){
      const s = samples[i];
      const prev = samples[i-1];
      const tx = s.x - prev.x, ty = s.y - prev.y;
      const tL = Math.hypot(tx, ty) || 1;
      const nx = -ty/tL, ny = tx/tL;
      out.push({
        x1: s.x - nx*tickHalf, y1: s.y - ny*tickHalf,
        x2: s.x + nx*tickHalf, y2: s.y + ny*tickHalf,
      });
    }
    stripes = out;
  }

  // Anno chip default offset = below the label pill on the perpendicular side.
  const ao = edge.annoOffset || { dx: 0, dy: 24 };
  const linkVisible = Math.hypot(ao.dx, ao.dy) > 28;

  return (
    <g className={`edge-group${selected?' selected':''}${whoosh?' whoosh':''}`}>
      <path className="edge-hit" d={curve.d}
            data-edge-id={edge.id}
            onClick={(e)=>onSelect(edge.id, e.shiftKey)}
            onDoubleClick={()=>onEdit(edge.id)}
            onPointerDown={(e)=>{
              if(onDragStart && !e.shiftKey) onDragStart(edge.id, e);
            }}/>
      <path
        className={`edge-path${whoosh?' whoosh':''}`}
        d={pathD}
        pathLength={whoosh ? 100 : undefined}
        stroke={color}
        strokeWidth={stroke}
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeDasharray={whoosh ? undefined : (dash || undefined)}
        fill="none"
      />
      {railDouble && (
        <path d={pathD} stroke="#fff"
              strokeWidth={Math.max(1, stroke - 4)}
              strokeLinecap="round" strokeLinejoin="round"
              fill="none" style={{pointerEvents:'none'}}/>
      )}
      {stripes && stripes.map((s, i)=>(
        <line key={i} x1={s.x1} y1={s.y1} x2={s.x2} y2={s.y2}
              stroke={color} strokeWidth={2.5} strokeLinecap="round"/>
      ))}
      {endArrow && <polygon points={endArrow} fill={color} />}
      {startArrow && <polygon points={startArrow} fill={color} className={whoosh?'edge-arrowhead whoosh':'edge-arrowhead'}/>}
      {/* Anno-link drawn BEFORE the label/chip so it sits underneath both
          rects (white pill + light chip). The line only shows in the gap. */}
      {hasAnno && linkVisible && (
        <line className="anno-link"
              x1={labelX} y1={labelY}
              x2={labelX + ao.dx} y2={labelY + ao.dy}
              stroke="#94A3B8" strokeWidth={1} strokeDasharray="2 4"
              pointerEvents="none" opacity={0.65}/>
      )}
      {(seqNumber !== undefined && seqNumber !== null) && (
        <SeqBadge x={seqAt.x} y={seqAt.y} n={seqNumber} color={color}/>
      )}
      {(labelText || stamp) ? (
        <EdgeLabelBox
          text={labelText}
          stamp={stamp}
          x={labelX} y={labelY}
          color={color}
          selected={selected}
          onSelect={(shiftKey)=>onSelect(edge.id, shiftKey)}
          onEdit={()=>onEdit(edge.id)}
          onDragStart={onDragStart ? (e)=>onDragStart(edge.id, e) : null}
        />
      ) : null}
      {hasAnno && (
        <AnnoChip
          items={annoItems}
          anchorX={labelX} anchorY={labelY}
          offsetX={ao.dx} offsetY={ao.dy}
          selected={selected}
          onPointerDown={(e)=>annoChipDrag(e, edge, ao, updateEdge, worldZoom, onSelect)}/>
      )}
    </g>
  );
}

// Pointerdown handler attached to anno chips. Each chip captures the pointer
// and patches edge.annoOffset live (skipHistory) until release, then commits.
function annoChipDrag(e, edge, ao, updateEdge, worldZoom, onSelect){
  e.stopPropagation();
  if (onSelect) onSelect(edge.id, false);
  const startX = e.clientX, startY = e.clientY;
  const ox = ao.dx || 0, oy = ao.dy || 0;
  let last = { dx: ox, dy: oy };
  let moved = false;
  const z = worldZoom || 1;
  const target = e.currentTarget;
  try { target.setPointerCapture(e.pointerId); } catch(_){}
  const onMove = (ev) => {
    const dx = (ev.clientX - startX) / z;
    const dy = (ev.clientY - startY) / z;
    if (Math.abs(dx)+Math.abs(dy) > 2) moved = true;
    last = { dx: ox + dx, dy: oy + dy };
    updateEdge(edge.id, { annoOffset: last }, { skipHistory: true });
  };
  const onUp = (ev) => {
    target.removeEventListener('pointermove', onMove);
    target.removeEventListener('pointerup', onUp);
    target.removeEventListener('pointercancel', onUp);
    try { target.releasePointerCapture(ev.pointerId); } catch(_){}
    if (moved) updateEdge(edge.id, { annoOffset: last });
  };
  target.addEventListener('pointermove', onMove);
  target.addEventListener('pointerup', onUp);
  target.addEventListener('pointercancel', onUp);
}

// Choose a sensible default chip offset for orth edges so it sits on the
// outside of the L (away from the inward bisector).
function defaultAnnoOffsetForOrth(style, labelPos, p1, p2, corner){
  // If the label is on the horizontal leg, place chip below; on vertical leg,
  // place chip to the right.
  const onHorizLeg = (Math.abs(labelPos.y - p1.y) < 1) || (style === 'orth-h' && Math.abs(labelPos.y - p1.y) < 1);
  if (onHorizLeg) return { dx: 0, dy: 24 };
  return { dx: 24, dy: 0 };
}

function sampleAtT(p1, c, p2, t){
  const it = 1 - t;
  return {
    x: it*it*p1.x + 2*it*t*c.x + t*t*p2.x,
    y: it*it*p1.y + 2*it*t*c.y + t*t*p2.y,
  };
}

function buildArrowhead(p1, c, p2, tPos, size, strokeW){
  size = size || 14;
  strokeW = strokeW || 4;
  const eps = 0.02;
  let tA, tB;
  if (tPos >= 1.0) { tA = 1.0 - eps; tB = 1.0; }
  else { tA = 0.0; tB = eps; }
  const evalBez = (t) => sampleAtT(p1, c, p2, t);
  const pA = evalBez(tA);
  const pB = evalBez(tB);
  let dx = pB.x - pA.x, dy = pB.y - pA.y;
  const L = Math.hypot(dx, dy) || 1;
  dx /= L; dy /= L;
  if (tPos < 0.5) { dx = -dx; dy = -dy; }
  const tip = evalBez(tPos);
  const nx = -dy, ny = dx;
  const halfW = size * 0.55;
  const backDist = size;
  const tipOffset = Math.max(4, strokeW * 1.0);
  tip.x += dx * tipOffset;
  tip.y += dy * tipOffset;
  const bx = tip.x - dx * backDist;
  const by = tip.y - dy * backDist;
  return `${tip.x},${tip.y} ${bx + nx*halfW},${by + ny*halfW} ${bx - nx*halfW},${by - ny*halfW}`;
}

function buildArrowheadFromDir(tip, dx, dy, size, strokeW){
  size = size || 14;
  strokeW = strokeW || 4;
  const L = Math.hypot(dx, dy) || 1;
  dx /= L; dy /= L;
  const nx = -dy, ny = dx;
  const halfW = size * 0.55;
  const backDist = size;
  const tipOffset = Math.max(4, strokeW * 1.0);
  const t = { x: tip.x + dx * tipOffset, y: tip.y + dy * tipOffset };
  const bx = t.x - dx * backDist;
  const by = t.y - dy * backDist;
  return `${t.x},${t.y} ${bx + nx*halfW},${by + ny*halfW} ${bx - nx*halfW},${by - ny*halfW}`;
}

window.Edge = Edge;
window.AnnoChip = AnnoChip;
window.buildOrthogonalPath = buildOrthogonalPath;
window.computeEdgeLabelPosition = computeEdgeLabelPosition;
