
/**
 * Shared Asuka Fabric controls (Main canvas + Studio canvas).
 *
 * Goal: keep the exact same UX everywhere (placement, visuals, click handlers),
 * while staying sandbox-friendly (Studio doesn't touch global store).
 *
 * This implementation is based on the original working controls from the main canvas.
 */
import { fabric } from '../fabric';
import { resolveAsukaUiScalingOptions } from './visualScaling';

export interface AsukaControlsOptions {
  /** Called after an object is cloned via the "clone" control. */
  prepareClone?: (obj: any) => void;
  /** Optional custom delete handler. */
  deleteHandler?: (eventData?: any, transform?: any) => boolean | void;
  /** Optional custom clone handler. */
  cloneHandler?: (eventData?: any, transform?: any) => boolean | void;
}

type AsukaControlIconKind = 'clone' | 'rotate' | 'delete' | 'scale';

function sanitizeTextPrototypeControls() {
  try {
    const fab: any = fabric as any;
    const textProtos = [fab?.Text?.prototype, fab?.IText?.prototype, fab?.Textbox?.prototype].filter(Boolean);
    for (const proto of textProtos) {
      const controls = proto?.controls;
      if (!controls || typeof controls !== 'object') continue;
      for (const key of Object.keys(controls)) {
        const ctrl = controls[key];
        if (!ctrl || typeof ctrl !== 'object') {
          try { delete controls[key]; } catch {}
          continue;
        }
        if (typeof ctrl.x !== 'number' || typeof ctrl.y !== 'number') {
          try { delete controls[key]; } catch {}
        }
      }
    }
  } catch {}
}

const ASUKA_CONTROL_ICON_PATHS: Record<AsukaControlIconKind, Array<{ d: string; opacity?: number }>> = {
  "clone": [
    {
      "d": "m3,8C1.35,8,0,9.35,0,11v10c0,1.65,1.35,3,3,3h10c1.65,0,3.01-1.35,3.01-3v-3h-5.01c-2.75,0-5-2.26-5-5v-5h-3.01Z",
      "opacity": 1
    },
    {
      "d": "m11,0C9.35,0,8,1.36,8,3v10c0,1.65,1.35,3,3,3h10c1.65,0,3-1.36,3-3V3C24,1.36,22.65,0,21,0h-10Z",
      "opacity": 0.36
    }
  ],
  "delete": [
    {
      "d": "m19.45,2.75h-4.56c-.02-.21.03-1.02-.02-1.22-.14-.86-.89-1.53-1.79-1.53h-2.15c-.9,0-1.65.66-1.79,1.53-.04.19-.02,1.01-.02,1.22h-4.56c-.56,0-1.01.45-1.01,1v1.51c0,.55.45,1,1.01,1,.77,0,14.14,0,14.9,0,.56,0,1.01-.45,1.01-1v-1.51c0-.55-.45-1-1.01-1h0Zm-9.12-.93c0-.32.26-.59.59-.59h2.15c.33,0,.59.27.59.59v.93h-3.34v-.93Z",
      "opacity": 1
    },
    {
      "d": "m4.7,7.49l.91,14.03c.08,1.39,1.24,2.48,2.64,2.48h7.52c1.39,0,2.55-1.09,2.64-2.48l.91-14.03H4.7Zm9.5,13.31l.76-11.04c.08-.81,1.24-.72,1.22.08l-.77,11.28c-.07.8-1.25.72-1.22-.09l.02-.23h0Zm-2.81-11c.01-.8,1.21-.8,1.22,0v11.27c-.01.8-1.21.8-1.22,0v-11.27Zm-3.55-.13c.14-.67,1.15-.62,1.2.09l.76,11.04.02.23c.04.8-1.14.89-1.22.09l-.77-11.28c0-.06,0-.12.02-.17h0Z",
      "opacity": 0.36
    }
  ],
  "rotate": [
    {
      "d": "m10.76,22.56c-1.47,0-2.91-.31-4.24-.91-1.26-.58-2.4-1.39-3.4-2.52-.09-.09-.09-.24,0-.33.09-.12.24-.12.36-.03,1.29,1.06,2.82,1.58,4.39,1.61,1.65.03,3.27-.45,4.63-1.43.99-.67,1.83-1.61,2.4-2.73.54-1.06.84-2.25.84-3.58h-1.05c-.21,0-.39-.03-.51-.09-.12-.06-.21-.12-.24-.24l-.03-.03c-.03-.09-.03-.21,0-.36.06-.12.15-.27.3-.42,1.2-1.37,2.4-2.64,3.66-3.98.39-.43.69-.64,1.02-.61.36,0,.63.18,1.02.55,1.17,1.21,2.64,2.97,3.79,4.04.12.15.21.27.27.42.03.12.03.24-.03.36-.06.13-.12.21-.24.27-.15.06-.3.09-.48.09h-1.08c-.06,2.79-1.29,5.13-3.15,6.83-2.1,1.91-4.95,3.03-7.84,3.09-.06,0-.39,0-.39,0Zm-.96-10.04c-1.23,1.37-2.44,2.64-3.67,3.98-.39.43-.72.61-1.05.61s-.63-.18-.99-.55c-1.2-1.21-2.67-2.97-3.82-4.04-.12-.15-.21-.27-.24-.42-.06-.12-.03-.27,0-.36.06-.12.15-.21.27-.27.12-.06.27-.09.48-.09h1.05c.09-2.79,1.32-5.13,3.16-6.83,2.1-1.91,4.99-3.03,7.87-3.09h.39c1.44,0,2.88.3,4.24.91,1.23.58,2.4,1.39,3.37,2.52.09.09.09.24.03.33-.09.12-.27.12-.36.03-1.29-1.06-2.85-1.58-4.42-1.61-1.62-.03-3.27.45-4.63,1.43-.96.67-1.8,1.61-2.4,2.73-.51,1.06-.84,2.24-.84,3.58h1.05c.21,0,.39.03.51.09.12.06.21.12.27.24v.03c.03.09.06.21,0,.36-.03.12-.15.27-.27.42Z",
      "opacity": 1
    }
  ],
  "scale": [
    {
      "d": "m4.62,18.3c-.24-.6-.72-1.08-1.32-1.32V4.62c.6-.24,1.08-.72,1.32-1.32h12.36c.24.6.72,1.08,1.32,1.32v5.68l1.8.72v-6.4c.88-.36,1.5-1.22,1.5-2.22C21.6,1.07,20.53,0,19.2,0,18.19,0,17.33.62,16.98,1.5H4.62C4.27.62,3.41,0,2.4,0,1.07,0,0,1.07,0,2.4c0,1.01.62,1.87,1.5,2.22v12.36c-.88.36-1.5,1.22-1.5,2.22,0,1.33,1.07,2.4,2.4,2.4,1.01,0,1.87-.62,2.22-1.5h6.4l-.72-1.8s-5.68,0-5.68,0Z",
      "opacity": 0.36
    },
    {
      "d": "m23.42,17.65l-4.81.96-.96,4.81c-.14.7-1.11.79-1.37.13l-4.22-10.56c-.23-.59.35-1.17.93-.93l10.56,4.22c.66.27.57,1.23-.13,1.37Z",
      "opacity": 1
    }
  ]
};

const ASUKA_CONTROL_ICON_PATH_CACHE: Partial<Record<AsukaControlIconKind, Array<{ path: Path2D; opacity: number }>>> = {};

function getAsukaControlIconPaths(kind: AsukaControlIconKind): Array<{ path: Path2D; opacity: number }> {
  const cached = ASUKA_CONTROL_ICON_PATH_CACHE[kind];
  if (cached) return cached;

  const raw = ASUKA_CONTROL_ICON_PATHS[kind] ?? [];
  const parsed = raw
    .map((item) => {
      try {
        return { path: new Path2D(item.d), opacity: typeof item.opacity === 'number' ? item.opacity : 1 };
      } catch {
        return null;
      }
    })
    .filter(Boolean) as Array<{ path: Path2D; opacity: number }>;

  ASUKA_CONTROL_ICON_PATH_CACHE[kind] = parsed;
  return parsed;
}

function asukaDrawFallbackIcon(ctx: CanvasRenderingContext2D, kind: AsukaControlIconKind, size: number, color = '#2563eb') {
  const half = size / 2;
  const pad = Math.max(7, Math.round(size * 0.34));
  const x0 = -half + pad;
  const y0 = -half + pad;
  const x1 = half - pad;
  const y1 = half - pad;
  const w = x1 - x0;
  const h = y1 - y0;

  ctx.save();
  ctx.strokeStyle = color;
  ctx.fillStyle = color;
  ctx.lineWidth = Math.max(2.1, Math.round(size * 0.105));
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';

  if (kind === 'delete') {
    ctx.beginPath();
    ctx.moveTo(x0, y0 + h * 0.25);
    ctx.lineTo(x1, y0 + h * 0.25);
    ctx.moveTo(x0 + w * 0.2, y0 + h * 0.42);
    ctx.lineTo(x0 + w * 0.28, y1);
    ctx.lineTo(x1 - w * 0.28, y1);
    ctx.lineTo(x1 - w * 0.2, y0 + h * 0.42);
    ctx.stroke();
  } else if (kind === 'clone') {
    ctx.strokeRect(x0 + w * 0.18, y0 + h * 0.18, w * 0.72, h * 0.72);
    ctx.strokeRect(x0, y0, w * 0.72, h * 0.72);
  } else if (kind === 'rotate') {
    ctx.beginPath();
    ctx.arc(0, 0, Math.max(3, w * 0.48), Math.PI * 0.10, Math.PI * 1.65);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(x0 + w * 0.76, y0 + h * 0.08);
    ctx.lineTo(x1, y0 + h * 0.26);
    ctx.lineTo(x1 - w * 0.24, y0 + h * 0.40);
    ctx.fill();
  } else {
    ctx.beginPath();
    ctx.moveTo(x0, y0);
    ctx.lineTo(x1, y1);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.lineTo(x1 - w * 0.38, y1);
    ctx.lineTo(x1, y1 - h * 0.38);
    ctx.closePath();
    ctx.fill();
  }

  ctx.restore();
}

function asukaDrawIcon(ctx: CanvasRenderingContext2D, kind: AsukaControlIconKind, size: number, color = '#2563eb') {
  const paths = getAsukaControlIconPaths(kind);

  if (!paths.length) {
    asukaDrawFallbackIcon(ctx, kind, size, color);
    return;
  }

  ctx.save();

  // Icons come from the designer SVG library (24x24 viewBox). Drawing them as paths
  // keeps the Fabric controls independent from font loading while matching the icon set.
  const iconSize = size * 0.54;
  const scale = iconSize / 24;
  ctx.translate(-iconSize / 2, -iconSize / 2);
  ctx.scale(scale, scale);
  ctx.fillStyle = color;

  for (const item of paths) {
    ctx.save();
    ctx.globalAlpha = Math.max(0.18, Math.min(1, item.opacity ?? 1));
    ctx.fill(item.path);
    ctx.restore();
  }

  ctx.restore();
}


function asukaRenderIcon(icon: HTMLImageElement, kind: AsukaControlIconKind) {
  return function (this: any, ctx: CanvasRenderingContext2D, left: number, top: number, _styleOverride: any, fabricObject: any) {
    const size = this?.cornerSize || 26;
    const selectionColor = fabricObject?.borderColor || '#4f8cff';
    const accent = kind === 'delete' ? '#ef4444' : selectionColor;

    ctx.save();
    ctx.translate(left, top);
    ctx.rotate((fabric as any).util.degreesToRadians(fabricObject.angle));

    // Premium control: white circular button, colored icon, soft shadow.
    // This is much more readable on bright/dark designs than filled blue squares.
    const radius = size / 2;
    ctx.save();
    ctx.shadowColor = 'rgba(15, 23, 42, 0.22)';
    ctx.shadowBlur = Math.max(8, size * 0.45);
    ctx.shadowOffsetY = Math.max(2, size * 0.10);
    ctx.fillStyle = 'rgba(255, 255, 255, 0.98)';
    ctx.beginPath();
    ctx.arc(0, 0, radius, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();

    // Crisp outline. Delete gets a subtle red outline, other controls follow the selection color.
    ctx.lineWidth = Math.max(1.35, size * 0.065);
    ctx.strokeStyle = kind === 'delete' ? 'rgba(239, 68, 68, 0.82)' : selectionColor;
    ctx.beginPath();
    ctx.arc(0, 0, radius - ctx.lineWidth / 2, 0, Math.PI * 2);
    ctx.stroke();

    // Tiny inner highlight for a polished look without visual noise.
    ctx.lineWidth = 1;
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.80)';
    ctx.beginPath();
    ctx.arc(0, 0, Math.max(1, radius - 2.5), Math.PI * 1.12, Math.PI * 1.88);
    ctx.stroke();

    // Vector icon, always sharp and centered.
    asukaDrawIcon(ctx, kind, size, accent);

    ctx.restore();
  };
}

function resetAsukaCloneIdentity(obj: any) {
  if (!obj) return;
  try { delete obj._asukaObjectId; } catch {}
  try { delete obj.id; } catch {}
  try { delete obj.__uid; } catch {}
  try {
    const children = typeof obj.getObjects === 'function' ? obj.getObjects() : (Array.isArray(obj._objects) ? obj._objects : []);
    if (Array.isArray(children)) children.forEach((child: any) => resetAsukaCloneIdentity(child));
  } catch {}
}

function asukaDeleteObject(eventData: any, transform: any) {
  const target: any = transform?.target;
  const canvas = target?.canvas ?? target?.group?.canvas ?? transform?.canvas ?? eventData?.target?.canvas;
  if (!canvas) return false;

  const delegated = (canvas as any)?.__asukaDeleteActiveObject;
  if (typeof delegated === 'function') {
    try {
      delegated(eventData, transform);
      return true;
    } catch {}
  }

  const isActiveSelection = !!target && (
    target.type === 'activeSelection' ||
    target.type === 'activeselection' ||
    target.isType?.('activeSelection') ||
    target.isType?.('activeselection')
  );

  if (isActiveSelection) {
    try {
      const objs = typeof target.getObjects === 'function'
        ? target.getObjects() || []
        : Array.isArray(target?._objects) ? target._objects : [];
      if (Array.isArray(objs)) {
        try { canvas.discardActiveObject?.(); } catch {}
        objs.forEach((o: any) => {
          try { if (o?.group === target) o.group = null; } catch {}
          try { canvas.remove(o); } catch {}
        });
      }
    } catch {}
    try { canvas.discardActiveObject?.(); } catch {}
  } else {
    try { canvas.remove(target); } catch {}
  }

  try { canvas.requestRenderAll(); } catch {}
  return true;
}

function createAsukaCloneHandler(options?: AsukaControlsOptions) {
  return function asukaCloneObject(eventData: any, transform: any) {
    const canvas = transform?.target?.canvas;
    const target = transform?.target;
    if (!canvas || !target) return false;

    const delegated = (canvas as any)?.__asukaDuplicateActiveObject;
    if (typeof delegated === 'function') {
      try {
        delegated(eventData, transform);
        return true;
      } catch {}
    }

    // Fabric v6 clone is asynchronous and returns a Promise.
    const maybePromise = target.clone?.();
    const handleCloned = (cloned: any) => {
      if (!cloned) return;
      cloned.left = (target.left ?? 0) + 10;
      cloned.top = (target.top ?? 0) + 10;

      // Keep Asuka extra props if present
      resetAsukaCloneIdentity(cloned);

      // Keep non-identity Asuka extra props if present.
      for (const k of ['_asukaZoneId', '_asukaRole', 'layerIndex', 'price', 'printingMethodId']) {
        if (target[k] !== undefined) cloned[k] = target[k];
      }

      try { options?.prepareClone?.(cloned); } catch {}

      // Re-apply our controls on the cloned object (and its children, if any)
      applyAsukaControlsDeep(cloned, options);

      canvas.add(cloned);
      canvas.setActiveObject(cloned);
      canvas.requestRenderAll();
    };

    if (maybePromise && typeof maybePromise.then === 'function') {
      maybePromise.then(handleCloned);
    }

    return true;
  };
}


function sanitizeControlsMap(obj: any) {
  try {
    const c = obj?.controls;
    if (!c || typeof c !== 'object') return;
    for (const k of Object.keys(c)) {
      const v = (c as any)[k];
      // Remove non-controls or undefined entries that can crash Fabric drawControls.
      if (!v || typeof v !== 'object' || typeof (v as any).x !== 'number' || typeof (v as any).y !== 'number') {
        try { delete (c as any)[k]; } catch {}
      }
    }

    // Fabric 6 `findControl()` iterates over `oCoords` and then reads
    // `this.controls[key].shouldActivate(...)`. If the controls map was changed
    // after coords were calculated (for example when an Element variation swaps
    // an image and we remove/replace controls), stale `oCoords` keys can point to
    // deleted controls and crash the whole canvas interaction loop.
    const oCoords = obj?.oCoords;
    if (oCoords && typeof oCoords === 'object') {
      for (const k of Object.keys(oCoords)) {
        const v = (c as any)[k];
        if (!v || typeof v !== 'object' || typeof (v as any).x !== 'number' || typeof (v as any).y !== 'number') {
          try { delete (oCoords as any)[k]; } catch {}
        }
      }
    }
  } catch {}
}

export function sanitizeAsukaObjectControls(obj: any) {
  sanitizeControlsMap(obj);
}

export function applyAsukaControls(obj: any, options?: AsukaControlsOptions) {
  if (!obj) return;

  // Ensure controls container exists and is SAFE for Fabric's for..in iteration.
// After loadFromJSON(), some environments end up with a polluted / inherited controls object.
// We force a null-prototype object containing only OWN keys to avoid inherited enumerable keys.
try {
  if (!obj.controls || typeof obj.controls !== 'object') {
    obj.controls = Object.create(null);
  } else {
    const fresh: any = Object.create(null);
    for (const k of Object.keys(obj.controls)) {
      const v = (obj.controls as any)[k];
      if (v) (fresh as any)[k] = v;
    }
    obj.controls = fresh;
  }
} catch {
  // fallback
  if (!obj.controls) obj.controls = {};
}

  // Rehydrated objects (loadFromJSON) can come back with hasControls/hasBorders turned off.
  // Ensure the custom Asuka controls are actually visible.
  try { obj.hasControls = true; } catch {}
  try { obj.hasBorders = true; } catch {}
  try { obj.selectable = obj.selectable ?? true; } catch {}
  try { obj.evented = obj.evented ?? true; } catch {}

  // Hide mid controls and default rotation control (we replace it)
  try {
    obj.setControlsVisibility?.({
      mt: false, mb: false, ml: false, mr: false,
      mtr: false,
      tl: false, tr: false, bl: false, br: false,
    });
    obj.setControlVisible?.('mt', false);
    obj.setControlVisible?.('mb', false);
    obj.setControlVisible?.('ml', false);
    obj.setControlVisible?.('mr', false);
    obj.setControlVisible?.('mtr', false);
  } catch {}
  try {
    (obj as any).hasRotatingPoint = false;
    (obj as any).centeredRotation = true;
  } catch {}

  // Some Fabric text objects can still resurrect the invalid top-middle /
  // default rotation handles even when visibility is set to false.
  // Remove them from the controls map entirely so only Asuka controls remain.
  try {
    if (obj?.controls) {
      delete (obj.controls as any).mt;
      delete (obj.controls as any).mb;
      delete (obj.controls as any).ml;
      delete (obj.controls as any).mr;
      delete (obj.controls as any).mtr;
    }
  } catch {}

  const Control = (fabric as any).Control;
  const controlsUtils = (fabric as any).controlsUtils;
  const fixedConstraints = (obj as any)?._asukaFixedSlotConstraints ?? (obj as any)?._asukaFixedConstraints ?? null;
  const fixedRotationLocked = fixedConstraints?.lockRotation === true || (obj as any)?.lockRotation === true;

  const uiScaling = resolveAsukaUiScalingOptions((obj as any)?.canvas?.__asukaUiScaling ?? null);
  const CORNER_SIZE = Number(uiScaling.baseControlSize) || 26;
  const TOUCH_CORNER_SIZE = Number(uiScaling.baseTouchControlSize) || Math.max(CORNER_SIZE, 40);

  obj.controls.cloneControl = new Control({
    x: -0.5,
    y: -0.5,
    offsetY: 0,
    offsetX: 0,
    cursorStyle: 'pointer',
    mouseUpHandler: createAsukaCloneHandler(options),
    render: asukaRenderIcon(undefined as any, 'clone'),
    cornerSize: CORNER_SIZE,
    touchCornerSize: TOUCH_CORNER_SIZE,
    __asukaBaseCornerSize: CORNER_SIZE,
    __asukaBaseTouchCornerSize: TOUCH_CORNER_SIZE,
  });

  if (!fixedRotationLocked) {
    obj.controls.rotateControl = new Control({
      x: 0.5,
      y: -0.5,
      offsetY: 0,
      offsetX: 0,
      cursorStyle: 'grab',
      actionHandler: controlsUtils?.rotationWithSnapping ?? controlsUtils?.rotationWithSnapping,
      render: asukaRenderIcon(undefined as any, 'rotate'),
      cornerSize: CORNER_SIZE,
      touchCornerSize: TOUCH_CORNER_SIZE,
      __asukaBaseCornerSize: CORNER_SIZE,
      __asukaBaseTouchCornerSize: TOUCH_CORNER_SIZE,
    });
  } else {
    try { (obj as any).lockRotation = true; } catch {}
    try { delete (obj.controls as any).rotateControl; } catch {}
  }

  obj.controls.deleteControl = new Control({
    x: -0.5,
    y: 0.5,
    offsetY: 0,
    offsetX: 0,
    cursorStyle: 'pointer',
    mouseUpHandler: asukaDeleteObject,
    render: asukaRenderIcon(undefined as any, 'delete'),
    cornerSize: CORNER_SIZE,
    touchCornerSize: TOUCH_CORNER_SIZE,
    __asukaBaseCornerSize: CORNER_SIZE,
    __asukaBaseTouchCornerSize: TOUCH_CORNER_SIZE,
  });

  obj.controls.scaleControl = new Control({
    x: 0.5,
    y: 0.5,
    offsetY: 0,
    offsetX: 0,
    cursorStyle: 'nwse-resize',
    actionHandler: controlsUtils?.scalingEqually ?? controlsUtils?.scalingEqually,
    render: asukaRenderIcon(undefined as any, 'scale'),
    cornerSize: CORNER_SIZE,
    touchCornerSize: TOUCH_CORNER_SIZE,
    __asukaBaseCornerSize: CORNER_SIZE,
    __asukaBaseTouchCornerSize: TOUCH_CORNER_SIZE,
  });

  // Keep Fabric's control coords in sync with the final controls map.
  // Without this, `oCoords` may still contain keys from controls we just removed
  // (mt/mb/ml/mr/mtr/default corners), and Fabric's `findControl()` can crash on
  // the next mouse move/down by reading `undefined.shouldActivate`.
  sanitizeControlsMap(obj);
  try { obj.setCoords?.(); } catch {}
  sanitizeControlsMap(obj);
}




export function sanitizeFabricControls() {
  try {
    sanitizeTextPrototypeControls();
    const fab: any = fabric as any;
    const protos: any[] = [];

    // Common bases
    protos.push(fab?.Object?.prototype);
    protos.push(fab?.InteractiveObject?.prototype);

    // HARD safety patch: Fabric's default forEachControl uses `for..in` over `this.controls`,
    // which can pick up inherited enumerable keys and yield `undefined` controls -> crash on control.x.
    // We replace it with an own-keys-only + validity-checked iterator.
    try {
      const ioProto = fab?.InteractiveObject?.prototype;
      if (ioProto && !ioProto.__asukaSafeForEachControlInstalled) {
        ioProto.__asukaSafeForEachControlInstalled = true;
        const origForEach = ioProto.forEachControl;
        ioProto.forEachControl = function (cb: any) {
          const controls = (this as any)?.controls;
          if (!controls || typeof controls !== 'object') return;
          const keys = Object.keys(controls);
          for (const key of keys) {
            const ctrl = (controls as any)[key];
            if (!ctrl || typeof ctrl !== 'object') continue;
            // Ensure required fields exist to prevent downstream drawControls crashes.
            if (typeof (ctrl as any).x !== 'number' || typeof (ctrl as any).y !== 'number') continue;
            cb(ctrl, key, this);
          }
        };
        // Keep a reference for debugging if needed
        ioProto.__asukaOriginalForEachControl = origForEach;
      }
    } catch {}

    // Fabric 6 `findControl()` trusts `oCoords` and assumes every key still has a
    // matching control. In a dynamic editor, controls can be removed/replaced after
    // coords were computed. Guard it globally so one stale control key never breaks
    // all pointer interactions.
    try {
      const ioProto = fab?.InteractiveObject?.prototype;
      if (ioProto && !ioProto.__asukaSafeFindControlInstalled) {
        ioProto.__asukaSafeFindControlInstalled = true;
        const origFindControl = ioProto.findControl;
        ioProto.findControl = function (pointer: any, forTouch = false) {
          if (!this?.hasControls || !this?.canvas) return;
          this.__corner = undefined;

          const controls = this.controls;
          const coords = this.oCoords;
          if (!controls || typeof controls !== 'object' || !coords || typeof coords !== 'object') return;

          const entries = Object.entries(coords);
          for (let i = entries.length - 1; i >= 0; i -= 1) {
            const [key, coord]: any = entries[i];
            const control = (controls as any)[key];
            if (!control || typeof control !== 'object' || typeof control.shouldActivate !== 'function') {
              try { delete (coords as any)[key]; } catch {}
              continue;
            }
            const corner = forTouch ? coord?.touchCorner : coord?.corner;
            try {
              if (control.shouldActivate(key, this, pointer, corner)) {
                this.__corner = key;
                return { key, control, coord: (coords as any)[key] };
              }
            } catch {}
          }
          return undefined;
        };
        ioProto.__asukaOriginalFindControl = origFindControl;
      }
    } catch {}


    // Scan all fabric exports for classes with prototype.controls
    try {
      for (const k of Object.keys(fab)) {
        const v: any = fab[k];
        if (typeof v === 'function' && v.prototype && v.prototype.controls) {
          protos.push(v.prototype);
        }
      }
    } catch {}

    const seen = new Set<any>();
    for (const proto of protos) {
      if (!proto || seen.has(proto)) continue;
      seen.add(proto);

      const c = proto.controls;
      if (!c || typeof c !== 'object') continue;

      // Remove invalid entries (undefined, null, or missing x/y numbers)
      for (const key of Object.keys(c)) {
        const ctrl = (c as any)[key];
        if (!ctrl || typeof ctrl !== 'object' || typeof (ctrl as any).x !== 'number' || typeof (ctrl as any).y !== 'number') {
          try { delete (c as any)[key]; } catch {}
        }
      }
    }
  } catch {}
}

export function debugDescribeControls(obj: any) {
  const keys = obj?.controls ? Object.keys(obj.controls) : [];
  return {
    type: obj?.type,
    hasControls: obj?.hasControls,
    hasBorders: obj?.hasBorders,
    selectable: obj?.selectable,
    evented: obj?.evented,
    controlsKeys: keys.slice(0, 20),
    asukaApplied: !!obj?.__asukaControlsApplied,
  };
}

export function applyAsukaControlsDeep(obj: any, options?: AsukaControlsOptions) {
  applyAsukaControls(obj, options);

  if (obj && typeof obj.getObjects === 'function') {
    try {
      const children = obj.getObjects();
      if (Array.isArray(children)) {
        children.forEach((child) => applyAsukaControls(child, options));
      }
    } catch {}
  }
}
