import { getResizeCursorUrl, getRotateCursorUrl } from "./cursors";
import { DOMRectTransform, DOMRectTransformReadonly } from "./DOMRectTransform";
import { FolkElement } from "./folk-element";
import { ResizeManager } from "./resize-manager";
import { css, html } from "./tags";
import { TransformEvent } from "./TransformEvent";
import type { Point } from "./types";
import { MAX_Z_INDEX } from "./utils";
import { Vector } from "./Vector";
import type { PropertyValues } from "@lit/reactive-element";
import type { PortDescriptor } from "./data-types";

const resizeManager = new ResizeManager();

type ResizeHandle =
	| "resize-top-left"
	| "resize-top-right"
	| "resize-bottom-right"
	| "resize-bottom-left";
type RotateHandle =
	| "rotation-top-left"
	| "rotation-top-right"
	| "rotation-bottom-right"
	| "rotation-bottom-left";
type Handle = ResizeHandle | RotateHandle | "move";
export type Dimension = number | "auto";

type HandleMap = Record<ResizeHandle, ResizeHandle>;

const oppositeHandleMap: HandleMap = {
	"resize-bottom-right": "resize-top-left",
	"resize-bottom-left": "resize-top-right",
	"resize-top-left": "resize-bottom-right",
	"resize-top-right": "resize-bottom-left",
};

const flipXHandleMap: HandleMap = {
	"resize-bottom-right": "resize-bottom-left",
	"resize-bottom-left": "resize-bottom-right",
	"resize-top-left": "resize-top-right",
	"resize-top-right": "resize-top-left",
};

const flipYHandleMap: HandleMap = {
	"resize-bottom-right": "resize-top-right",
	"resize-bottom-left": "resize-top-left",
	"resize-top-left": "resize-bottom-left",
	"resize-top-right": "resize-bottom-right",
};

const styles = css`
	* {
		box-sizing: border-box;
	}

	:host {
		display: block;
		position: absolute;
		top: 0;
		left: 0;
		cursor: move;
		transform-origin: 0 0;
		box-sizing: border-box;
		color: var(--rs-text-primary, #e2e8f0);
	}

	:host::before {
		content: "";
		position: absolute;
		inset: -15px;
		z-index: -1;
	}

	.slot-container {
		height: 100%;
		width: 100%;
		overflow: hidden;
		pointer-events: none;
	}

	:host(:state(editing)) .slot-container {
		pointer-events: auto;
	}

	:host(:state(editing)) {
		outline: 2px solid #14b8a6;
		outline-offset: 2px;
		cursor: text;
	}

	::slotted(*) {
		cursor: default;
		pointer-events: auto;
	}

	:host(:focus-within),
	:host(:focus-visible) {
		z-index: calc(${MAX_Z_INDEX} - 1);
		outline: none;
	}

	:host(:hover) {
		outline: none;
	}

	:host(:state(highlighted)) {
		outline: 2px solid #3b82f6;
		outline-offset: 2px;
	}

	:host(:state(forgotten)) {
		opacity: 0.35;
		filter: grayscale(0.8);
		transition: opacity 0.3s ease, filter 0.3s ease;
	}

	:host(:state(locked)) {
		cursor: default;
	}

	[part="forgotten-tooltip"] {
		display: none;
		position: absolute;
		top: -32px;
		left: 50%;
		transform: translateX(-50%);
		background: rgba(0,0,0,0.85);
		color: #fff;
		font-size: 11px;
		padding: 4px 10px;
		border-radius: 6px;
		white-space: nowrap;
		pointer-events: none;
		z-index: 9999;
	}
	:host(:state(forgotten)):hover [part="forgotten-tooltip"] {
		display: block;
	}

	:host(:state(move)),
	:host(:state(rotate)),
	:host(:state(resize-top-left)),
	:host(:state(resize-top-right)),
	:host(:state(resize-bottom-right)),
	:host(:state(resize-bottom-left)) {
		user-select: none;
	}

	[part] {
		aspect-ratio: 1;
		display: none;
		position: absolute;
		z-index: calc(${MAX_Z_INDEX} - 1);
		padding: 0;
	}

	[part^="resize"] {
		background: hsl(210, 20%, 98%);
		width: 9px;
		transform: translate(-50%, -50%);
		border: 1.5px solid hsl(214, 84%, 56%);
		border-radius: 2px;
	}

	[part^="rotation"] {
		opacity: 0;
		width: 16px;
	}

	/* Touch devices: enlarge handles to ≥22px so they're reachable,
	   and make rotation handles visible (tinted dot). Hover-only reveal
	   doesn't work on touch. */
	@media (pointer: coarse) {
		[part^="resize"] {
			width: 22px;
			border-width: 2px;
			border-radius: 4px;
		}
		[part^="rotation"] {
			opacity: 0.6;
			width: 26px;
			background: hsl(214, 84%, 56%);
			border-radius: 50%;
		}
	}

	[part$="top-left"] {
		top: 0;
		left: 0;
	}

	[part="rotation-top-left"] {
		translate: -100% -100%;
	}

	[part$="top-right"] {
		top: 0;
		left: 100%;
	}

	[part="rotation-top-right"] {
		translate: 0% -100%;
	}

	[part$="bottom-right"] {
		top: 100%;
		left: 100%;
	}

	[part="rotation-bottom-right"] {
		translate: 0% 0%;
	}

	[part$="bottom-left"] {
		top: 100%;
		left: 0;
	}

	[part="rotation-bottom-left"] {
		translate: -100% 0%;
	}

	:host(:state(highlighted)) :is([part^="resize"], [part^="rotation"]),
	:host(:focus) :is([part^="resize"], [part^="rotation"]),
	:host(:state(move)) :is([part^="resize"], [part^="rotation"]),
	:host(:state(resize-top-left)) :is([part^="resize"], [part^="rotation"]),
	:host(:state(resize-top-right)) :is([part^="resize"], [part^="rotation"]),
	:host(:state(resize-bottom-right)) :is([part^="resize"], [part^="rotation"]),
	:host(:state(resize-bottom-left)) :is([part^="resize"], [part^="rotation"]) {
		display: block;
	}
`;

declare global {
	interface HTMLElementTagNameMap {
		"folk-shape": FolkShape;
	}
}

export class FolkShape extends FolkElement {
	static tagName = "folk-shape";
	static styles = styles;

	// ── Viewport-based lazy loading ──
	static #visibilityObserver: IntersectionObserver | null = null;
	static #ensureObserver(): IntersectionObserver {
		if (!FolkShape.#visibilityObserver) {
			FolkShape.#visibilityObserver = new IntersectionObserver(
				(entries) => {
					for (const entry of entries) {
						const shape = entry.target as FolkShape;
						if (entry.isIntersecting && !shape.#hasBeenVisible) {
							shape.#hasBeenVisible = true;
							shape.onBecameVisible();
						}
					}
				},
				{ rootMargin: '500px' }
			);
		}
		return FolkShape.#visibilityObserver;
	}

	#hasBeenVisible = false;
	get hasBeenVisible(): boolean { return this.#hasBeenVisible; }

	/** Called once when shape first enters viewport (+500px margin). Override to defer heavy init. */
	protected onBecameVisible(): void {}

	#internals = this.attachInternals();
	#attrWidth: Dimension = 0;
	#attrHeight: Dimension = 0;
	#rect = new DOMRectTransform();
	#previousRect = new DOMRectTransform();
	#readonlyRect = new DOMRectTransformReadonly();
	#handles!: Record<ResizeHandle | RotateHandle, HTMLElement>;
	#startAngle = 0;
	#lastTouchPos: Point | null = null;
	#isTouchDragging = false;
	#dragOffset: Point | null = null;
	#touchStartPos: Point | null = null;
	#longPressTimer: ReturnType<typeof setTimeout> | null = null;
	#extraLongPressTimer: ReturnType<typeof setTimeout> | null = null;
	#touchDidLongPress = false;
	static LONG_PRESS_MS = 500;
	static EXTRA_LONG_PRESS_MS = 1000;
	static TOUCH_MOVE_THRESHOLD = 8; // px before considered a drag

	get x() {
		return this.#rect.x;
	}
	set x(x) {
		this.#previousRect.x = this.#rect.x;
		this.#rect.x = x;
		this.requestUpdate("x");
	}

	get y() {
		return this.#rect.y;
	}
	set y(y) {
		this.#previousRect.y = this.#rect.y;
		this.#rect.y = y;
		this.requestUpdate("y");
	}

	get width(): number {
		return this.#rect.width;
	}
	set width(width: Dimension) {
		if (width === "auto") {
			resizeManager.observe(this, this.#onAutoResize);
		} else {
			if (this.#attrWidth === "auto" && this.#attrHeight !== "auto") {
				resizeManager.unobserve(this, this.#onAutoResize);
			}
			this.#previousRect.width = this.#rect.width;
			this.#rect.width = width;
		}
		this.#attrWidth = width;
		this.requestUpdate("width");
	}

	get height(): number {
		return this.#rect.height;
	}
	set height(height: Dimension) {
		if (height === "auto") {
			resizeManager.observe(this, this.#onAutoResize);
		} else {
			if (this.#attrHeight === "auto" && this.#attrWidth !== "auto") {
				resizeManager.unobserve(this, this.#onAutoResize);
			}
			this.#previousRect.height = this.#rect.height;
			this.#rect.height = height;
		}
		this.#attrHeight = height;
		this.requestUpdate("height");
	}

	get rotation(): number {
		return this.#rect.rotation;
	}
	set rotation(rotation: number) {
		this.#previousRect.rotation = this.#rect.rotation;
		this.#rect.rotation = rotation;
		this.requestUpdate("rotation");
	}

	static pushExemptTags = new Set(["folk-arrow", "folk-slide", "folk-shape"]);

	#highlighted = false;
	get highlighted() {
		return this.#highlighted;
	}
	set highlighted(highlighted) {
		if (this.#highlighted === highlighted) return;
		this.#highlighted = highlighted;
		highlighted
			? this.#internals.states.add("highlighted")
			: this.#internals.states.delete("highlighted");
	}

	#forgotten = false;
	get forgotten() {
		return this.#forgotten;
	}
	set forgotten(forgotten) {
		if (this.#forgotten === forgotten) return;
		this.#forgotten = forgotten;
		forgotten
			? this.#internals.states.add("forgotten")
			: this.#internals.states.delete("forgotten");
	}

	#locked = false;
	get locked() {
		return this.#locked;
	}
	set locked(locked) {
		if (this.#locked === locked) return;
		this.#locked = locked;
		locked
			? this.#internals.states.add("locked")
			: this.#internals.states.delete("locked");
		// Reflect as attribute for external CSS styling
		if (locked) this.setAttribute("locked", "");
		else this.removeAttribute("locked");
	}

	#editing = false;
	get editing() {
		return this.#editing;
	}

	enterEditMode() {
		if (this.#editing) return;
		this.#editing = true;
		this.#internals.states.add("editing");

		// Find first focusable child and focus it
		const root = this.renderRoot as ShadowRoot;
		const focusable = root.querySelector<HTMLElement>(
			'input, textarea, [contenteditable="true"], select'
		) ?? this.querySelector<HTMLElement>(
			'input, textarea, [contenteditable="true"], select'
		);
		if (focusable) {
			focusable.focus();
		}

		this.dispatchEvent(new CustomEvent("edit-enter"));
	}

	/** Check if a screen-space point overlaps a text input inside this shape's slotted content. */
	#findTextInputAtPoint(clientX: number, clientY: number): HTMLElement | null {
		const slot = (this.renderRoot as ShadowRoot).querySelector("slot");
		if (!slot) return null;

		const TEXT_SELECTOR = 'textarea, [contenteditable="true"], input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="hidden"])';

		for (const el of slot.assignedElements()) {
			// Check light DOM children
			const lightInputs = el.querySelectorAll<HTMLElement>(TEXT_SELECTOR);
			for (const input of lightInputs) {
				const rect = input.getBoundingClientRect();
				if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
					return input;
				}
			}
			// Check shadow DOM children (one level deep — covers all folk-* shapes)
			if ((el as Element).shadowRoot) {
				const shadowInputs = (el as Element).shadowRoot!.querySelectorAll<HTMLElement>(TEXT_SELECTOR);
				for (const input of shadowInputs) {
					const rect = input.getBoundingClientRect();
					if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
						return input;
					}
				}
			}
		}
		return null;
	}

	exitEditMode() {
		if (!this.#editing) return;
		this.#editing = false;
		this.#internals.states.delete("editing");
		this.dispatchEvent(new CustomEvent("edit-exit"));
	}

	override createRenderRoot() {
		const root = super.createRenderRoot();

		this.addEventListener("pointerdown", this);
		this.addEventListener("touchstart", this, { passive: false });
		this.addEventListener("touchmove", this, { passive: false });
		this.addEventListener("touchend", this);
		this.addEventListener("keydown", this);

		this.addEventListener("dblclick", (e: MouseEvent) => {
			e.stopPropagation();
			this.enterEditMode();
		});

		(root as ShadowRoot).setHTMLUnsafe(
			html`<button part="rotation-top-left" tabindex="-1"></button>
				<button part="rotation-top-right" tabindex="-1"></button>
				<button part="rotation-bottom-right" tabindex="-1"></button>
				<button part="rotation-bottom-left" tabindex="-1"></button>
				<button part="resize-top-left" aria-label="Resize shape from top left"></button>
				<button part="resize-top-right" aria-label="Resize shape from top right"></button>
				<button part="resize-bottom-right" aria-label="Resize shape from bottom right"></button>
				<button part="resize-bottom-left" aria-label="Resize shape from bottom left"></button>
				<div class="slot-container"><slot></slot></div>
				<div part="forgotten-tooltip">Forgotten — right-click to remember or forget permanently</div>`,
		);

		this.#handles = Object.fromEntries(
			Array.from(root.querySelectorAll("[part]")).map((el) => [
				el.getAttribute("part") as ResizeHandle | RotateHandle,
				el as HTMLElement,
			]),
		) as Record<ResizeHandle | RotateHandle, HTMLElement>;

		this.#updateCursors();

		// Only initialize from HTML attributes if they exist — when properties
		// are set via JS before DOM insertion, attributes are absent and reading
		// them would overwrite the already-set values with 0/"auto".
		const attrX = this.getAttribute("x");
		const attrY = this.getAttribute("y");
		const attrW = this.getAttribute("width");
		const attrH = this.getAttribute("height");
		const attrR = this.getAttribute("rotation");
		if (attrX !== null) this.x = Number(attrX) || 0;
		if (attrY !== null) this.y = Number(attrY) || 0;
		if (attrW !== null) this.width = Number(attrW) || "auto";
		if (attrH !== null) this.height = Number(attrH) || "auto";
		if (attrR !== null) this.rotation = (Number(attrR) || 0) * (Math.PI / 180);

		this.#rect.transformOrigin = { x: 0, y: 0 };
		this.#rect.rotateOrigin = { x: 0.5, y: 0.5 };

		this.#previousRect = new DOMRectTransform(this.#rect);

		this.setAttribute("tabindex", "0");

		return root;
	}

	override connectedCallback() {
		super.connectedCallback();
		FolkShape.#ensureObserver().observe(this);
	}

	override disconnectedCallback() {
		super.disconnectedCallback();
		FolkShape.#visibilityObserver?.unobserve(this);
	}

	getTransformDOMRect() {
		return this.#readonlyRect;
	}

	/** Compute the effective CSS transform scale of the parent (e.g. canvas zoom). */
	#getParentScale(): number {
		const parent = this.parentElement;
		if (!parent) return 1;
		const rect = parent.getBoundingClientRect();
		const w = parent.offsetWidth;
		if (!w || !rect.width) return 1;
		return rect.width / w;
	}

	/** Convert screen (viewport) coordinates to the parent's coordinate space. */
	#screenToParent(screenX: number, screenY: number): Point {
		const parent = this.parentElement;
		if (!parent) return { x: screenX, y: screenY };
		const parentRect = parent.getBoundingClientRect();
		const zoom = this.#getParentScale();
		return {
			x: (screenX - parentRect.left) / zoom,
			y: (screenY - parentRect.top) / zoom,
		};
	}

	#clearLongPressTimers() {
		if (this.#longPressTimer) { clearTimeout(this.#longPressTimer); this.#longPressTimer = null; }
		if (this.#extraLongPressTimer) { clearTimeout(this.#extraLongPressTimer); this.#extraLongPressTimer = null; }
	}

	handleEvent(event: PointerEvent | KeyboardEvent | TouchEvent) {
		// In feed mode, suppress all drag/resize interactions
		if (this.closest('#canvas.feed-mode')) return;

		// Locked shapes cannot be moved or resized, but can still be interacted with
		if (this.#locked) return;

		// Handle touch events for mobile drag support
		if (event instanceof TouchEvent) {
			const target = event.composedPath()[0] as HTMLElement;
			// Allow interactive elements to receive focus on mobile
			const tag = target?.tagName?.toLowerCase();
			const isInteractive = tag === "input" || tag === "textarea" || tag === "select" || tag === "button" || target?.isContentEditable;
			if (!isInteractive) {
				event.preventDefault();
			}
			const isDragHandle = target?.closest?.(".header, [data-drag]") !== null;
			const isValidDragTarget = target === this || isDragHandle;

			// Two-finger gesture → cancel shape drag so canvas pan takes over
			if (event.touches.length >= 2) {
				this.#clearLongPressTimers();
				this.#lastTouchPos = null;
				this.#isTouchDragging = false;
				this.#touchDidLongPress = false;
				this.#internals.states.delete("move");
				return;
			}

			if (event.type === "touchstart" && event.touches.length === 1) {
				if (!isValidDragTarget) return;

				// Check if touch is over a text input — enter edit mode instead of dragging
				const touch = event.touches[0];
				const textInput = this.#findTextInputAtPoint(touch.clientX, touch.clientY);
				if (textInput) {
					this.enterEditMode();
					requestAnimationFrame(() => textInput.focus());
					return;
				}

				this.#touchStartPos = { x: touch.clientX, y: touch.clientY };
				this.#lastTouchPos = { x: touch.clientX, y: touch.clientY };
				this.#isTouchDragging = false;
				this.#touchDidLongPress = false;

				// Long press → select shape
				this.#longPressTimer = setTimeout(() => {
					this.#longPressTimer = null;
					this.#touchDidLongPress = true;
					// Stop any drag — switch to selected state
					this.#isTouchDragging = false;
					this.#internals.states.delete("move");
					navigator?.vibrate?.(30);
					this.dispatchEvent(new CustomEvent("touch-select", {
						bubbles: true, composed: true,
						detail: { shapeId: this.id },
					}));
				}, FolkShape.LONG_PRESS_MS);

				// Extra long press → context menu
				this.#extraLongPressTimer = setTimeout(() => {
					this.#extraLongPressTimer = null;
					navigator?.vibrate?.(50);
					const cmEvent = new MouseEvent("contextmenu", {
						bubbles: true, cancelable: true,
						clientX: touch.clientX, clientY: touch.clientY,
					});
					this.dispatchEvent(cmEvent);
				}, FolkShape.EXTRA_LONG_PRESS_MS);

				this.focus();
				return;
			}

			if (event.type === "touchmove" && event.touches.length === 1) {
				const touch = event.touches[0];

				// Check if finger moved past threshold to start drag
				if (!this.#isTouchDragging && this.#touchStartPos) {
					const dx = touch.clientX - this.#touchStartPos.x;
					const dy = touch.clientY - this.#touchStartPos.y;
					if (Math.abs(dx) > FolkShape.TOUCH_MOVE_THRESHOLD ||
						Math.abs(dy) > FolkShape.TOUCH_MOVE_THRESHOLD) {
						// Movement → cancel long press timers, begin drag
						this.#clearLongPressTimers();
						if (!this.#touchDidLongPress) {
							this.#isTouchDragging = true;
							this.#internals.states.add("move");
						}
					}
				}

				if (this.#isTouchDragging && this.#lastTouchPos) {
					const zoom = (window.visualViewport?.scale ?? 1) * this.#getParentScale();
					const moveDelta = {
						x: (touch.clientX - this.#lastTouchPos.x) / zoom,
						y: (touch.clientY - this.#lastTouchPos.y) / zoom,
					};
					this.#lastTouchPos = { x: touch.clientX, y: touch.clientY };

					// Apply movement
					this.#rect.x += moveDelta.x;
					this.#rect.y += moveDelta.y;
					this.requestUpdate();
					this.#dispatchTransformEvent();
				} else {
					this.#lastTouchPos = { x: touch.clientX, y: touch.clientY };
				}
				return;
			}

			if (event.type === "touchend") {
				this.#clearLongPressTimers();
				this.#lastTouchPos = null;
				this.#touchStartPos = null;
				this.#isTouchDragging = false;
				this.#touchDidLongPress = false;
				this.#internals.states.delete("move");
				return;
			}

			return;
		}

		const shadowRoot = this.renderRoot as ShadowRoot | undefined;
		if (!shadowRoot) return;
		const focusedElement = shadowRoot.activeElement as HTMLElement | null;
		const target = event.composedPath()[0] as HTMLElement;
		let handle: Handle | null = null;
		if (target) {
			handle = target.getAttribute("part") as Handle | null;
		} else if (focusedElement) {
			handle = focusedElement.getAttribute("part") as Handle | null;
		}

		// Check if target is a drag handle (header or element with data-drag attribute)
		const isDragHandle = target?.closest?.(".header, [data-drag]") !== null;

		if (event instanceof PointerEvent) {
			// For pointerdown on non-drag targets (content areas, inputs, etc.),
			// stop immediate propagation so canvas selection/snap listeners don't fire.
			if (event.type === "pointerdown" && target !== this && !handle && !isDragHandle) {
				event.stopImmediatePropagation();
				return;
			}
			event.stopPropagation();
			if (event.type === "pointerdown") {

				// If clicking on the shape body (not a handle), check if a text input
				// is under the pointer — if so, enter edit mode immediately instead of dragging.
				if (!handle && (target === this || isDragHandle)) {
					const textInput = this.#findTextInputAtPoint(event.clientX, event.clientY);
					if (textInput) {
						this.enterEditMode();
						// Re-dispatch click so the input receives focus after pointer-events are enabled
						requestAnimationFrame(() => textInput.focus());
						return;
					}
				}

				if (handle?.startsWith("rotation")) {
					const parentRotateOrigin = this.#rect.toParentSpace({
						x: this.#rect.width * this.#rect.rotateOrigin.x,
						y: this.#rect.height * this.#rect.rotateOrigin.y,
					});
					const mousePos = this.#screenToParent(event.clientX, event.clientY);
					this.#startAngle = Vector.angleFromOrigin(mousePos, parentRotateOrigin) - this.#rect.rotation;
				}

				// Capture offset from mouse to shape origin for absolute drag tracking
				if (!handle) {
					const mouseInParent = this.#screenToParent(event.clientX, event.clientY);
					this.#dragOffset = {
						x: mouseInParent.x - this.#rect.x,
						y: mouseInParent.y - this.#rect.y,
					};
				}

				target.addEventListener("pointermove", this);
				target.addEventListener("lostpointercapture", this);
				target.setPointerCapture(event.pointerId);
				this.#internals.states.add(handle || "move");
				this.focus();
				return;
			}

			if (event.type === "lostpointercapture") {
				this.#internals.states.delete(handle || "move");
				this.#dragOffset = null;
				target.removeEventListener("pointermove", this);
				target.removeEventListener("lostpointercapture", this);
				this.#updateCursors();
				if (handle?.startsWith("rotation")) {
					target.style.removeProperty("cursor");
				}
				return;
			}
		}

		let moveDelta: Point | null = null;
		if (event instanceof KeyboardEvent) {
			const MOVEMENT_MUL = event.shiftKey ? 20 : 2;
			const arrowKeys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"];
			if (!arrowKeys.includes(event.key)) return;

			moveDelta = {
				x: (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0) * MOVEMENT_MUL,
				y: (event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0) * MOVEMENT_MUL,
			};
		} else if (event.type === "pointermove") {
			if (!target) return;

			// For shape body drag, use absolute positioning (no drift)
			if ((target === this || isDragHandle) && !handle && this.#dragOffset) {
				const mouseInParent = this.#screenToParent(event.clientX, event.clientY);
				this.x = mouseInParent.x - this.#dragOffset.x;
				this.y = mouseInParent.y - this.#dragOffset.y;
				event.preventDefault();
				return;
			}

			// For resize/rotate handles, use delta-based movement
			const zoom = (window.visualViewport?.scale ?? 1) * this.#getParentScale();
			moveDelta = {
				x: event.movementX / zoom,
				y: event.movementY / zoom,
			};
		}

		if (!moveDelta) return;

		if (target === this || isDragHandle || (!handle && event instanceof KeyboardEvent)) {
			if (event instanceof KeyboardEvent && event.altKey) {
				const ROTATION_MUL = event.shiftKey ? Math.PI / 12 : Math.PI / 36;
				const rotationDelta = moveDelta.x !== 0 ? (moveDelta.x > 0 ? ROTATION_MUL : -ROTATION_MUL) : 0;
				this.rotation += rotationDelta;
			} else {
				this.x += moveDelta.x;
				this.y += moveDelta.y;
			}
			event.preventDefault();
			return;
		}

		if (handle?.startsWith("resize")) {
			const rect = this.#rect;
			const corner = {
				"resize-top-left": rect.topLeft,
				"resize-top-right": rect.topRight,
				"resize-bottom-right": rect.bottomRight,
				"resize-bottom-left": rect.bottomLeft,
			}[handle as ResizeHandle];

			const currentPos = rect.toParentSpace(corner);
			const mousePos =
				event instanceof KeyboardEvent
					? { x: currentPos.x + moveDelta.x, y: currentPos.y + moveDelta.y }
					: this.#screenToParent(event.clientX, event.clientY);

			this.#handleResize(
				handle as ResizeHandle,
				mousePos,
				target,
				event instanceof PointerEvent ? event : undefined,
			);
			event.preventDefault();
			return;
		}

		if (handle?.startsWith("rotation") && event instanceof PointerEvent) {
			const parentRotateOrigin = this.#rect.toParentSpace({
				x: this.#rect.width * this.#rect.rotateOrigin.x,
				y: this.#rect.height * this.#rect.rotateOrigin.y,
			});
			const currentAngle = Vector.angleFromOrigin(
				this.#screenToParent(event.clientX, event.clientY),
				parentRotateOrigin,
			);
			this.rotation = currentAngle - this.#startAngle;

			const degrees = (this.#rect.rotation * 180) / Math.PI;
			const cursorRotation = {
				"rotation-top-left": degrees,
				"rotation-top-right": (degrees + 90) % 360,
				"rotation-bottom-right": (degrees + 180) % 360,
				"rotation-bottom-left": (degrees + 270) % 360,
			}[handle as RotateHandle];

			target.style.setProperty("cursor", getRotateCursorUrl(cursorRotation));
			return;
		}
	}

	protected override update(changedProperties: PropertyValues): void {
		this.#dispatchTransformEvent();
		super.update(changedProperties);
	}

	#dispatchTransformEvent() {
		const emittedRect = new DOMRectTransform(this.#rect);
		const event = new TransformEvent(emittedRect, this.#previousRect);
		this.dispatchEvent(event);

		if (event.xPrevented) emittedRect.x = this.#previousRect.x;
		if (event.yPrevented) emittedRect.y = this.#previousRect.y;
		if (event.widthPrevented) emittedRect.width = this.#previousRect.width;
		if (event.heightPrevented) emittedRect.height = this.#previousRect.height;
		if (event.rotatePrevented) emittedRect.rotation = this.#previousRect.rotation;

		this.style.transform = emittedRect.toCssString();
		this.style.width = this.#attrWidth === "auto" ? "" : `${emittedRect.width}px`;
		this.style.height = this.#attrHeight === "auto" ? "" : `${emittedRect.height}px`;

		this.#readonlyRect = new DOMRectTransformReadonly(emittedRect);
	}

	#onAutoResize = (entry: ResizeObserverEntry) => {
		this.#previousRect.height = this.#rect.height;
		this.#rect.height = entry.contentRect.height;
		this.#previousRect.width = this.#rect.width;
		this.#rect.width = entry.contentRect.width;
		this.#dispatchTransformEvent();
	};

	#updateCursors() {
		const degrees = (this.#rect.rotation * 180) / Math.PI;

		const resizeCursor0 = getResizeCursorUrl(degrees);
		const resizeCursor90 = getResizeCursorUrl((degrees + 90) % 360);

		this.#handles["resize-top-left"].style.setProperty("cursor", resizeCursor0);
		this.#handles["resize-bottom-right"].style.setProperty("cursor", resizeCursor0);
		this.#handles["resize-top-right"].style.setProperty("cursor", resizeCursor90);
		this.#handles["resize-bottom-left"].style.setProperty("cursor", resizeCursor90);

		this.#handles["rotation-top-left"].style.setProperty("cursor", getRotateCursorUrl(degrees));
		this.#handles["rotation-top-right"].style.setProperty(
			"cursor",
			getRotateCursorUrl((degrees + 90) % 360),
		);
		this.#handles["rotation-bottom-right"].style.setProperty(
			"cursor",
			getRotateCursorUrl((degrees + 180) % 360),
		);
		this.#handles["rotation-bottom-left"].style.setProperty(
			"cursor",
			getRotateCursorUrl((degrees + 270) % 360),
		);
	}

	#handleResize(handle: ResizeHandle, pointerPos: Point, target: HTMLElement, event?: PointerEvent) {
		const localPointer = this.#rect.toLocalSpace(pointerPos);

		switch (handle) {
			case "resize-bottom-right":
				this.#rect.bottomRight = localPointer;
				break;
			case "resize-bottom-left":
				this.#rect.bottomLeft = localPointer;
				break;
			case "resize-top-left":
				this.#rect.topLeft = localPointer;
				break;
			case "resize-top-right":
				this.#rect.topRight = localPointer;
				break;
		}

		let nextHandle: ResizeHandle = handle;
		const flipWidth = this.#rect.width < 0;
		const flipHeight = this.#rect.height < 0;

		if (flipWidth && flipHeight) {
			nextHandle = oppositeHandleMap[handle];
		} else if (flipWidth) {
			nextHandle = flipXHandleMap[handle];
		} else if (flipHeight) {
			nextHandle = flipYHandleMap[handle];
		}

		const newTarget = this.renderRoot.querySelector(`[part="${nextHandle}"]`) as HTMLElement;

		if (newTarget) {
			newTarget.focus();
			this.#internals.states.delete(handle);
			this.#internals.states.add(nextHandle);

			if (event && "setPointerCapture" in target) {
				target.removeEventListener("pointermove", this);
				target.removeEventListener("lostpointercapture", this);
				newTarget.addEventListener("pointermove", this);
				newTarget.addEventListener("lostpointercapture", this);
				target.releasePointerCapture(event.pointerId);
				newTarget.setPointerCapture(event.pointerId);
			}
		}

		// Sync public properties so CommunitySync sees the new values
		// and #attrWidth/#attrHeight stay current for CSS application.
		this.#attrWidth = this.#rect.width;
		this.#attrHeight = this.#rect.height;
		this.#previousRect = new DOMRectTransform(this.#rect);
		this.#dispatchTransformEvent();
	}


	// ── Data Ports (for shape-to-shape data piping) ──

	/** Port descriptors — subclasses override to declare their inputs/outputs. */
	static portDescriptors: PortDescriptor[] = [];

	#ports = new Map<string, unknown>();

	/** Initialize ports from the class's static descriptors. */
	initPorts(): void {
		const descriptors = (this.constructor as typeof FolkShape).portDescriptors;
		for (const desc of descriptors) {
			if (!this.#ports.has(desc.name)) {
				this.#ports.set(desc.name, undefined);
			}
		}
	}

	/** Get a port descriptor by name. */
	getPort(name: string): PortDescriptor | undefined {
		return (this.constructor as typeof FolkShape).portDescriptors.find(p => p.name === name);
	}

	/** Set a port value and dispatch a change event. */
	setPortValue(name: string, value: unknown): void {
		const prev = this.#ports.get(name);
		if (prev === value) return;
		this.#ports.set(name, value);
		this.dispatchEvent(new CustomEvent("port-value-changed", {
			bubbles: true,
			detail: { name, value, previous: prev },
		}));
	}

	/** Get a port's current value. */
	getPortValue(name: string): unknown {
		return this.#ports.get(name);
	}

	/** Set a port value without dispatching events (for sync restore). */
	setPortValueSilent(name: string, value: unknown): void {
		this.#ports.set(name, value);
	}

	/** Get all input port descriptors. */
	getInputPorts(): PortDescriptor[] {
		return (this.constructor as typeof FolkShape).portDescriptors.filter(p => p.direction === "input");
	}

	/** Get all output port descriptors. */
	getOutputPorts(): PortDescriptor[] {
		return (this.constructor as typeof FolkShape).portDescriptors.filter(p => p.direction === "output");
	}

	/**
	 * Called by CanvasEventBus when a subscribed channel fires.
	 * Override in subclasses to handle broadcast events.
	 */
	onEventReceived?(channel: string, payload: unknown, sourceShapeId: string): void;

	/**
	 * Serialize shape to JSON for Automerge sync
	 * Subclasses should override and call super.toJSON()
	 */
	toJSON(): Record<string, unknown> {
		const json: Record<string, unknown> = {
			type: "folk-shape",
			id: this.id,
			x: this.x,
			y: this.y,
			width: this.width,
			height: this.height,
			rotation: this.rotation,
		};

		if (this.#locked) json.locked = true;

		// Include port values when ports have data
		if (this.#ports.size > 0) {
			const portValues: Record<string, unknown> = {};
			let hasValues = false;
			for (const [name, value] of this.#ports) {
				if (value !== undefined) {
					portValues[name] = value;
					hasValues = true;
				}
			}
			if (hasValues) json.ports = portValues;
		}

		return json;
	}

	/**
	 * Create a new shape element from Automerge data.
	 * Subclasses should override to handle their own custom properties.
	 */
	static fromData(data: Record<string, any>): FolkShape {
		const shape = document.createElement(data.type || "folk-shape") as FolkShape;
		shape.id = data.id;

		const x = (typeof data.x === "number" && Number.isFinite(data.x)) ? data.x : 100;
		const y = (typeof data.y === "number" && Number.isFinite(data.y)) ? data.y : 100;
		const w = (typeof data.width === "number" && Number.isFinite(data.width) && data.width > 0) ? data.width : 300;
		const h = (typeof data.height === "number" && Number.isFinite(data.height) && data.height > 0) ? data.height : 200;

		shape.x = x;
		shape.y = y;
		shape.width = w;
		shape.height = h;
		if (typeof data.rotation === "number" && Number.isFinite(data.rotation)) {
			shape.rotation = data.rotation;
		}
		if (data.locked) shape.locked = true;

		return shape;
	}

	/**
	 * Apply sync data to an existing shape.
	 * Subclasses should override and call super.applyData(data).
	 */
	applyData(data: Record<string, any>): void {
		if (this.x !== data.x) this.x = data.x;
		if (this.y !== data.y) this.y = data.y;
		if (this.width !== data.width) this.width = data.width;
		if (this.height !== data.height) this.height = data.height;
		if (this.rotation !== data.rotation) this.rotation = data.rotation;
		if (!!data.locked !== this.#locked) this.locked = !!data.locked;

		// Restore port values without dispatching events (avoids sync loops)
		if (data.ports && typeof data.ports === "object") {
			for (const [name, value] of Object.entries(data.ports)) {
				this.setPortValueSilent(name, value);
			}
		}
	}
}
