/**
 * Annotation Editor - Fabric.js-based editor for image annotations
 *
 * Alpine.js component for the Maker role to place measurement markers,
 * arrows, and area rectangles on technical drawings.
 *
 * Dependencies:
 *   - Fabric.js v5.3.1 (loaded via CDN before this file)
 *   - Alpine.js (loaded with defer)
 *
 * Annotation JSON Format (output):
 * {
 *   "version": 1,
 *   "width": 800,
 *   "height": 480,
 *   "objects": [
 *     { "type": "marker", "left": 150, "top": 200, "markerNumber": 1, ... },
 *     { "type": "arrow",  "left": 100, "top": 80, "x1": 100, "y1": 80, "x2": 300, "y2": 200, ... },
 *     { "type": "area",   "left": 50,  "top": 60, "width": 150, "height": 100, ... }
 *   ]
 * }
 *
 * Usage:
 * <div x-data="annotationEditor()"
 *      x-init="loadBackgroundImage('/path/to/image.jpg')">
 *   <canvas x-ref="fabricCanvas"></canvas>
 * </div>
 */

function annotationEditor() {
  'use strict';

  // ---- Debounce utility ----

  function debounce(fn, delay) {
    var timer = null;
    return function () {
      var context = this;
      var args = arguments;
      if (timer) clearTimeout(timer);
      timer = setTimeout(function () {
        fn.apply(context, args);
      }, delay);
    };
  }

  return {
    // ---- State ----

    /** @type {fabric.Canvas|null} */
    canvas: null,
    /** @type {HTMLCanvasElement|null} */
    canvasEl: null,

    /** Current interaction mode: 'select' | 'marker' | 'arrow' | 'rect' */
    activeMode: 'select',

    /** Next auto-assigned marker number */
    nextMarkerNumber: 1,

    /** Whether a background image has been loaded */
    imageLoaded: false,

    /** Whether the canvas has unsaved changes */
    isDirty: false,

    /** Natural (unscaled) dimensions of the loaded background image */
    _bgImageNatW: 0,
    _bgImageNatH: 0,

    /** Annotations JSON to load after background image is ready */
    _pendingAnnotations: null,

    /** Current color for new annotations */
    currentColor: '#2563EB',

    /** Current stroke width for arrows and rectangles */
    currentStrokeWidth: 2,

    /** Current line dash pattern: [] = solid, [5,3] = dashed, [2,2] = dotted */
    currentLineDash: [],

    // ----------------------------------------------------------------
    // Initialization
    // ----------------------------------------------------------------

    /**
     * Alpine init hook.
     * Creates the Fabric.js canvas instance and wires up all event listeners.
     */
    init() {
      this.canvasEl = this.$refs.fabricCanvas;
      if (!this.canvasEl) {
        console.error('AnnotationEditor: canvas ref "fabricCanvas" not found');
        return;
      }

      if (typeof fabric === 'undefined') {
        console.error('AnnotationEditor: Fabric.js not loaded');
        return;
      }

      // Objects are move-only: no resize/rotate handles, just selection border
      fabric.Object.prototype.hasControls = false;
      fabric.Object.prototype.hasBorders = true;
      fabric.Object.prototype.borderColor = '#2563EB';
      fabric.Object.prototype.lockRotation = true;
      fabric.Object.prototype.lockScalingX = true;
      fabric.Object.prototype.lockScalingY = true;
      // Disable object caching to avoid stale render states
      fabric.Object.prototype.objectCaching = false;

      try {
        this.canvas = new fabric.Canvas(this.canvasEl, {
          selection: true,
          preserveObjectStacking: true,
          backgroundColor: '#f1f5f9',
          enableRetinaScaling: false,
        });
      } catch (err) {
        console.error('AnnotationEditor: failed to create canvas:', err);
        return;
      }

      var self = this;
      var canvas = this.canvas;

      // ---- Fabric.js v5 controls-visibility workaround ----
      //
      // Fabric v5.3.1 draws object controls (resize/rotate handles) on the
      // LOWER canvas.  The UPPER canvas (contextTop) sits on top and can
      // obscure those controls if it has stale content.
      // Fix: force contextTopDirty = true before every renderAll().

      var _origRenderAll = canvas.renderAll.bind(canvas);
      canvas.renderAll = function () {
        canvas.contextTopDirty = true;
        return _origRenderAll();
      };

      // ---- Reliable pointer override ----
      //
      // Fabric.js v5.3.1 getPointer can produce wrong coordinates due to
      // retina-scaling arithmetic, stale _offset cache, or scroll-offset
      // mismatches.  Replace with a minimal BCR-based calculation that is
      // always correct: mouse-client-position minus canvas-BCR, scaled to
      // canvas coordinate space.

      canvas.getPointer = function (e, ignoreVpt) {
        // Honour Fabric's per-event cache (set by _cacheTransformEventData)
        if (canvas._absolutePointer && !ignoreVpt) {
          return canvas._absolutePointer;
        }
        if (canvas._pointer && ignoreVpt) {
          return canvas._pointer;
        }

        // Extract clientX/Y from mouse or touch event
        var clientX = 0, clientY = 0;
        if (e) {
          if (e.clientX != null) {
            clientX = e.clientX;
            clientY = e.clientY;
          } else if (e.changedTouches && e.changedTouches[0]) {
            clientX = e.changedTouches[0].clientX;
            clientY = e.changedTouches[0].clientY;
          } else if (e.touches && e.touches[0]) {
            clientX = e.touches[0].clientX;
            clientY = e.touches[0].clientY;
          }
        }

        // BCR of the upper canvas (where events are captured)
        var rect = canvas.upperCanvasEl.getBoundingClientRect();

        // Position relative to canvas element in CSS pixels
        var x = clientX - rect.left;
        var y = clientY - rect.top;

        // Scale from CSS pixels to canvas internal coordinate space
        // With enableRetinaScaling:false these should be 1:1, but handle
        // any mismatch defensively.
        if (rect.width > 0 && rect.height > 0) {
          x = x * (canvas.width / rect.width);
          y = y * (canvas.height / rect.height);
        }

        // When ignoreVpt is false, return object-space coordinates
        if (!ignoreVpt) {
          return canvas.restorePointerVpt({ x: x, y: y });
        }
        return { x: x, y: y };
      };

      // ---- Keep canvas offset fresh ----
      canvas.on('mouse:down:before', function () {
        canvas.calcOffset();
      });

      // Force move-only properties on every object added to the canvas
      this.canvas.on('object:added', function (e) {
        var obj = e.target;
        if (obj && obj.objectType !== '_temp') {
          obj.hasControls = false;
          obj.hasBorders = true;
          obj.selectable = true;
          obj.evented = true;
          obj.lockRotation = true;
          obj.lockScalingX = true;
          obj.lockScalingY = true;
          obj.setCoords();
        }
      });

      this.setupEvents();
      this.setupKeyboard();

      this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200);
      window.addEventListener('resize', this._debouncedResize);

      // ---- Bridge: listen for commands from recipeEditor ----

      this._onToolChange = function (e) {
        if (e.detail && e.detail.tool) {
          self.setMode(e.detail.tool);
        }
      };
      window.addEventListener('anno-tool-change', this._onToolChange);

      this._onDeleteSelected = function () {
        self.deleteSelected();
      };
      window.addEventListener('anno-delete-selected', this._onDeleteSelected);

      this._onClearAll = function () {
        self.clearAll();
      };
      window.addEventListener('anno-clear-all', this._onClearAll);

      this._onColorChange = function (e) {
        if (e.detail && e.detail.color) {
          self.currentColor = e.detail.color;
          // Update selected object
          var active = self.canvas ? self.canvas.getActiveObject() : null;
          if (active) {
            if (active.objectType === 'marker') {
              var items = active.getObjects();
              if (items[0]) items[0].set('fill', e.detail.color);
              active.markerColor = e.detail.color;
            } else if (active.objectType === 'arrow') {
              var items = active.getObjects();
              if (items[0]) items[0].set('stroke', e.detail.color);
              if (items[1]) items[1].set('fill', e.detail.color);
              active.arrowColor = e.detail.color;
            } else if (active.objectType === 'area') {
              active.set('stroke', e.detail.color);
              active.areaColor = e.detail.color;
            }
            active.dirty = true;
            self.canvas.renderAll();
            self.isDirty = true;
            window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
          }
        }
      };
      window.addEventListener('anno-color-change', this._onColorChange);

      this._onStrokeWidthChange = function (e) {
        if (e.detail && e.detail.width) {
          self.currentStrokeWidth = e.detail.width;
          var active = self.canvas ? self.canvas.getActiveObject() : null;
          if (active) {
            if (active.objectType === 'arrow') {
              var items = active.getObjects();
              if (items[0]) items[0].set('strokeWidth', e.detail.width);
              if (items[1]) items[1].set({ width: 8 + e.detail.width * 2, height: 8 + e.detail.width * 2 });
              active.arrowStrokeWidth = e.detail.width;
            } else if (active.objectType === 'area') {
              active.set('strokeWidth', e.detail.width);
              active.areaStrokeWidth = e.detail.width;
            }
            active.dirty = true;
            self.canvas.renderAll();
            self.isDirty = true;
            window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
          }
        }
      };
      window.addEventListener('anno-stroke-width-change', this._onStrokeWidthChange);

      this._onLineDashChange = function (e) {
        if (e.detail) {
          self.currentLineDash = e.detail.dash || [];
          var active = self.canvas ? self.canvas.getActiveObject() : null;
          if (active) {
            var dashArr = self.currentLineDash.length > 0 ? self.currentLineDash.slice() : null;
            if (active.objectType === 'arrow') {
              var items = active.getObjects();
              if (items[0]) items[0].set('strokeDashArray', dashArr);
              active.arrowLineDash = self.currentLineDash.slice();
            } else if (active.objectType === 'area') {
              active.set('strokeDashArray', dashArr);
              active.areaLineDash = self.currentLineDash.slice();
            }
            active.dirty = true;
            self.canvas.renderAll();
            self.isDirty = true;
            window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
          }
        }
      };
      window.addEventListener('anno-line-dash-change', this._onLineDashChange);

      this._onImageLoaded = function (e) {
        if (e.detail && e.detail.path) {
          // Wait for x-show transition to reveal the canvas and DOM layout
          self._deferredLoad(
            '/maker/api/files/' + e.detail.path,
            e.detail.annotations || null
          );
        }
      };
      window.addEventListener('image-loaded', this._onImageLoaded);

      // ---- Initial load: check parent scope for existing file ----
      // Deferred to requestAnimationFrame so the browser has laid out the
      // container (x-cloak removed, x-show applied) before we read dimensions.
      var filePath = this.currentFilePath; // from parent recipeEditor scope
      if (filePath) {
        this._deferredLoad(
          '/maker/api/files/' + filePath,
          this.annotationsJson || null
        );
      } else {
        // No file yet — still resize so canvas is ready when image is uploaded
        requestAnimationFrame(function () {
          self.resizeCanvas();
        });
      }
    },

    /**
     * Deferred load: waits for the DOM to be fully laid out (via
     * requestAnimationFrame) then resizes the canvas and loads the image.
     * Falls back to a second attempt after 300ms if the container still
     * has zero width (e.g. Alpine x-show transition not yet complete).
     */
    _deferredLoad(imageUrl, annotationsJson) {
      var self = this;

      requestAnimationFrame(function () {
        self.resizeCanvas();

        // If container is still hidden / zero-width, retry after transition
        if (!self.canvas.width) {
          setTimeout(function () {
            self.resizeCanvas();
            self._loadImageAndAnnotations(imageUrl, annotationsJson);
          }, 350);
        } else {
          self._loadImageAndAnnotations(imageUrl, annotationsJson);
        }
      });
    },

    /**
     * Internal: load background image and optionally annotations.
     * Annotations are deferred until the image callback fires so that
     * canvas dimensions and imageScale are known before placing objects.
     */
    _loadImageAndAnnotations(imageUrl, annotationsJson) {
      this._pendingAnnotations = annotationsJson || null;
      this.loadBackgroundImage(imageUrl);
    },

    // ----------------------------------------------------------------
    // Background image
    // ----------------------------------------------------------------

    /**
     * Load an image or PDF as the canvas background.
     * For PDFs, renders the first page via PDF.js then uses it as background.
     * Scales to fit within the canvas while maintaining aspect ratio.
     *
     * @param {string} imageUrl - URL of the image or PDF to load
     */
    loadBackgroundImage(imageUrl) {
      if (!this.canvas) return;

      if (!this.canvas.width) {
        this.resizeCanvas();
      }

      var isPdf = imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');

      if (isPdf && typeof pdfjsLib !== 'undefined') {
        this._loadPdfBackground(imageUrl);
      } else {
        this._loadImageBackground(imageUrl);
      }
    },

    /**
     * Render first page of a PDF via PDF.js and set as canvas background.
     */
    _loadPdfBackground(pdfUrl) {
      var self = this;
      var loadingTask = pdfjsLib.getDocument(pdfUrl);

      loadingTask.promise.then(function (pdf) {
        return pdf.getPage(1);
      }).then(function (page) {
        // Render at 2x scale for crisp display
        var scale = 2;
        var viewport = page.getViewport({ scale: scale });

        // Off-screen canvas for rendering
        var offCanvas = document.createElement('canvas');
        offCanvas.width = viewport.width;
        offCanvas.height = viewport.height;
        var ctx = offCanvas.getContext('2d');

        var renderContext = {
          canvasContext: ctx,
          viewport: viewport,
        };

        page.render(renderContext).promise.then(function () {
          // Convert rendered PDF page to data URL, then load as Fabric image
          var dataUrl = offCanvas.toDataURL('image/png');
          self._loadImageBackground(dataUrl);
        });
      }).catch(function (err) {
        console.error('AnnotationEditor: failed to load PDF:', err);
      });
    },

    /**
     * Load a raster image URL as the canvas background.
     * The canvas is resized to fit the image exactly (no empty space).
     */
    _loadImageBackground(imageUrl) {
      var self = this;

      fabric.Image.fromURL(
        imageUrl,
        function (img) {
          if (!img || !img.width || !img.height) {
            console.error('AnnotationEditor: failed to load background image from', imageUrl);
            return;
          }

          self._bgImageNatW = img.width;
          self._bgImageNatH = img.height;

          self._fitCanvasToImage(img);
          self.imageLoaded = true;

          // Load pending annotations now that canvas is sized to the image
          if (self._pendingAnnotations) {
            self.loadAnnotationsJson(self._pendingAnnotations);
            self._pendingAnnotations = null;
          }
        }
      );
    },

    /**
     * Resize canvas to fit the background image exactly within the
     * container width.  Canvas dimensions = image dimensions * scale,
     * so there is zero empty space and canvas coords map 1:1 to
     * scaled-image coords.
     *
     * @param {fabric.Image} img - Fabric image object (background)
     */
    _fitCanvasToImage(img) {
      var fabricWrapper = this.canvasEl.parentElement;
      var container = fabricWrapper ? fabricWrapper.parentElement : null;
      if (!container) return;

      var containerWidth = container.clientWidth;
      if (!containerWidth) return;

      var natW = img.width || this._bgImageNatW;
      var natH = img.height || this._bgImageNatH;
      if (!natW || !natH) return;

      // Scale to fit container width; never upscale
      var scale = Math.min(containerWidth / natW, 1);
      var canvasW = Math.round(natW * scale);
      var canvasH = Math.round(natH * scale);

      this.canvas.setDimensions({ width: canvasW, height: canvasH });

      this.canvas.setBackgroundImage(
        img,
        this.canvas.renderAll.bind(this.canvas),
        {
          scaleX: scale,
          scaleY: scale,
          originX: 'left',
          originY: 'top',
        }
      );

      this.canvas.calcOffset();
      this.canvas.forEachObject(function (obj) { obj.setCoords(); });
      this.canvas.renderAll();
    },

    // ----------------------------------------------------------------
    // Object creation - Markers
    // ----------------------------------------------------------------

    /**
     * Add a numbered circle marker at the given position.
     *
     * @param {number} x - left position on canvas
     * @param {number} y - top position on canvas
     * @param {number} [number] - explicit marker number (auto-increments if omitted)
     * @returns {fabric.Group} the created marker group
     */
    addMarker(x, y, number) {
      var markerNumber = number || this.nextMarkerNumber++;

      var circle = new fabric.Circle({
        radius: 16,
        fill: this.currentColor,
        originX: 'center',
        originY: 'center',
        stroke: '#ffffff',
        strokeWidth: 2,
      });

      var text = new fabric.Text(markerNumber.toString(), {
        fontSize: 14,
        fill: '#ffffff',
        fontFamily: 'Inter, sans-serif',
        fontWeight: 'bold',
        originX: 'center',
        originY: 'center',
      });

      var marker = new fabric.Group([circle, text], {
        left: x,
        top: y,
        selectable: true,
        evented: true,
        hasControls: false,
        hasBorders: true,
        lockRotation: true,
        lockScalingX: true,
        lockScalingY: true,
        // Custom properties
        objectType: 'marker',
        markerNumber: markerNumber,
        markerColor: this.currentColor,
      });

      this.canvas.add(marker);
      marker.setCoords();
      this.canvas.setActiveObject(marker);
      this.isDirty = true;
      window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
      return marker;
    },

    // ----------------------------------------------------------------
    // Object creation - Arrows
    // ----------------------------------------------------------------

    /**
     * Add an arrow (line + triangular arrowhead) between two points.
     *
     * @param {number} x1 - start x
     * @param {number} y1 - start y
     * @param {number} x2 - end x
     * @param {number} y2 - end y
     * @returns {fabric.Group} the created arrow group
     */
    addArrow(x1, y1, x2, y2) {
      var line = new fabric.Line([x1, y1, x2, y2], {
        stroke: this.currentColor,
        strokeWidth: this.currentStrokeWidth,
        strokeDashArray: this.currentLineDash.length > 0 ? this.currentLineDash.slice() : null,
        originX: 'center',
        originY: 'center',
      });

      var angle = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI;

      var arrowHead = new fabric.Triangle({
        left: x2,
        top: y2,
        originX: 'center',
        originY: 'center',
        width: 8 + this.currentStrokeWidth * 2,
        height: 8 + this.currentStrokeWidth * 2,
        fill: this.currentColor,
        angle: angle + 90,
      });

      var arrow = new fabric.Group([line, arrowHead], {
        selectable: true,
        evented: true,
        hasControls: false,
        hasBorders: true,
        lockRotation: true,
        lockScalingX: true,
        lockScalingY: true,
        objectType: 'arrow',
        // Store original endpoints for serialization
        arrowX1: x1,
        arrowY1: y1,
        arrowX2: x2,
        arrowY2: y2,
        arrowColor: this.currentColor,
        arrowStrokeWidth: this.currentStrokeWidth,
        arrowLineDash: this.currentLineDash.slice(),
      });

      this.canvas.add(arrow);
      arrow.setCoords();
      this.canvas.setActiveObject(arrow);
      this.isDirty = true;
      window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
      return arrow;
    },

    // ----------------------------------------------------------------
    // Object creation - Rectangles
    // ----------------------------------------------------------------

    /**
     * Add a dashed-outline area rectangle.
     *
     * @param {number} x      - left position
     * @param {number} y      - top position
     * @param {number} [width=150]  - rectangle width
     * @param {number} [height=100] - rectangle height
     * @returns {fabric.Rect} the created rectangle
     */
    addRect(x, y, width, height) {
      var rect = new fabric.Rect({
        left: x,
        top: y,
        width: width || 150,
        height: height || 100,
        fill: 'rgba(0, 0, 0, 0)',
        stroke: this.currentColor,
        strokeWidth: this.currentStrokeWidth,
        strokeDashArray: this.currentLineDash.length > 0 ? this.currentLineDash.slice() : null,
        selectable: true,
        evented: true,
        hasControls: false,
        hasBorders: true,
        lockRotation: true,
        lockScalingX: true,
        lockScalingY: true,
        objectType: 'area',
        areaColor: this.currentColor,
        areaStrokeWidth: this.currentStrokeWidth,
        areaLineDash: this.currentLineDash.slice(),
      });

      this.canvas.add(rect);
      rect.setCoords();
      this.canvas.setActiveObject(rect);
      this.isDirty = true;
      window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
      return rect;
    },

    // ----------------------------------------------------------------
    // Object creation - Text
    // ----------------------------------------------------------------

    addText(x, y, textContent, fontSize) {
      var size = fontSize || 18;
      var itext = new fabric.IText(textContent || 'Testo', {
        left: x,
        top: y,
        fontSize: size,
        fill: this.currentColor,
        fontFamily: 'Inter, sans-serif',
        fontWeight: 'bold',
        selectable: true,
        evented: true,
        hasControls: false,
        hasBorders: true,
        lockRotation: true,
        lockScalingX: true,
        lockScalingY: true,
        objectType: 'text',
        textColor: this.currentColor,
        textFontSize: size,
      });

      var self = this;
      itext.on('editing:exited', function () {
        if (!itext.text || itext.text.trim() === '') {
          self.canvas.remove(itext);
        }
        itext.textColor = itext.fill;
        self.isDirty = true;
        window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
      });

      itext.on('changed', function () {
        self.isDirty = true;
      });

      this.canvas.add(itext);
      itext.setCoords();
      this.canvas.setActiveObject(itext);
      this.isDirty = true;
      window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
      return itext;
    },

    // ----------------------------------------------------------------
    // Toolbar / mode switching
    // ----------------------------------------------------------------

    /**
     * Set the current interaction mode.
     *
     * @param {'select'|'marker'|'arrow'|'rect'|'text'} mode
     */
    setMode(mode) {
      this.activeMode = mode;

      if (mode === 'select') {
        this.canvas.selection = true;
        this.canvas.defaultCursor = 'default';
        this.canvas.forEachObject(function (o) {
          o.selectable = true;
          o.evented = true;
        });
      } else {
        // marker, arrow, rect — disable object interaction during drawing
        this.canvas.selection = false;
        this.canvas.defaultCursor = 'crosshair';
        this.canvas.discardActiveObject();
        this.canvas.forEachObject(function (o) {
          o.selectable = false;
          o.evented = false;
        });
      }

      this.canvas.renderAll();
    },

    // ----------------------------------------------------------------
    // Mouse events
    // ----------------------------------------------------------------

    /**
     * Wire up all Fabric.js mouse events for drawing and selection.
     */
    setupEvents() {
      var self = this;
      var isDrawing = false;
      var drawStartX = 0;
      var drawStartY = 0;
      var tempRect = null;
      var tempLine = null;

      // ---- mouse:down ----

      this.canvas.on('mouse:down', function (opt) {
        // In select mode, let Fabric.js handle clicks without interference
        if (self.activeMode === 'select') return;

        var pointer = self.canvas.getPointer(opt.e);

        if (self.activeMode === 'marker') {
          self.addMarker(pointer.x, pointer.y);
          self.setMode('select');
          window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
        } else if (self.activeMode === 'text') {
          var textObj = self.addText(pointer.x, pointer.y);
          self.setMode('select');
          window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
          self.canvas.setActiveObject(textObj);
          textObj.enterEditing();
          textObj.selectAll();
        } else if (self.activeMode === 'arrow') {
          isDrawing = true;
          drawStartX = pointer.x;
          drawStartY = pointer.y;
          // Preview line
          tempLine = new fabric.Line(
            [drawStartX, drawStartY, drawStartX, drawStartY],
            {
              stroke: '#DC2626',
              strokeWidth: 2,
              strokeDashArray: [4, 4],
              selectable: false,
              evented: false,
              objectType: '_temp',
            }
          );
          self.canvas.add(tempLine);
        } else if (self.activeMode === 'rect') {
          isDrawing = true;
          drawStartX = pointer.x;
          drawStartY = pointer.y;
          // Preview rect
          tempRect = new fabric.Rect({
            left: drawStartX,
            top: drawStartY,
            width: 0,
            height: 0,
            fill: 'rgba(37, 99, 235, 0.05)',
            stroke: '#2563EB',
            strokeWidth: 1,
            strokeDashArray: [4, 4],
            selectable: false,
            evented: false,
            objectType: '_temp',
          });
          self.canvas.add(tempRect);
        }
      });

      // ---- mouse:move ----

      this.canvas.on('mouse:move', function (opt) {
        if (!isDrawing) return;
        var pointer = self.canvas.getPointer(opt.e);

        if (self.activeMode === 'arrow' && tempLine) {
          tempLine.set({ x2: pointer.x, y2: pointer.y });
          self.canvas.requestRenderAll();
        } else if (self.activeMode === 'rect' && tempRect) {
          var x = Math.min(drawStartX, pointer.x);
          var y = Math.min(drawStartY, pointer.y);
          var w = Math.abs(pointer.x - drawStartX);
          var h = Math.abs(pointer.y - drawStartY);
          tempRect.set({ left: x, top: y, width: w, height: h });
          self.canvas.requestRenderAll();
        }
      });

      // ---- mouse:up ----

      this.canvas.on('mouse:up', function (opt) {
        // Clean up temp preview objects
        if (tempLine) {
          self.canvas.remove(tempLine);
          tempLine = null;
        }
        if (tempRect) {
          self.canvas.remove(tempRect);
          tempRect = null;
        }

        if (isDrawing) {
          var pointer = self.canvas.getPointer(opt.e);

          if (self.activeMode === 'arrow') {
            var dx = pointer.x - drawStartX;
            var dy = pointer.y - drawStartY;
            var dist = Math.sqrt(dx * dx + dy * dy);
            if (dist > 10) {
              self.addArrow(drawStartX, drawStartY, pointer.x, pointer.y);
            }
          } else if (self.activeMode === 'rect') {
            var w = Math.abs(pointer.x - drawStartX);
            var h = Math.abs(pointer.y - drawStartY);
            var x = Math.min(drawStartX, pointer.x);
            var y = Math.min(drawStartY, pointer.y);
            if (w > 5 && h > 5) {
              self.addRect(x, y, w, h);
            }
          }

          isDrawing = false;
          self.setMode('select');
          window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
        }
      });

      // ---- Selection events ----

      this.canvas.on('selection:created', function (e) {
        var sel = e.selected && e.selected[0];
        var objType = sel ? sel.objectType : null;
        window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true, objectType: objType } }));
        var selected = e.selected;
        if (selected && selected.length === 1 && selected[0].objectType === 'marker') {
          window.dispatchEvent(new CustomEvent('marker-selected', {
            detail: { markerNumber: selected[0].markerNumber }
          }));
        }
        // Force coords + re-render so controls are visible on lower canvas
        var active = self.canvas.getActiveObject();
        if (active) {
          active.setCoords();
          self.canvas.requestRenderAll();
        }
      });

      this.canvas.on('selection:updated', function (e) {
        var sel = e.selected && e.selected[0];
        var objType = sel ? sel.objectType : null;
        window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true, objectType: objType } }));
        var selected = e.selected;
        if (selected && selected.length === 1 && selected[0].objectType === 'marker') {
          window.dispatchEvent(new CustomEvent('marker-selected', {
            detail: { markerNumber: selected[0].markerNumber }
          }));
        }
        // Force coords + re-render so controls are visible on lower canvas
        var active = self.canvas.getActiveObject();
        if (active) {
          active.setCoords();
          self.canvas.requestRenderAll();
        }
      });

      this.canvas.on('selection:cleared', function () {
        window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: false } }));
        window.dispatchEvent(new CustomEvent('marker-selected', { detail: { markerNumber: null } }));
      });

      // ---- Object modification ----

      this.canvas.on('object:modified', function (e) {
        // Update arrow stored endpoints after a move so serialization
        // reflects the current position (not the original creation coords).
        var obj = e.target;
        if (obj && obj.objectType === 'arrow' && e.transform) {
          var dx = obj.left - e.transform.original.left;
          var dy = obj.top - e.transform.original.top;
          obj.arrowX1 = (obj.arrowX1 || 0) + dx;
          obj.arrowY1 = (obj.arrowY1 || 0) + dy;
          obj.arrowX2 = (obj.arrowX2 || 0) + dx;
          obj.arrowY2 = (obj.arrowY2 || 0) + dy;
        }
        self.isDirty = true;
        window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
      });

    },

    // ----------------------------------------------------------------
    // Keyboard shortcuts
    // ----------------------------------------------------------------

    /**
     * Register global keyboard shortcuts.
     * Ignored when focus is inside input / textarea elements.
     */
    setupKeyboard() {
      var self = this;

      this._onKeyDown = function (e) {
        // Skip when typing in form fields
        var tag = e.target.tagName;
        if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
        if (e.target.isContentEditable) return;

        if (e.key === 'Delete' || e.key === 'Backspace') {
          e.preventDefault();
          self.deleteSelected();
        } else if (e.key === 'Escape') {
          self.setMode('select');
          self.canvas.discardActiveObject();
          self.canvas.renderAll();
        }
      };
      document.addEventListener('keydown', this._onKeyDown);
    },

    // ----------------------------------------------------------------
    // Object manipulation
    // ----------------------------------------------------------------

    /**
     * Delete the currently selected object(s) from the canvas.
     */
    deleteSelected() {
      var active = this.canvas.getActiveObject();
      if (!active) return;

      if (active.type === 'activeSelection') {
        var self = this;
        active.forEachObject(function (obj) {
          self.canvas.remove(obj);
        });
        this.canvas.discardActiveObject();
      } else {
        this.canvas.remove(active);
      }

      this.isDirty = true;
      window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
      this.canvas.renderAll();
    },

    /**
     * Clear all annotation objects from the canvas (keeps background image).
     */
    clearAll() {
      this.canvas.getObjects().slice().forEach(
        function (obj) {
          this.canvas.remove(obj);
        }.bind(this)
      );
      this.nextMarkerNumber = 1;
      this.isDirty = true;
      window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
      this.canvas.renderAll();
    },

    // ----------------------------------------------------------------
    // Canvas sizing
    // ----------------------------------------------------------------

    /**
     * Resize the Fabric.js canvas to fill its parent container.
     * Maintains a minimum 5:3 aspect ratio (height = width * 0.6).
     *
     * Note: Fabric.js wraps the <canvas> in its own div, so parentElement
     * is the Fabric wrapper (which has a fixed pixel width from creation).
     * We need the .canvas-container grandparent for the real layout width.
     */
    resizeCanvas() {
      if (!this.canvasEl || !this.canvas) return;

      // If a background image is loaded, fit canvas to its dimensions
      if (this.canvas.backgroundImage && this._bgImageNatW && this._bgImageNatH) {
        this._fitCanvasToImage(this.canvas.backgroundImage);
        return;
      }

      // No image yet — use placeholder size so canvas is visible
      var fabricWrapper = this.canvasEl.parentElement;
      var container = fabricWrapper ? fabricWrapper.parentElement : null;
      if (!container) return;

      var width = container.clientWidth;
      var height = Math.max(400, Math.round(width * 0.6));

      this.canvas.setDimensions({ width: width, height: height });
      this.canvas.calcOffset();
      this.canvas.renderAll();
    },

    // ----------------------------------------------------------------
    // Serialization - Export
    // ----------------------------------------------------------------

    /**
     * Serialize all canvas annotation objects to a JSON string.
     *
     * @returns {string} JSON representation of all annotations
     */
    getAnnotationsJson() {
      var objects = this.canvas.getObjects().map(function (obj) {
        var data = {
          type: obj.objectType || obj.type,
          left: Math.round(obj.left * 100) / 100,
          top: Math.round(obj.top * 100) / 100,
          width: Math.round((obj.width || 0) * 100) / 100,
          height: Math.round((obj.height || 0) * 100) / 100,
          markerNumber: obj.markerNumber || null,
        };

        // Color and stroke width
        if (obj.markerColor) data.fill = obj.markerColor;
        if (obj.arrowColor) data.stroke = obj.arrowColor;
        if (obj.areaColor) data.stroke = obj.areaColor;
        if (obj.arrowStrokeWidth) data.strokeWidth = obj.arrowStrokeWidth;
        if (obj.areaStrokeWidth) data.strokeWidth = obj.areaStrokeWidth;
        if (obj.arrowLineDash) data.lineDash = obj.arrowLineDash;
        if (obj.areaLineDash) data.lineDash = obj.areaLineDash;

        // Text-specific
        if (obj.objectType === 'text') {
          data.textContent = obj.text;
          data.textColor = obj.textColor || obj.fill;
          data.fontSize = obj.textFontSize || obj.fontSize;
        }

        // Arrow-specific: store original endpoints
        if (obj.objectType === 'arrow') {
          data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
          data.y1 = obj.arrowY1 != null ? Math.round(obj.arrowY1 * 100) / 100 : null;
          data.x2 = obj.arrowX2 != null ? Math.round(obj.arrowX2 * 100) / 100 : null;
          data.y2 = obj.arrowY2 != null ? Math.round(obj.arrowY2 * 100) / 100 : null;
        }

        return data;
      });

      // Save background image scale so the preview can convert canvas coords
      // to image coords correctly regardless of editor canvas dimensions.
      var bgImg = this.canvas.backgroundImage;
      var imageScale = bgImg ? (bgImg.scaleX || 1) : 1;

      return JSON.stringify({
        version: 2,
        width: this.canvas.width,
        height: this.canvas.height,
        imageScale: imageScale,
        objects: objects,
      });
    },

    // ----------------------------------------------------------------
    // Serialization - Import
    // ----------------------------------------------------------------

    /**
     * Load annotations from a JSON string and recreate all objects on the canvas.
     * Clears any existing annotation objects first (keeps background image).
     *
     * @param {string|Object} jsonString - JSON string or parsed object
     */
    loadAnnotationsJson(jsonString) {
      if (!jsonString) return;

      try {
        var data =
          typeof jsonString === 'string' ? JSON.parse(jsonString) : jsonString;

        // Coordinate scale factor: annotations may have been saved at a
        // different canvas size (different screen width).  Since canvas
        // coords = imageCoords * imageScale, we convert:
        //   currentCoord = savedCoord * (currentImageScale / savedImageScale)
        var savedImageScale = data.imageScale || 1;
        var bgImg = this.canvas.backgroundImage;
        var currentImageScale = bgImg ? (bgImg.scaleX || 1) : 1;
        var cs = currentImageScale / savedImageScale; // coordinate scale

        // Remove existing objects
        this.canvas.getObjects().slice().forEach(
          function (obj) {
            this.canvas.remove(obj);
          }.bind(this)
        );

        var objects = data.objects || [];

        for (var i = 0; i < objects.length; i++) {
          var obj = objects[i];

          if (obj.type === 'marker') {
            var savedColor = this.currentColor;
            if (obj.fill) this.currentColor = obj.fill;
            this.addMarker(obj.left * cs, obj.top * cs, obj.markerNumber);
            this.currentColor = savedColor;
          } else if (obj.type === 'arrow') {
            var savedColor2 = this.currentColor;
            var savedWidth = this.currentStrokeWidth;
            var savedDash = this.currentLineDash.slice();
            if (obj.stroke) this.currentColor = obj.stroke;
            if (obj.strokeWidth) this.currentStrokeWidth = obj.strokeWidth;
            if (obj.lineDash) this.currentLineDash = obj.lineDash;
            else this.currentLineDash = [];
            // Reconstruct arrow from stored endpoints, scaled to current canvas
            var x1 = (obj.x1 != null ? obj.x1 : obj.left) * cs;
            var y1 = (obj.y1 != null ? obj.y1 : obj.top) * cs;
            var x2 = (obj.x2 != null ? obj.x2 : obj.left + (obj.width || 100)) * cs;
            var y2 = (obj.y2 != null ? obj.y2 : obj.top + (obj.height || 50)) * cs;
            this.addArrow(x1, y1, x2, y2);
            this.currentColor = savedColor2;
            this.currentStrokeWidth = savedWidth;
            this.currentLineDash = savedDash;
          } else if (obj.type === 'area') {
            var savedColor3 = this.currentColor;
            var savedWidth2 = this.currentStrokeWidth;
            var savedDash2 = this.currentLineDash.slice();
            if (obj.stroke) this.currentColor = obj.stroke;
            if (obj.strokeWidth) this.currentStrokeWidth = obj.strokeWidth;
            if (obj.lineDash) this.currentLineDash = obj.lineDash;
            else this.currentLineDash = [];
            this.addRect(obj.left * cs, obj.top * cs, (obj.width || 150) * cs, (obj.height || 100) * cs);
            this.currentColor = savedColor3;
            this.currentStrokeWidth = savedWidth2;
            this.currentLineDash = savedDash2;
          } else if (obj.type === 'text') {
            var savedColor4 = this.currentColor;
            if (obj.textColor) this.currentColor = obj.textColor;
            this.addText(obj.left * cs, obj.top * cs, obj.textContent, (obj.fontSize || 18) * cs);
            this.currentColor = savedColor4;
          }
        }

        // Recalculate nextMarkerNumber
        var markerNumbers = objects
          .filter(function (o) {
            return o.type === 'marker' && o.markerNumber;
          })
          .map(function (o) {
            return o.markerNumber;
          });

        this.nextMarkerNumber =
          markerNumbers.length > 0 ? Math.max.apply(null, markerNumbers) + 1 : 1;

        this.isDirty = false;
        this.canvas.discardActiveObject();
        this.canvas.renderAll();
      } catch (e) {
        console.error('AnnotationEditor: failed to load annotations:', e);
      }
    },

    // ----------------------------------------------------------------
    // Marker query helpers
    // ----------------------------------------------------------------

    /**
     * Get a sorted list of all markers currently on the canvas.
     *
     * @returns {Array<{number: number, left: number, top: number}>}
     */
    getMarkers() {
      return this.canvas
        .getObjects()
        .filter(function (obj) {
          return obj.objectType === 'marker';
        })
        .map(function (obj) {
          return {
            number: obj.markerNumber,
            left: Math.round(obj.left * 100) / 100,
            top: Math.round(obj.top * 100) / 100,
          };
        })
        .sort(function (a, b) {
          return a.number - b.number;
        });
    },

    /**
     * Highlight a specific marker by its number.
     * Selects it on the canvas so the user sees the Fabric selection border.
     *
     * @param {number} markerNumber
     */
    selectMarkerByNumber(markerNumber) {
      var target = null;
      this.canvas.forEachObject(function (obj) {
        if (obj.objectType === 'marker' && obj.markerNumber === markerNumber) {
          target = obj;
        }
      });

      if (target) {
        this.canvas.setActiveObject(target);
        this.canvas.renderAll();
      }
    },

    // ----------------------------------------------------------------
    // Cleanup
    // ----------------------------------------------------------------

    /**
     * Destroy the Fabric.js canvas and remove event listeners.
     * Call this when the Alpine component is removed from the DOM.
     */
    destroy() {
      if (this._debouncedResize) {
        window.removeEventListener('resize', this._debouncedResize);
      }
      if (this._onToolChange) {
        window.removeEventListener('anno-tool-change', this._onToolChange);
      }
      if (this._onDeleteSelected) {
        window.removeEventListener('anno-delete-selected', this._onDeleteSelected);
      }
      if (this._onClearAll) {
        window.removeEventListener('anno-clear-all', this._onClearAll);
      }
      if (this._onColorChange) {
        window.removeEventListener('anno-color-change', this._onColorChange);
      }
      if (this._onStrokeWidthChange) {
        window.removeEventListener('anno-stroke-width-change', this._onStrokeWidthChange);
      }
      if (this._onLineDashChange) {
        window.removeEventListener('anno-line-dash-change', this._onLineDashChange);
      }
      if (this._onImageLoaded) {
        window.removeEventListener('image-loaded', this._onImageLoaded);
      }
      if (this._onKeyDown) {
        document.removeEventListener('keydown', this._onKeyDown);
      }
      if (this.canvas) {
        this.canvas.dispose();
        this.canvas = null;
      }
    },
  };
}
