/**
 * Non-side-effect class export for `<noodles-ui>`.
 *
 * Importing this file gives you the class(es) without auto-registering the tag.
 * Useful for test isolation, subclassing with tag-name override, or selective
 * composition.
 *
 * The auto-register path stays at `@adia-ai/web-components/components/noodles`
 * (which imports this file + calls `defineIfFree()`).
 *
 * @see ../../USAGE.md#registration--auto-vs-explicit
 */

/**
 * <noodles-ui editable show-ports>
 *   <div data-noodle-port="left right">Node A</div>
 *   <div data-noodle-port="top bottom">Node B</div>
 * </noodles-ui>
 *
 * Renders SVG connection lines ("noodles") between children that declare
 * ports via the data-noodle-port attribute (space-separated sides).
 *
 * Supports bezier, step, and straight curve modes.
 */

import { UIElement } from '../../core/element.js';

let nextId = 0;

export class UINoodles extends UIElement {
  static properties = {
    editable:    { type: Boolean, default: false, reflect: true },
    color:       { type: String,  default: '', reflect: true },
    strokeWidth: { type: Number,  default: 2,     reflect: true, attribute: 'stroke-width' },
    tension:     { type: Number,  default: 0.5, reflect: true },
    showPorts:   { type: Boolean, default: false,  reflect: true, attribute: 'show-ports' },
    portSize:    { type: Number,  default: 10,     attribute: 'port-size', reflect: true },
    curve:       { type: String,  default: 'bezier', reflect: true },
    animated:    { type: Boolean, default: false,  reflect: true },
    readonly:    { type: Boolean, default: false,  reflect: true },
  };

  static template = () => null;

  #svg = null;
  #connections = [];
  #portIndicators = new Map();   // key: "elIndex:side" -> DOM element
  #resizeObs = null;
  #mutationObs = null;
  #rafId = null;
  #bound = false;
  #dragState = null;
  #dragPath = null;
  #dragCancel = null;

  // ── Public API ──────────────────────────────────────────────

  get connections() {
    return this.#connections.map(c => ({ ...c }));
  }

  connect(from, to, fromPort = 'right', toPort = 'left') {
    const id = `noodle-${++nextId}`;
    const conn = { id, from, to, fromPort, toPort };
    this.#connections.push(conn);
    this.#scheduleUpdate();
    this.dispatchEvent(new CustomEvent('noodle-connect', {
      bubbles: true, detail: { ...conn },
    }));
    return id;
  }

  disconnect(id) {
    const idx = this.#connections.findIndex(c => c.id === id);
    if (idx === -1) return;
    const [conn] = this.#connections.splice(idx, 1);
    this.#scheduleUpdate();
    this.dispatchEvent(new CustomEvent('noodle-disconnect', {
      bubbles: true, detail: { ...conn },
    }));
  }

  setConnections(list) {
    this.#connections = list.map(c => ({
      id: c.id || `noodle-${++nextId}`,
      from: c.from,
      to: c.to,
      fromPort: c.fromPort || 'right',
      toPort: c.toPort || 'left',
    }));
    this.#scheduleUpdate();
  }

  clear() {
    this.#connections = [];
    this.#scheduleUpdate();
  }

  update() {
    this.#performUpdate();
  }

  // ── Lifecycle ───────────────────────────────────────────────

  connected() {
    // Create the SVG overlay
    this.#svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    this.#svg.setAttribute('data-noodle-svg', '');
    this.prepend(this.#svg);

    // Observers for child layout changes
    this.#resizeObs = new ResizeObserver(() => this.#scheduleUpdate());
    this.#mutationObs = new MutationObserver(() => {
      this.#rebuildPortIndicators();
      this.#scheduleUpdate();
    });

    this.#resizeObs.observe(this);
    this.#observeMutations();

    // Observe each port-bearing child
    for (const child of this.#getPortChildren()) {
      this.#resizeObs.observe(child);
    }

    this.#rebuildPortIndicators();
    this.#scheduleUpdate();
  }

  render() {
    if (!this.#svg) return;
    // Update SVG attributes from properties
    if (this.color) {
      this.#svg.style.setProperty('--noodles-stroke', this.color);
    } else {
      this.#svg.style.removeProperty('--noodles-stroke');
    }
  }

  disconnected() {
    // Flush any in-flight drag so per-port pointermove/up listeners + their
    // closure references are released before the host detaches.
    this.#dragCancel?.();
    this.#resizeObs?.disconnect();
    this.#mutationObs?.disconnect();
    if (this.#rafId) cancelAnimationFrame(this.#rafId);
    this.#rafId = null;
    this.#clearPortIndicators();
    this.#svg?.remove();
    this.#svg = null;
    this.#bound = false;
  }

  // ── Update scheduling ───────────────────────────────────────

  #scheduleUpdate() {
    if (this.#rafId) return;
    this.#rafId = requestAnimationFrame(() => {
      this.#rafId = null;
      this.#performUpdate();
    });
  }

  #observeMutations() {
    this.#mutationObs?.observe(this, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['data-noodle-port', 'style'],
    });
  }

  #pauseMutations() {
    this.#mutationObs?.disconnect();
  }

  #performUpdate() {
    if (!this.#svg) return;
    this.#pauseMutations();

    // Sync resize observer with current port children
    const portChildren = this.#getPortChildren();
    for (const child of portChildren) {
      this.#resizeObs.observe(child);
    }

    // Update port indicator positions
    this.#updatePortIndicatorPositions();

    // Render paths
    const pathData = this.#connections.map(conn => {
      const fromEl = this.#resolveElement(conn.from);
      const toEl = this.#resolveElement(conn.to);
      if (!fromEl || !toEl) return null;

      const p1 = this.#getPortPosition(fromEl, conn.fromPort);
      const p2 = this.#getPortPosition(toEl, conn.toPort);
      if (!p1 || !p2) return null;

      return { id: conn.id, d: this.#computePath(p1, p2, conn.fromPort, conn.toPort) };
    }).filter(Boolean);

    // Reconcile SVG path elements
    const existing = new Map();
    for (const path of this.#svg.querySelectorAll('path[data-noodle-id]')) {
      existing.set(path.getAttribute('data-noodle-id'), path);
    }

    const activeIds = new Set(pathData.map(p => p.id));

    // Remove stale paths
    for (const [id, path] of existing) {
      if (!activeIds.has(id)) path.remove();
    }

    // Add/update paths
    for (const { id, d } of pathData) {
      let path = existing.get(id);
      if (!path) {
        path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('data-noodle-id', id);
        this.#svg.appendChild(path);
      }
      path.setAttribute('d', d);
      path.setAttribute('stroke-width', this.strokeWidth);
    }

    // Render drag preview path
    if (this.#dragState && this.#dragPath) {
      let preview = this.#svg.querySelector('path[data-noodle-preview]');
      if (!preview) {
        preview = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        preview.setAttribute('data-noodle-preview', '');
        preview.style.opacity = '0.5';
        preview.style.strokeDasharray = '6 3';
        this.#svg.appendChild(preview);
      }
      preview.setAttribute('d', this.#dragPath);
      preview.setAttribute('stroke-width', this.strokeWidth);
    } else {
      this.#svg.querySelector('path[data-noodle-preview]')?.remove();
    }

    this.#observeMutations();
  }

  // ── Element resolution ──────────────────────────────────────

  #resolveElement(ref) {
    if (ref instanceof HTMLElement) return ref;
    if (typeof ref === 'string') return this.querySelector(`#${CSS.escape(ref)}`);
    if (typeof ref === 'number') return this.#getPortChildren()[ref] ?? null;
    return null;
  }

  #getPortChildren() {
    return [...this.querySelectorAll('[data-noodle-port]')];
  }

  // ── Port position computation ──────────────────────────────
  // Uses bounding client rects so nested port-bearing descendants
  // (e.g. inside an absolutely-positioned card wrapper) project
  // correctly into the noodles-ui coordinate system.
  //
  // When an ancestor applies a CSS transform (zoom/pan in a graph
  // editor), bounding rects are in *screen* pixels — but SVG paths
  // and port-indicator dots are positioned in *local* (untransformed)
  // pixels. We detect any ancestor scale by comparing this element's
  // visible width to its layout width and divide deltas accordingly.
  // No transform → ratio is 1, no-op.
  #getPortPosition(el, side) {
    const elRect = el.getBoundingClientRect();
    const myRect = this.getBoundingClientRect();
    const localScale = (this.offsetWidth && myRect.width)
      ? (myRect.width / this.offsetWidth)
      : 1;
    const left = (elRect.left - myRect.left) / localScale;
    const top  = (elRect.top  - myRect.top)  / localScale;
    const w = elRect.width  / localScale;
    const h = elRect.height / localScale;

    switch (side) {
      case 'left':   return { x: left,         y: top + h / 2 };
      case 'right':  return { x: left + w,      y: top + h / 2 };
      case 'top':    return { x: left + w / 2,  y: top };
      case 'bottom': return { x: left + w / 2,  y: top + h };
      default:       return { x: left + w / 2,  y: top + h / 2 };
    }
  }

  // ── Path computation ───────────────────────────────────────

  #computePath(p1, p2, fromSide, toSide) {
    switch (this.curve) {
      case 'step':     return this.#stepPath(p1, p2);
      case 'straight': return this.#straightPath(p1, p2);
      case 'bezier':
      default:         return this.#bezierPath(p1, p2, fromSide, toSide);
    }
  }

  #bezierPath(p1, p2, fromSide, toSide) {
    const dx = Math.abs(p2.x - p1.x);
    const dy = Math.abs(p2.y - p1.y);
    const dist = Math.sqrt(dx * dx + dy * dy);
    const offset = Math.max(50, dist * this.tension);

    const c1 = this.#controlPoint(p1, fromSide, offset);
    const c2 = this.#controlPoint(p2, toSide, offset);

    return `M ${p1.x} ${p1.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${p2.x} ${p2.y}`;
  }

  #controlPoint(p, side, offset) {
    switch (side) {
      case 'left':   return { x: p.x - offset, y: p.y };
      case 'right':  return { x: p.x + offset, y: p.y };
      case 'top':    return { x: p.x,           y: p.y - offset };
      case 'bottom': return { x: p.x,           y: p.y + offset };
      default:       return { x: p.x + offset, y: p.y };
    }
  }

  #stepPath(p1, p2) {
    const midX = (p1.x + p2.x) / 2;
    return `M ${p1.x} ${p1.y} L ${midX} ${p1.y} L ${midX} ${p2.y} L ${p2.x} ${p2.y}`;
  }

  #straightPath(p1, p2) {
    return `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`;
  }

  // ── Port indicators ────────────────────────────────────────

  #rebuildPortIndicators() {
    this.#pauseMutations();
    this.#clearPortIndicators();

    const children = this.#getPortChildren();
    for (const child of children) {
      const sides = (child.getAttribute('data-noodle-port') || '').split(/\s+/).filter(Boolean);
      for (const side of sides) {
        const key = this.#portKey(child, side);
        const dot = document.createElement('div');
        dot.setAttribute('data-noodle-port-indicator', '');
        dot.setAttribute('data-noodle-side', side);
        dot.dataset.portElement = child.id || '';

        if (this.editable) {
          dot.addEventListener('pointerdown', (e) => this.#onPortPointerDown(e, child, side));
        }

        this.appendChild(dot);
        this.#portIndicators.set(key, dot);
      }
    }

    this.#updatePortIndicatorPositions();
    this.#observeMutations();
  }

  #clearPortIndicators() {
    for (const [, dot] of this.#portIndicators) {
      dot.remove();
    }
    this.#portIndicators.clear();

  }

  #updatePortIndicatorPositions() {
    const children = this.#getPortChildren();
    for (const child of children) {
      const sides = (child.getAttribute('data-noodle-port') || '').split(/\s+/).filter(Boolean);
      for (const side of sides) {
        const key = this.#portKey(child, side);
        const dot = this.#portIndicators.get(key);
        if (!dot) continue;

        const pos = this.#getPortPosition(child, side);
        dot.style.left = `${pos.x}px`;
        dot.style.top = `${pos.y}px`;
      }
    }
  }

  #portKey(el, side) {
    // Use a stable identifier
    if (!el._noodleId) el._noodleId = `_n${++nextId}`;
    return `${el._noodleId}:${side}`;
  }

  // ── Drag to connect ────────────────────────────────────────

  #onPortPointerDown = (e, fromEl, fromSide) => {
    if (!this.editable || this.readonly) return;
    e.preventDefault();
    e.stopPropagation();

    const dot = e.currentTarget;
    dot.setPointerCapture(e.pointerId);
    dot.setAttribute('data-noodle-dragging', '');

    const startPos = this.#getPortPosition(fromEl, fromSide);

    const onMove = (ev) => {
      if (!this.#dragState) return;
      const rect = this.getBoundingClientRect();
      const mx = ev.clientX - rect.left;
      const my = ev.clientY - rect.top;

      this.#dragPath = this.#computePath(
        startPos, { x: mx, y: my }, fromSide, this.#inferSide(startPos, { x: mx, y: my })
      );
      this.#scheduleUpdate();

      // Highlight potential drop targets
      this.#updateDropTargets(ev.clientX, ev.clientY, fromEl);

      this.dispatchEvent(new CustomEvent('noodle-drag', {
        bubbles: true,
        detail: { from: fromEl, fromPort: fromSide, x: mx, y: my },
      }));
    };

    const onUp = (ev) => {
      try { dot.releasePointerCapture(ev.pointerId); } catch (_) { /* already released */ }
      dot.removeAttribute('data-noodle-dragging');
      dot.removeEventListener('pointermove', onMove);
      dot.removeEventListener('pointerup', onUp);

      // Check if dropped on a target port (skip on synthetic disconnect cancel).
      if (ev.type === 'pointerup' && ev.clientX !== undefined) {
        const target = this.#findDropTarget(ev.clientX, ev.clientY, fromEl);
        if (target) this.connect(fromEl, target.el, fromSide, target.side);
      }

      this.#clearDropTargets();
      this.#dragState = null;
      this.#dragPath = null;
      this.#dragCancel = null;
      this.#scheduleUpdate();
    };

    // Capture the handlers so `disconnected()` can flush a mid-drag drag
    // by calling the same onUp tear-down without a synthetic event.
    this.#dragCancel = () => {
      this.#dragState = null;
      dot.removeAttribute('data-noodle-dragging');
      dot.removeEventListener('pointermove', onMove);
      dot.removeEventListener('pointerup', onUp);
      this.#clearDropTargets();
      this.#dragPath = null;
      this.#dragCancel = null;
    };

    this.#dragState = { fromEl, fromSide, startPos, pointerId: e.pointerId };
    this.#dragPath = null;

    dot.addEventListener('pointermove', onMove);
    dot.addEventListener('pointerup', onUp);
  };

  #inferSide(from, to) {
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    if (Math.abs(dx) > Math.abs(dy)) {
      return dx > 0 ? 'left' : 'right';
    }
    return dy > 0 ? 'top' : 'bottom';
  }

  #updateDropTargets(clientX, clientY, excludeEl) {
    this.#clearDropTargets();
    const target = this.#findDropTarget(clientX, clientY, excludeEl);
    if (target?.dot) {
      target.dot.setAttribute('data-noodle-drop-ready', '');
    }
  }

  #findDropTarget(clientX, clientY, excludeEl) {
    const hitRadius = this.portSize * 2;
    let best = null;
    let bestDist = Infinity;

    for (const [key, dot] of this.#portIndicators) {
      const rect = dot.getBoundingClientRect();
      const cx = rect.left + rect.width / 2;
      const cy = rect.top + rect.height / 2;
      const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2);

      // Parse the key to find the element
      const [noodleId, side] = key.split(':');
      const el = this.#getPortChildren().find(c => c._noodleId === noodleId);
      if (!el || el === excludeEl) continue;

      if (dist < hitRadius && dist < bestDist) {
        bestDist = dist;
        best = { el, side, dot };
      }
    }

    return best;
  }

  #clearDropTargets() {
    for (const [, dot] of this.#portIndicators) {
      dot.removeAttribute('data-noodle-drop-ready');
    }
  }
}
