Source: modules/schematic.js

/**
 * Manages the schematic editor canvas, including drawing components,
 * handling user interactions (pan, zoom, clicks), and managing component data per floor.
 */
import { fetchComponentsByModel } from './servlet.js';
import { componentFields } from './forms.js';

export class SchematicEditor {
  /**
   * Creates an instance of SchematicEditor.
   * @param {string} canvasId - The ID of the HTML canvas element.
   * @param {object} componentModels - An object containing arrays of available component models (e.g., { cables: [], derivadores: [], ... }).
   */
  constructor(canvasId, componentModels) {
    this.canvas = document.getElementById(canvasId);
    if (!this.canvas) {
      console.error('Canvas element not found:', canvasId);
      return; // Prevent further initialization
    }
    this.ctx = this.canvas.getContext('2d');

    // --- State ---
    this.currentFloor = null; // Start with no floor selected
    this.scale = 1;
    this.offsetX = 0;
    this.offsetY = 0;
    this.isDragging = false; // Is the mouse button currently down?
    this.dragStarted = false; // Did the mouse move significantly after mousedown?
    this.startX = 0; // Mousedown X coordinate
    this.startY = 0; // Mousedown Y coordinate
    this.lastX = 0; // Previous mousemove X
    this.lastY = 0; // Previous mousemove Y
    this.firstRender = true; // Flag for initial view centering
    this.resizeObserver = null;

    // --- Component Data ---
    // Map<floorNumber, { derivador: string|null, distribuidores: [string|null, string|null], tomasLeft: string[], tomasRight: string[] }>
    this.floorComponents = new Map();
    this.componentModels = this._validateModels(componentModels);
    this.selectedCable = this.componentModels.cables[0] || null; // Default cable

    // --- Layout Constants ---
    this.LAYOUT = {
      GRID_SIZE: 20,
      COMPONENT_SIZE: 40, // Visual size on canvas
      CLICK_AREA_SIZE: 45, // Slightly larger click area
      FLOOR_SPACING: 240,
      DE_X: 0,
      DI_X_OFFSET: 100,
      BT_X_OFFSET_FROM_DI: 80,
      BT_VERTICAL_SPACING: 45,
      CABLE_DE_DI: '5m',
      CABLE_DI_BT: '10m',
      CABLE_INTERFLOOR: '3m',
      PLACEHOLDER_ALPHA: 0.3,
      CONNECTION_LINE_WIDTH: 1.5, // Slightly thinner lines
      COMPONENT_BORDER_WIDTH: 1,
      FONT_SIZE_TYPE: 11, // Adjusted font sizes
      FONT_SIZE_MODEL: 9,
      FONT_SIZE_LABEL: 10,
      FONT_SIZE_FLOOR: 13,
      DRAG_THRESHOLD: 5, // Increased threshold
    };

    // --- Colors ---
    this.COLORS = {
      bt: { light: '#22c55e', dark: '#4ade80' }, // Example: Keep Green or switch to a neutral zinc
      di: { light: '#0ea5e9', dark: '#38bdf8' }, // Example: Keep Blue or switch to a neutral zinc
      de: { light: '#f59e0b', dark: '#facc15' }, // Example: Keep Amber or switch to a neutral zinc
      cable: { light: '#a1a1aa', dark: '#d4d4d8' }, // Zinc 400 / Zinc 300
      textLightBg: { light: '#ffffff', dark: '#ffffff' }, // White text on dark components still likely best
      textDarkBg: { light: '#18181b', dark: '#f4f4f5' }, // Zinc 900 / Zinc 100 (for text on light/dark backgrounds)
      placeholderStroke: { light: '#a1a1aa', dark: '#71717a' }, // Zinc 400 / Zinc 500
      placeholderFill: { light: 'rgba(228, 228, 231, 0.1)', dark: 'rgba(113, 113, 122, 0.1)' }, // Zinc 200 / Zinc 500 transparent
      componentStroke: { light: 'rgba(0, 0, 0, 0.2)', dark: 'rgba(255, 255, 255, 0.15)' }, // Subtler strokes
      grid: { light: 'rgba(0, 0, 0, 0.06)', dark: 'rgba(255, 255, 255, 0.06)' }, // Fainter grid
      tempMessageBg: { light: '#fefce8', dark: '#3f3f46' }, // Lighter Yellow / Zinc 700
      tempMessageBorder: { light: '#eab308', dark: '#52525b' }, // Yellow 600 / Zinc 600
      tempMessageText: { light: '#713f12', dark: '#e4e4e7' }, // Amber 900 / Zinc 200
    };

    requestAnimationFrame(() => {
      this.initializeCanvas();
      this.setupEventListeners();
      this.render();
    });
  }

  // --- Helper Methods ---

  /**
   * Validates and ensures component models are arrays.
   * @private
   * @param {Object} models - Object containing component model arrays
   * @returns {Object} Validated models object with default empty arrays
   */
  _validateModels(models) {
    const validated = { cables: [], derivadores: [], distribuidores: [], tomas: [] };
    for (const key in validated) {
      if (models && Array.isArray(models[key])) {
        validated[key] = models[key];
      } else {
        console.warn(`Component models for '${key}' are missing or not an array.`);
      }
    }
    return validated;
  }

  /**
   * Gets the appropriate color based on the current theme.
   * @private
   * @param {string} name - The color name from the COLORS object
   * @param {string} [theme] - Optional theme override ('light' or 'dark')
   * @returns {string} The color value (hex or rgba)
   */
  _getColor(name, theme) {
    const themeKey =
      theme || (document.documentElement.classList.contains('dark') ? 'dark' : 'light');
    return this.COLORS[name]?.[themeKey] || '#CCCCCC'; // Fallback color
  }

  /**
   * Creates a default structure for storing components on a floor.
   * @private
   * @returns {Object} Floor data object with null/empty component slots
   */
  _createEmptyFloorData() {
    // Each floor has one derivador, two distribuidores, and two sets of tomas
    return {
      derivador: null,
      distribuidores: [null, null],
      tomasLeft: [],
      tomasRight: [],
    };
  }

  /**
   * (Re)Initializes the schematic editor models.
   * @param {Object} newModels - New component models object
   */
  updateModels(newModels) {
    this.componentModels = this._validateModels(newModels);
    // Update selected cable if the current one is no longer valid
    if (!this.componentModels.cables.includes(this.selectedCable)) {
      this.selectedCable = this.componentModels.cables[0] || null;
    }
    console.log('Schematic models updated:', this.componentModels);
  }

  /**
   * Initializes canvas size, DPR (Device Pixel Ratio), and ResizeObserver.
   * Ensures the canvas renders crisply on high-DPI displays.
   */
  initializeCanvas() {
    const updateCanvasSize = () => {
      if (!this.canvas || !this.canvas.parentElement) return;
      const rect = this.canvas.parentElement.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
      const dpr = window.devicePixelRatio || 1;
      this.canvas.width = rect.width * dpr;
      this.canvas.height = rect.height * dpr;
      this.ctx.resetTransform();
      this.ctx.scale(dpr, dpr);
      this.canvas.style.width = `${rect.width}px`;
      this.canvas.style.height = `${rect.height}px`;
      if (this.firstRender) {
        this.resetView();
        this.firstRender = false;
      }
      this.render();
    };
    updateCanvasSize();
    if (!this.resizeObserver && this.canvas.parentElement) {
      this.resizeObserver = new ResizeObserver(updateCanvasSize);
      this.resizeObserver.observe(this.canvas.parentElement);
    }
  }

  /**
   * Sets up event listeners for mouse interactions and wheel zoom.
   */
  setupEventListeners() {
    if (!this.canvas) return;
    this.canvas.addEventListener('mousedown', this._handleMouseDown);
    window.addEventListener('mousemove', this._handleMouseMove);
    window.addEventListener('mouseup', this._handleMouseUp);
    this.canvas.addEventListener('mouseleave', this._handleMouseLeave);
    this.canvas.style.cursor = 'grab';
    this.canvas.addEventListener('wheel', this._handleWheel, { passive: false });
  }

  // --- Event Handlers (Bound methods using arrow functions for correct 'this') ---

  /**
   * Handles mouse down events - initiates dragging.
   * @private
   * @param {MouseEvent} e - The mouse event
   */
  _handleMouseDown = (e) => {
    if (e.button !== 0) return;
    this.isDragging = true;
    this.dragStarted = false;
    this.startX = e.clientX;
    this.startY = e.clientY;
    this.lastX = e.clientX;
    this.lastY = e.clientY;
    this.canvas.style.cursor = 'grabbing';
  };

  /**
   * Handles mouse move events - pans the view when dragging.
   * @private
   * @param {MouseEvent} e - The mouse event
   */
  _handleMouseMove = (e) => {
    if (this.isDragging) {
      if (
        !this.dragStarted &&
        (Math.abs(e.clientX - this.startX) > this.LAYOUT.DRAG_THRESHOLD ||
          Math.abs(e.clientY - this.startY) > this.LAYOUT.DRAG_THRESHOLD)
      ) {
        this.dragStarted = true;
      }
      if (this.dragStarted) {
        const deltaX = e.clientX - this.lastX;
        const deltaY = e.clientY - this.lastY;
        this.offsetX += deltaX;
        this.offsetY += deltaY;
        this.render();
      }
      this.lastX = e.clientX;
      this.lastY = e.clientY;
    }
  };

  /**
   * Handles mouse up events - ends dragging or processes clicks.
   * @private
   * @param {MouseEvent} e - The mouse event
   */
  _handleMouseUp = (e) => {
    if (e.button !== 0) return;
    let isOverCanvas = false;
    if (this.canvas) {
      const rect = this.canvas.getBoundingClientRect();
      isOverCanvas =
        e.clientX >= rect.left &&
        e.clientX <= rect.right &&
        e.clientY >= rect.top &&
        e.clientY <= rect.bottom;
    }
    if (this.isDragging) {
      if (isOverCanvas && !this.dragStarted) {
        this._handleClick(e);
      }
    }
    this.isDragging = false;
    this.dragStarted = false;
    if (this.canvas) this.canvas.style.cursor = 'grab';
  };

  /**
   * Handles mouse leave events - resets cursor.
   * @private
   */
  _handleMouseLeave = () => {
    if (this.canvas) this.canvas.style.cursor = 'grab';
  };

  /**
   * Handles mouse wheel events for zooming.
   * @private
   * @param {WheelEvent} e - The wheel event
   */
  _handleWheel = (e) => {
    e.preventDefault();
    const delta = e.deltaY > 0 ? -1 : 1;
    const zoomFactor = 1.1;
    const zoom = delta > 0 ? zoomFactor : 1 / zoomFactor;
    this._zoomTowardsPoint(zoom, e.clientX, e.clientY);
  };

  /**
   * Handles click events on the canvas to detect component clicks.
   * Opens component selector popup when clicking on component slots.
   * @private
   * @param {MouseEvent} e - The mouse event
   */
  _handleClick = (e) => {
    if (!this.canvas) return;
    const rect = this.canvas.getBoundingClientRect();
    const clickX = (e.clientX - rect.left - this.offsetX) / this.scale;
    const clickY = (e.clientY - rect.top - this.offsetY) / this.scale;
    const sortedFloors = this._getSortedFloorNumbers();
    if (sortedFloors.length > 0) {
      if (
        this.nextFloorPlaceholder &&
        this._isPointInArea(clickX, clickY, this.nextFloorPlaceholder)
      ) {
        const configSelect = document.getElementById('simulation-config');
        if (!configSelect) return;
        const selectedOption = configSelect.options[configSelect.selectedIndex];
        if (!selectedOption) return;
        const configData = JSON.parse(selectedOption.dataset.config || '{}');
        const maxFloors = configData.num_pisos || 0;
        if (sortedFloors.length >= maxFloors) {
          this._showTemporaryMessage(
            `No se pueden añadir más pisos. Límite: ${maxFloors} pisos.`,
            e.clientX,
            e.clientY,
          );
          return;
        }
        const nextFloor = Math.max(...sortedFloors) + 1;
        this.setCurrentFloor(nextFloor);
        const floorSelect = document.getElementById('simulation-floor');
        if (floorSelect) {
          floorSelect.value = nextFloor;
        }
        return;
      }
    }
    if (sortedFloors.length === 0) {
      const viewCenterX = (rect.width / 2 - this.offsetX) / this.scale;
      const viewCenterY = (rect.height / 2 - this.offsetY) / this.scale;
      const placeholderSize = this.LAYOUT.COMPONENT_SIZE;
      if (
        this._isPointInArea(clickX, clickY, {
          x: viewCenterX,
          y: viewCenterY,
          size: placeholderSize,
        })
      ) {
        this.setCurrentFloor(1);
        const floorSelect = document.getElementById('simulation-floor');
        if (floorSelect) {
          floorSelect.value = 1;
        }
        return;
      }
    }
    for (let i = 0; i < sortedFloors.length; i++) {
      const floor = sortedFloors[i];
      const floorY = this._getFloorCenterY(i);
      const clickableAreas = this._getClickableAreas(floor, floorY);
      for (const area of clickableAreas) {
        if (this._isPointInArea(clickX, clickY, area)) {
          // Skip left side components (positionIndex 0)
          if (area.type !== 'DE' && area.positionIndex === 0) {
            this._showTemporaryMessage(
              'Los componentes del lado izquierdo son simétricos respecto al lado derecho.',
              e.clientX,
              e.clientY,
            );
            return;
          }
          // *** Floor Activation Check ***
          if (this.currentFloor === null) {
            console.warn('Interaction prevented: No floor selected in the UI.');
            this._showTemporaryMessage(
              'Seleccione un piso en el panel izquierdo para editar.',
              e.clientX,
              e.clientY,
            );
            return; // Prevent interaction
          }
          if (this.currentFloor !== floor) {
            this.setCurrentFloor(floor);
            // Update the floor selector in the UI
            const floorSelect = document.getElementById('simulation-floor');
            if (floorSelect) {
              floorSelect.value = floor;
            }
            return;
          }
          // If checks pass, show the selector
          this._showComponentSelector(area.type, floor, area.positionIndex, e, area.slotIndex);
          return; // Stop after finding the first hit
        }
      }
    }
  };

  // --- Zoom/Pan Helpers ---

  /**
   * Zooms towards a specific point on the canvas.
   * @private
   * @param {number} factor - The zoom factor (>1 to zoom in, <1 to zoom out)
   * @param {number} clientX - The X coordinate to zoom towards
   * @param {number} clientY - The Y coordinate to zoom towards
   */
  _zoomTowardsPoint(factor, clientX, clientY) {
    if (!this.canvas) return;
    const newScale = this.scale * factor;
    if (newScale < 0.1 || newScale > 5) return; // Clamp zoom
    const rect = this.canvas.getBoundingClientRect();
    const mouseX = clientX - rect.left; // Mouse position relative to canvas element
    const mouseY = clientY - rect.top;
    // Adjust offset to keep the point under the mouse stationary
    this.offsetX = mouseX - (mouseX - this.offsetX) * factor;
    this.offsetY = mouseY - (mouseY - this.offsetY) * factor;
    this.scale = newScale;
    this.render();
  }

  /**
   * Resets the view (zoom and pan) to the default centered state.
   * Automatically adjusts to fit all floors in the viewport.
   */
  resetView() {
    if (!this.canvas) return;
    const rect = this.canvas.getBoundingClientRect();
    const sortedFloors = this._getSortedFloorNumbers();
    if (sortedFloors.length === 0) {
      this.scale = 1;
      this.offsetX = rect.width / 2;
      this.offsetY = rect.height * 0.4;
      this.render();
      return;
    }
    const totalFloors = sortedFloors.length;
    const totalHeight = totalFloors * this.LAYOUT.FLOOR_SPACING;
    const padding = this.LAYOUT.COMPONENT_SIZE * 2;
    const requiredHeight = totalHeight + padding;
    const heightScale = rect.height / requiredHeight;
    const rightmostX = this._getBtGroupX(1) + this.LAYOUT.COMPONENT_SIZE;
    const leftmostX = this._getDiX(0) - this.LAYOUT.COMPONENT_SIZE;
    const requiredWidth = rightmostX - leftmostX + padding;
    const widthScale = rect.width / requiredWidth;
    this.scale = Math.min(heightScale, widthScale, 1);
    this.offsetX = rect.width / 2;
    const firstFloorY = this._getFloorCenterY(totalFloors - 1);
    const lastFloorY = this._getFloorCenterY(0);
    const centerY = (firstFloorY + lastFloorY) / 2;
    this.offsetY = rect.height / 2 - centerY * this.scale;
    this.render();
  }

  /**
   * Clears all components from the schematic and resets the view.
   */
  clearSchematic() {
    this.floorComponents.clear();
    this.currentFloor = null;
    this.resetView();
    this.render();
    console.log('Schematic cleared');
  }

  /**
   * Applies a zoom factor centered on the canvas view.
   * @param {number} factor - The zoom factor
   */
  zoom(factor) {
    if (!this.canvas) return;
    const rect = this.canvas.getBoundingClientRect();
    this._zoomTowardsPoint(factor, rect.left + rect.width / 2, rect.top + rect.height / 2);
  }

  // --- Component Management ---

  /**
   * Sets the currently active floor. Ensures floor data exists.
   * @param {number|null} floor - The floor number to set as active, or null to deselect
   */
  setCurrentFloor(floor) {
    // Allow setting to null
    if (floor === null) {
      this.currentFloor = null;
      console.log('Current floor set to null (no floor selected)');
      return;
    }
    // Validate if it's a number
    if (typeof floor === 'number' && floor > 0) {
      this.currentFloor = floor;
      // Ensure floor data object exists when a floor is selected
      if (!this.floorComponents.has(floor)) {
        this.floorComponents.set(floor, this._createEmptyFloorData());
        // Re-render if adding a floor structure changed the layout needs
        this.render();
      }
      console.log('Current floor set to:', this.currentFloor);
    } else {
      console.warn('Invalid floor number provided:', floor);
      // Optionally reset currentFloor if invalid value received?
      // this.currentFloor = null;
    }
  }

  /**
   * Sets the cable type for connections.
   * @param {string} type - The cable model name
   */
  setCableType(type) {
    if (this.componentModels.cables.includes(type)) {
      this.selectedCable = type;
      console.log('Selected cable type:', this.selectedCable);
    } else if (type === null || type === undefined || type === '') {
      this.selectedCable = null; // Allow clearing cable selection
      console.log('Cable type cleared.');
    } else {
      console.warn(`Invalid cable type ${type} provided.`);
    }
  }

  /**
   * Sets or removes a component value at a specific position.
   * @private
   * @param {number} floor - The floor number
   * @param {string} type - Component type ('DE', 'DI', 'BT')
   * @param {number} positionIndex - The position index (0 or 1 for DI/BT)
   * @param {string|null} model - The component model or null to remove
   * @returns {boolean} True if the component was changed
   */
  _setComponentValue(floor, type, positionIndex, model) {
    if (floor === null || !this.floorComponents.has(floor)) {
      console.error('Cannot set component: Floor data not found for', floor);
      return false;
    }
    // Allow model to be null (for removal)
    if (model !== null) {
      // Validate model exists for the given type
      const validModels = {
        DE: this.componentModels.derivadores,
        DI: this.componentModels.distribuidores,
      }[type];
      if (!validModels || !validModels.includes(model)) {
        console.error(`Invalid model '${model}' for type '${type}'.`);
        return false;
      }
    }

    const floorData = this.floorComponents.get(floor);
    let changed = false;

    switch (type) {
      case 'DE':
        if (floorData.derivador !== model) {
          floorData.derivador = model;
          changed = true;
          console.log(`Set Floor ${floor} DE to: ${model}`);
        }
        break;
      case 'DI':
        if (positionIndex === 1) {
          // Only allow right side (index 1) editing
          if (floorData.distribuidores[positionIndex] !== model) {
            floorData.distribuidores[positionIndex] = model;
            // Mirror to left side
            floorData.distribuidores[0] = model;
            // Clear associated tomas if DI is removed
            if (model === null) {
              floorData.tomasLeft = [];
              floorData.tomasRight = [];
              console.log(`Cleared Floor ${floor} Tomas due to DI removal.`);
            }
            changed = true;
            console.log(
              `Set Floor ${floor} DI[${positionIndex}] to: ${model} (mirrored to left side)`,
            );
          }
        } else {
          console.error(
            `Invalid positionIndex ${positionIndex} for DI. Only right side (1) is editable.`,
          );
          return false;
        }
        break;
      case 'BT':
        console.error(
          '_setComponentValue should not be called directly for BT type. Use _setSingleTomaModel.',
        );
        return false;
      default:
        console.error('Unknown component type:', type);
        return false;
    }

    if (changed) {
      this.render();
    }
    return changed;
  }

  /**
   * Sets or removes the model for a single Toma at a specific slot.
   * @private
   * @param {number} floor - The floor number
   * @param {number} sideIndex - The side index (0=left, 1=right)
   * @param {number} slotIndex - The slot index (0-3)
   * @param {string|null} model - The toma model or null to remove
   * @returns {boolean} True if the toma was changed
   */
  _setSingleTomaModel(floor, sideIndex, slotIndex, model) {
    if (!this.floorComponents.has(floor)) {
      console.error(`Cannot set toma: Floor ${floor} data not found.`);
      return false;
    }
    if (slotIndex < 0 || slotIndex > 3) {
      console.error(`Invalid toma slotIndex: ${slotIndex}. Must be 0-3.`);
      return false;
    }
    if (model !== null && !this.componentModels.tomas.includes(model)) {
      console.error(`Invalid toma model: ${model}`);
      return false;
    }

    // Only allow right side editing
    if (sideIndex === 0) {
      console.error(
        'Cannot edit left side tomas directly. They are automatically mirrored from the right side.',
      );
      return false;
    }

    const floorData = this.floorComponents.get(floor);
    const tomasArray = floorData.tomasRight;

    // Pad array with nulls if necessary
    while (tomasArray.length <= slotIndex) {
      tomasArray.push(null);
    }

    // Set the model (or null to remove)
    if (tomasArray[slotIndex] !== model) {
      tomasArray[slotIndex] = model;
      // Mirror to left side
      while (floorData.tomasLeft.length <= slotIndex) {
        floorData.tomasLeft.push(null);
      }
      floorData.tomasLeft[slotIndex] = model;

      console.log(
        `Set Floor ${floor} Right Toma[${slotIndex}] to: ${model} (mirrored to left side)`,
      );

      // Trim trailing nulls for accurate length tracking
      while (tomasArray.length > 0 && tomasArray[tomasArray.length - 1] === null) {
        tomasArray.pop();
      }
      while (
        floorData.tomasLeft.length > 0 &&
        floorData.tomasLeft[floorData.tomasLeft.length - 1] === null
      ) {
        floorData.tomasLeft.pop();
      }
      console.log(` -> Updated Tomas Arrays (Right: ${tomasArray}, Left: ${floorData.tomasLeft})`);

      this.render();
      return true;
    }
    return false; // No change
  }

  /**
   * Retrieves component data for a specific floor.
   * @param {number} floor - The floor number
   * @returns {Object|undefined} The floor component data or undefined
   */
  getFloorComponents(floor) {
    return this.floorComponents.get(floor);
  }

  /**
   * Retrieves all component data, formatted for simulation.
   * @returns {Object} Object containing cable type and floors data
   */
  getAllComponents() {
    const floorsData = {};
    for (const [floor, data] of this.floorComponents.entries()) {
      // Only include floors that have *any* component placed
      if (
        data.derivador ||
        data.distribuidores.some((d) => d !== null) ||
        data.tomasLeft.length > 0 ||
        data.tomasRight.length > 0
      ) {
        floorsData[floor] = {
          derivador: data.derivador || null,
          distribuidores: data.distribuidores, // Keep as [model|null, model|null]
          tomasLeft: data.tomasLeft,
          tomasRight: data.tomasRight,
        };
      }
    }
    return {
      cable: this.selectedCable, // Include the selected cable type
      floors: floorsData,
    };
  }

  // --- Click Handling and Component Selection ---

  /**
   * Gets the Y coordinate for the center of a floor based on its index.
   * @private
   * @param {number} index - The floor index (0-based)
   * @returns {number} The Y coordinate
   */
  _getFloorCenterY(index) {
    // Ensure floors are drawn down to up
    const totalFloors = this._getSortedFloorNumbers().length;
    return (totalFloors - 1 - index) * this.LAYOUT.FLOOR_SPACING;
  }

  /**
   * Gets the X coordinate for DI (distributor) components.
   * @private
   * @param {number} positionIndex - 0 for left, 1 for right
   * @returns {number} The X coordinate
   */
  _getDiX(positionIndex) {
    // 0 for left, 1 for right
    const sign = positionIndex === 0 ? -1 : 1;
    return this.LAYOUT.DE_X + sign * this.LAYOUT.DI_X_OFFSET;
  }

  /**
   * Gets the X coordinate for the center of a BT (toma) group.
   * @private
   * @param {number} positionIndex - 0 for left, 1 for right
   * @returns {number} The X coordinate
   */
  _getBtGroupX(positionIndex) {
    // 0 for left, 1 for right
    const diX = this._getDiX(positionIndex);
    const sign = positionIndex === 0 ? -1 : 1;
    return diX + sign * this.LAYOUT.BT_X_OFFSET_FROM_DI;
  }

  /**
   * Calculates clickable areas for a floor, including individual BT slots.
   * @private
   * @param {number} floor - The floor number
   * @param {number} floorY - The Y coordinate of the floor
   * @returns {Array<Object>} Array of clickable area objects
   */
  _getClickableAreas(floor, floorY) {
    const areas = [];
    const { COMPONENT_SIZE, DE_X } = this.LAYOUT;

    // Derivador (DE)
    areas.push({
      type: 'DE',
      x: DE_X,
      y: floorY,
      floor: floor,
      positionIndex: 0,
      size: COMPONENT_SIZE,
    });

    // Distribuidores (DI) - Left and Right
    for (let sideIndex = 0; sideIndex < 2; sideIndex++) {
      // sideIndex = 0 (Left), sideIndex = 1 (Right)
      const diX = this._getDiX(sideIndex);
      areas.push({
        type: 'DI',
        x: diX,
        y: floorY,
        floor: floor,
        positionIndex: sideIndex,
        size: COMPONENT_SIZE,
      });

      // Tomas (BT) - Create 4 distinct areas per side
      const btGroupX = this._getBtGroupX(sideIndex);
      for (let slotIndex = 0; slotIndex < 4; slotIndex++) {
        const btSlotY = this._getBtSlotY(floorY, slotIndex);
        areas.push({
          type: 'BT',
          x: btGroupX,
          y: btSlotY,
          floor: floor,
          positionIndex: sideIndex, // 0=Left, 1=Right
          slotIndex: slotIndex, // 0, 1, 2, 3
          size: COMPONENT_SIZE,
        });
      }
    }
    return areas;
  }

  /**
   * Calculates the Y coordinate for a specific BT slot index.
   * @private
   * @param {number} floorY - The Y coordinate of the floor center
   * @param {number} slotIndex - The slot index (0-3)
   * @returns {number} The Y coordinate for the slot
   */
  _getBtSlotY(floorY, slotIndex) {
    // Calculate center of the 4-slot stack (midpoint between slot 1 and 2)
    const stackCenterY = floorY;
    // Calculate offset from the center based on slot index (0-3) and spacing
    // Indices 0, 1, 2, 3 correspond to offsets -1.5, -0.5, +0.5, +1.5 times the spacing
    const offsetMultiplier = slotIndex - 1.5;
    return stackCenterY + offsetMultiplier * this.LAYOUT.BT_VERTICAL_SPACING;
  }

  /**
   * Checks if a point is within a component's clickable area.
   * @private
   * @param {number} px - The X coordinate of the point
   * @param {number} py - The Y coordinate of the point
   * @param {Object} area - The area object to check against
   * @returns {boolean} True if the point is within the area
   */
  _isPointInArea(px, py, area) {
    const halfSize = (area.size || this.LAYOUT.COMPONENT_SIZE) / 2; // Use specific size if provided
    return (
      px >= area.x - halfSize &&
      px <= area.x + halfSize &&
      py >= area.y - halfSize &&
      py <= area.y + halfSize
    );
  }

  /**
   * Displays the appropriate component selection popup.
   * @private
   * @param {string} type - The component type
   * @param {number} floor - The floor number
   * @param {number} positionIndex - The position index
   * @param {MouseEvent} event - The triggering mouse event
   * @param {number|null} [slotIndex=null] - Optional slot index for tomas
   */
  _showComponentSelector(type, floor, positionIndex, event, slotIndex = null) {
    this.removeExistingPopups();

    const popup = document.createElement('div');
    popup.id = 'component-selector-popup';
    popup.className =
      'fixed z-50 p-4 bg-zinc-200 rounded-lg shadow-xl dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700';

    // Center the popup on the canvas
    const canvasRect = this.canvas.getBoundingClientRect();
    popup.style.left = `${canvasRect.left + (canvasRect.width - 200) / 2}px`;
    popup.style.top = `${canvasRect.top + (canvasRect.height - 300) / 2}px`;
    popup.style.minWidth = '200px';
    popup.style.maxHeight = '80vh';
    popup.style.overflowY = 'auto';

    let titleText = '';
    let models = [];
    let currentModel = null;
    let modelSetter = null;
    let removalHandler = null;
    let apiType = '';

    const floorData = this.floorComponents.get(floor);
    if (!floorData) {
      console.error('Cannot show selector: Floor data missing for floor', floor);
      return;
    }

    switch (type) {
      case 'DE':
        titleText = 'Seleccionar Derivador';
        models = this.componentModels.derivadores;
        currentModel = floorData.derivador;
        modelSetter = (model) => this._setComponentValue(floor, 'DE', 0, model);
        removalHandler = () => this._setComponentValue(floor, 'DE', 0, null);
        apiType = 'derivador';
        break;
      case 'DI':
        const sideLabelDI = positionIndex === 0 ? 'Izquierda' : 'Derecha';
        titleText = `Seleccionar Distribuidor (${sideLabelDI})`;
        models = this.componentModels.distribuidores;
        currentModel = floorData.distribuidores[positionIndex];
        modelSetter = (model) => this._setComponentValue(floor, 'DI', positionIndex, model);
        removalHandler = () => this._setComponentValue(floor, 'DI', positionIndex, null);
        apiType = 'distribuidor';
        break;
      case 'BT':
        if (positionIndex !== 1) {
          console.error('BT selector should only be shown for right side');
          return;
        }
        titleText = 'Seleccionar Tomas';
        models = this.componentModels.tomas;
        currentModel = floorData.tomasRight[0] || null;
        apiType = 'toma';
        break;
      default:
        console.error('Unknown component type for selector:', type);
        return;
    }

    // --- Build Popup DOM ---
    const title = document.createElement('h3');
    title.className = 'text-lg font-medium mb-3 text-zinc-900 dark:text-white';
    title.textContent = titleText;
    popup.appendChild(title);

    const list = document.createElement('div');
    list.className = 'max-h-60 overflow-y-auto mb-2 -mr-2 pr-2';

    if (!models || models.length === 0) {
      const message = document.createElement('p');
      message.className = 'text-sm text-zinc-500 dark:text-zinc-400 px-3 py-1';
      message.textContent = 'No hay modelos disponibles.';
      list.appendChild(message);
    } else {
      models.forEach((model) => {
        const buttonContainer = document.createElement('div');
        buttonContainer.className = 'flex items-center mb-1';

        const button = document.createElement('button');
        button.type = 'button';
        button.className = `flex-grow text-left px-3 py-1.5 text-sm rounded-l transition-colors duration-150 ${
          model === currentModel
            ? 'bg-primary-100 dark:bg-primary-700 font-medium text-primary-700 dark:text-primary-100'
            : 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700'
        }`;
        button.textContent = model;

        if (type === 'BT') {
          // For BT, show quantity selector after model selection
          button.onclick = () => {
            this._showTomaQuantitySelector(floor, model, event);
            this.removeExistingPopups();
          };
        } else {
          button.onclick = () => {
            if (modelSetter) modelSetter(model);
            this.removeExistingPopups();
          };
        }

        const infoButton = document.createElement('button');
        infoButton.type = 'button';
        infoButton.className = `p-1.5 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300 rounded-r transition-colors duration-150 ${
          model === currentModel
            ? 'bg-primary-100 dark:bg-primary-700'
            : 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
        }`;
        infoButton.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
                </svg>`;

        // Handle info button click
        infoButton.onclick = async (e) => {
          e.stopPropagation();
          try {
            const componentData = await fetchComponentsByModel(apiType, model);
            this._showComponentDetails(componentData, type, model, e);
          } catch (error) {
            console.error('Error fetching component details:', error);
          }
        };

        buttonContainer.appendChild(button);
        buttonContainer.appendChild(infoButton);
        list.appendChild(buttonContainer);
      });
    }
    popup.appendChild(list);

    // --- Add Remove/Cancel Buttons ---
    const buttonContainer = document.createElement('div');
    buttonContainer.className =
      'flex justify-between items-center pt-3 mt-1 border-t border-zinc-200 dark:border-zinc-700';

    if (currentModel !== null && removalHandler) {
      const removeButton = document.createElement('button');
      removeButton.type = 'button';
      removeButton.textContent = 'Quitar';
      removeButton.className =
        'text-sm text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 px-3 py-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30';
      removeButton.onclick = () => {
        removalHandler();
        this.removeExistingPopups();
      };
      buttonContainer.appendChild(removeButton);
    } else {
      buttonContainer.appendChild(document.createElement('div')); // Placeholder
    }

    const cancelButton = document.createElement('button');
    cancelButton.type = 'button';
    cancelButton.textContent = 'Cancelar';
    cancelButton.className =
      'text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 px-3 py-1 rounded bg-zinc-100 dark:bg-zinc-600 hover:bg-zinc-200 dark:hover:bg-zinc-500';
    cancelButton.onclick = () => this.removeExistingPopups();
    buttonContainer.appendChild(cancelButton);

    popup.appendChild(buttonContainer);

    document.body.appendChild(popup);
    this._addOutsideClickListener();
  }

  /**
   * Shows a popup to select the quantity of tomas (2 or 4).
   * @private
   * @param {number} floor - The floor number
   * @param {string} model - The toma model to set
   * @param {MouseEvent} event - The triggering mouse event
   */
  _showTomaQuantitySelector(floor, model, event) {
    this.removeExistingPopups(); // Remove any existing popups first

    const popup = document.createElement('div');
    popup.id = 'toma-quantity-selector';
    popup.className =
      'fixed z-[55] p-4 bg-zinc-200 rounded-lg shadow-xl dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700';

    // Center the popup on the canvas
    const canvasRect = this.canvas.getBoundingClientRect();
    popup.style.left = `${canvasRect.left + (canvasRect.width - 200) / 2}px`;
    popup.style.top = `${canvasRect.top + (canvasRect.height - 200) / 2}px`;
    popup.style.minWidth = '200px';

    const title = document.createElement('h3');
    title.className = 'text-lg font-medium mb-3 text-zinc-900 dark:text-white';
    title.textContent = 'Seleccionar Cantidad de Tomas';
    popup.appendChild(title);

    const buttonContainer = document.createElement('div');
    buttonContainer.className = 'flex flex-col gap-2';

    [2, 4].forEach((quantity) => {
      const button = document.createElement('button');
      button.type = 'button';
      button.className =
        'text-left px-3 py-2 text-sm rounded transition-colors duration-150 text-zinc-700 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700';
      button.textContent = `${quantity} Tomas`;
      button.onclick = (e) => {
        e.stopPropagation();
        this._setTomaQuantity(floor, model, quantity);
        popup.remove();
      };
      buttonContainer.appendChild(button);
    });

    popup.appendChild(buttonContainer);

    const cancelButton = document.createElement('button');
    cancelButton.type = 'button';
    cancelButton.textContent = 'Cancelar';
    cancelButton.className =
      'mt-3 text-sm text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:hover:text-zinc-200 px-3 py-1 rounded bg-zinc-100 dark:bg-zinc-600 hover:bg-zinc-200 dark:hover:bg-zinc-500';
    cancelButton.onclick = (e) => {
      e.stopPropagation();
      popup.remove();
    };
    popup.appendChild(cancelButton);

    document.body.appendChild(popup);

    // Add click outside handler
    const handleOutsideClick = (e) => {
      if (!popup.contains(e.target)) {
        popup.remove();
        document.removeEventListener('click', handleOutsideClick);
      }
    };

    // Delay adding the listener to prevent immediate closing
    setTimeout(() => {
      document.addEventListener('click', handleOutsideClick);
    }, 0);
  }

  /**
   * Sets the specified quantity of tomas with the given model.
   * @private
   * @param {number} floor - The floor number
   * @param {string} model - The toma model
   * @param {number} quantity - The quantity (2 or 4)
   * @returns {boolean} True if successful
   */
  _setTomaQuantity(floor, model, quantity) {
    if (!this.floorComponents.has(floor)) {
      console.error(`Cannot set tomas: Floor ${floor} data not found.`);
      return false;
    }

    const floorData = this.floorComponents.get(floor);

    // Clear existing tomas
    floorData.tomasLeft = [];
    floorData.tomasRight = [];

    // Set new tomas based on quantity
    for (let i = 0; i < quantity; i++) {
      floorData.tomasRight.push(model);
      floorData.tomasLeft.push(model);
    }

    console.log(`Set ${quantity} tomas on floor ${floor} with model ${model}`);
    this.render();
    return true;
  }

  /**
   * Shows component details in a popup.
   * @private
   * @param {Object} componentData - The component data to display
   * @param {string} type - The component type
   * @param {string} model - The component model
   * @param {MouseEvent} event - The triggering mouse event
   */
  _showComponentDetails(componentData, type, model, event) {
    const detailsPopup = document.createElement('div');
    detailsPopup.id = 'component-details-popup';
    detailsPopup.className =
      'fixed z-[60] p-4 bg-zinc-200 rounded-lg shadow-xl dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700';

    // Center the popup on the canvas, slightly offset from the component selector
    const canvasRect = this.canvas.getBoundingClientRect();
    detailsPopup.style.left = `${canvasRect.left + (canvasRect.width - 220) / 2}px`;
    detailsPopup.style.top = `${canvasRect.top + (canvasRect.height - 300) / 2 - 20}px`;
    detailsPopup.style.minWidth = '220px';

    // Title
    const title = document.createElement('h3');
    title.className =
      'text-base font-medium mb-3 text-zinc-900 dark:text-white flex items-center justify-between';
    title.innerHTML = `
            <span>Detalles de ${model}</span>
            <button class="text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300" onclick="this.closest('#component-details-popup').remove()">
                <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                </svg>
            </button>
        `;
    detailsPopup.appendChild(title);

    // Details content
    const content = document.createElement('div');
    content.className = 'space-y-2 text-sm';

    // Add cost (common to all components)
    content.innerHTML = `
            <div class="flex justify-between items-center">
                <span class="text-zinc-600 dark:text-zinc-400">Costo:</span>
                <span class="font-medium text-zinc-800 dark:text-zinc-200">${componentData.costo?.toFixed(2) ?? '-'} €</span>
            </div>
        `;

    // Get the API type based on the internal type
    const apiType =
      type === 'DE'
        ? 'derivador'
        : type === 'DI'
          ? 'distribuidor'
          : type === 'BT'
            ? 'toma'
            : 'coaxial';

    // Add component-specific properties using componentFields
    if (componentFields[apiType]) {
      componentFields[apiType].forEach((field) => {
        content.innerHTML += `
                    <div class="flex justify-between items-center">
                        <span class="text-zinc-600 dark:text-zinc-400">${field.label.replace(' (dB)', '').replace(' (dB/100m)', '')}:</span>
                        <span class="font-medium text-zinc-800 dark:text-zinc-200">
                            ${componentData[field.name]?.toFixed(field.step === '0.01' ? 2 : 1) ?? '-'}
                            ${field.label.includes('dB/100m') ? ' dB/100m' : field.label.includes('dB') ? ' dB' : ''}
                        </span>
                    </div>
                `;
      });
    }

    detailsPopup.appendChild(content);
    document.body.appendChild(detailsPopup);

    // Add click outside listener for this popup
    const handleOutsideClick = (e) => {
      if (!detailsPopup.contains(e.target) && !e.target.closest('button[type="button"]')) {
        detailsPopup.remove();
        document.removeEventListener('click', handleOutsideClick);
      }
    };

    // Delay adding the listener to prevent immediate closing
    setTimeout(() => {
      document.addEventListener('click', handleOutsideClick);
    }, 0);
  }

  /**
   * Adds a listener to close popups when clicking outside.
   * @private
   */
  _addOutsideClickListener() {
    // Use timeout to prevent the initiating click from closing the popup
    setTimeout(() => {
      // Use a named function reference for easy removal
      document.addEventListener('click', this._handleOutsideClick, { capture: true, once: true });
    }, 0);
  }

  /**
   * Event handler for clicks outside the popup.
   * @private
   * @param {MouseEvent} event - The click event
   */
  _handleOutsideClick = (event) => {
    const popup1 = document.getElementById('component-selector-popup');
    const tempMsg = document.getElementById('schematic-temp-message');
    let clickedInsidePopup = false;

    if (popup1 && popup1.contains(event.target)) clickedInsidePopup = true;
    if (tempMsg && tempMsg.contains(event.target)) clickedInsidePopup = true; // Ignore clicks on temp message

    if (!clickedInsidePopup) {
      this.removeExistingPopups();
    } else {
      // If click was inside, re-add the listener for the *next* click outside
      document.addEventListener('click', this._handleOutsideClick, { capture: true, once: true });
    }
  };

  /**
   * Removes any active component selection popups and temporary messages.
   */
  removeExistingPopups() {
    ['component-selector-popup', 'component-details-popup', 'schematic-temp-message'].forEach(
      (id) => {
        document.getElementById(id)?.remove();
      },
    );
    // Clean up the outside click listener just in case it's still attached
    document.removeEventListener('click', this._handleOutsideClick, { capture: true });
  }

  /**
   * Helper to show a temporary message near the click location.
   * @private
   * @param {string} message - The message to display
   * @param {number} x - The X screen coordinate
   * @param {number} y - The Y screen coordinate
   */
  _showTemporaryMessage(message, x, y) {
    this.removeExistingPopups(); // Remove other popups first
    const msgDiv = document.createElement('div');
    msgDiv.id = 'schematic-temp-message';
    msgDiv.style.position = 'fixed';
    msgDiv.style.left = `${x + 15}px`;
    msgDiv.style.top = `${y + 5}px`;
    msgDiv.style.padding = '8px 12px';
    msgDiv.style.background = this._getColor('tempMessageBg');
    msgDiv.style.color = this._getColor('tempMessageText');
    msgDiv.style.border = `1px solid ${this._getColor('tempMessageBorder')}`;
    msgDiv.style.borderRadius = '4px';
    msgDiv.style.fontSize = '12px';
    msgDiv.style.zIndex = '100'; // Ensure it's on top
    msgDiv.style.pointerEvents = 'none'; // Don't let it interfere with future clicks
    msgDiv.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
    msgDiv.textContent = message;
    document.body.appendChild(msgDiv);

    // Remove after a few seconds
    setTimeout(() => {
      // Check if it still exists before removing (might have been removed by another action)
      document.getElementById('schematic-temp-message')?.remove();
    }, 2500); // Remove after 2.5 seconds
  }

  // --- Drawing Methods ---

  /**
   * Draws a component or its placeholder.
   * @private
   * @param {string} type - The component type ('DE', 'DI', 'BT')
   * @param {number} x - The X coordinate
   * @param {number} y - The Y coordinate
   * @param {string|null} [model=null] - The component model or null for placeholder
   * @param {boolean} [isActiveFloor=false] - Whether this is on the active floor
   */
  _drawComponent(type, x, y, model = null, isActiveFloor = false) {
    this.ctx.save();
    const { COMPONENT_SIZE, COMPONENT_BORDER_WIDTH, FONT_SIZE_TYPE, FONT_SIZE_MODEL } = this.LAYOUT;
    const halfSize = COMPONENT_SIZE / 2;
    const scaledLineWidth = Math.max(0.5, COMPONENT_BORDER_WIDTH / this.scale); // Ensure minimum line width
    const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';

    this.ctx.lineWidth = scaledLineWidth;

    // Highlight if it's on the active floor
    if (isActiveFloor) {
      this.ctx.shadowColor =
        theme === 'dark' ? 'rgba(56, 189, 248, 0.7)' : 'rgba(2, 132, 199, 0.5)'; // primary-400 / primary-600 based glow
      this.ctx.shadowBlur = 8 / this.scale; // Adjust blur with scale
    }

    if (model) {
      this.ctx.fillStyle = this._getColor(type.toLowerCase(), theme);
      this.ctx.strokeStyle = this._getColor('componentStroke', theme);

      this.ctx.beginPath();
      this.ctx.rect(x - halfSize, y - halfSize, COMPONENT_SIZE, COMPONENT_SIZE);
      this.ctx.fill();
      this.ctx.stroke();

      // Reset shadow after drawing the main shape if applied
      if (isActiveFloor) {
        this.ctx.shadowColor = 'transparent';
        this.ctx.shadowBlur = 0;
      }

      // Draw text
      this.ctx.fillStyle = this._getColor('textLightBg', theme);
      this.ctx.textAlign = 'center';
      this.ctx.textBaseline = 'middle';
      const typeFontSize = FONT_SIZE_TYPE / this.scale;
      const modelFontSize = FONT_SIZE_MODEL / this.scale;

      this.ctx.font = `bold ${typeFontSize}px sans-serif`;
      this.ctx.fillText(type, x, y - typeFontSize * 0.35);
      this.ctx.font = `${modelFontSize}px sans-serif`;
      const maxTextWidth = COMPONENT_SIZE * 0.9;
      let displayText = model;
      if (this.ctx.measureText(displayText).width > maxTextWidth && model.length > 6) {
        displayText = model.substring(0, 5) + '...';
      }
      this.ctx.fillText(displayText, x, y + typeFontSize * 0.45);
    } else {
      this.ctx.strokeStyle = this._getColor('placeholderStroke', theme);
      this.ctx.fillStyle = this._getColor('placeholderFill', theme);
      this.ctx.setLineDash([3 / this.scale, 3 / this.scale]);

      this.ctx.beginPath();
      this.ctx.rect(x - halfSize, y - halfSize, COMPONENT_SIZE, COMPONENT_SIZE);
      this.ctx.fill();
      this.ctx.stroke();
      this.ctx.setLineDash([]);

      // Reset shadow after drawing the placeholder shape if applied
      if (isActiveFloor) {
        this.ctx.shadowColor = 'transparent';
        this.ctx.shadowBlur = 0;
      }

      this.ctx.font = `bold ${FONT_SIZE_TYPE / this.scale}px sans-serif`;
      this.ctx.fillStyle = this._getColor('placeholderStroke', theme);
      this.ctx.globalAlpha = 0.6;
      this.ctx.textAlign = 'center';
      this.ctx.textBaseline = 'middle';
      this.ctx.fillText(type, x, y);
      this.ctx.globalAlpha = 1.0;
    }
    this.ctx.restore();
  }

  /**
   * Draws all elements for a single floor.
   * @private
   * @param {number} floor - The floor number
   * @param {number} floorY - The Y coordinate of the floor
   * @param {Object} floorData - The floor component data
   * @param {boolean} isCurrentActive - Whether this is the active floor
   */
  _drawFloor(floor, floorY, floorData, isCurrentActive) {
    if (!floorData) return;

    const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
    const { DE_X, FONT_SIZE_FLOOR, COMPONENT_SIZE } = this.LAYOUT;
    const leftDIX = this._getDiX(0);
    const rightDIX = this._getDiX(1);
    const leftBTGroupX = this._getBtGroupX(0);
    const rightBTGroupX = this._getBtGroupX(1);

    // Draw Components (DE, DI)
    this._drawComponent('DE', DE_X, floorY, floorData.derivador, isCurrentActive);
    this._drawComponent('DI', leftDIX, floorY, floorData.distribuidores[0], isCurrentActive);
    this._drawComponent('DI', rightDIX, floorY, floorData.distribuidores[1], isCurrentActive);

    // Draw Tomas (BT) - 4 vertical slots per side
    this._drawTomasSide(leftBTGroupX, floorY, floorData.tomasLeft, isCurrentActive);
    this._drawTomasSide(rightBTGroupX, floorY, floorData.tomasRight, isCurrentActive);

    // Draw Connections
    this._drawFloorConnections(leftDIX, rightDIX, floorY, floorData, isCurrentActive);

    // Draw Floor Label
    this.ctx.save();
    const labelFontSize = FONT_SIZE_FLOOR / this.scale;
    this.ctx.font = `bold ${labelFontSize}px sans-serif`;
    this.ctx.fillStyle = this._getColor('textDarkBg', theme);
    this.ctx.textAlign = 'right';
    this.ctx.textBaseline = 'middle';
    const labelX = leftBTGroupX - COMPONENT_SIZE / 2 - 15 / this.scale;
    this.ctx.fillText(`Piso ${floor}`, labelX, floorY);
    this.ctx.restore();
  }

  /**
   * Draws the 4 possible Tomas slots vertically for one side.
   * @private
   * @param {number} groupX - The X coordinate for the toma group
   * @param {number} floorY - The Y coordinate of the floor center
   * @param {Array<string|null>} tomas - Array of toma models (or nulls)
   * @param {boolean} isCurrentActive - Whether this is on the active floor
   */
  _drawTomasSide(groupX, floorY, tomas, isCurrentActive) {
    // Draw all 4 potential slots
    for (let i = 0; i < 4; i++) {
      const slotY = this._getBtSlotY(floorY, i);
      const model = tomas[i] || null; // Get model if exists, else null
      this._drawComponent('BT', groupX, slotY, model, isCurrentActive);
    }
  }

  /**
   * Draws connection lines within a floor using edge-to-edge logic.
   * @private
   * @param {number} leftDIX - X coordinate of left distributor
   * @param {number} rightDIX - X coordinate of right distributor
   * @param {number} floorY - Y coordinate of the floor
   * @param {Object} floorData - The floor component data
   * @param {boolean} isCurrentActive - Whether this is the active floor
   */
  _drawFloorConnections(leftDIX, rightDIX, floorY, floorData, isCurrentActive) {
    const { DE_X, COMPONENT_SIZE, CABLE_DE_DI, CABLE_DI_BT } = this.LAYOUT;
    const halfSize = COMPONENT_SIZE / 2;

    // DE <-> DI Connections (same as before)
    if (floorData.distribuidores[0] && floorData.derivador) {
      this._drawCable(
        leftDIX + halfSize,
        floorY,
        DE_X - halfSize,
        floorY,
        CABLE_DE_DI,
        isCurrentActive,
      );
    }
    if (floorData.distribuidores[1] && floorData.derivador) {
      this._drawCable(
        rightDIX - halfSize,
        floorY,
        DE_X + halfSize,
        floorY,
        CABLE_DE_DI,
        isCurrentActive,
      );
    }

    // --- DI <-> BT Connections (Connect DI to each *active* BT slot) ---
    const leftBTGroupX = this._getBtGroupX(0);
    const rightBTGroupX = this._getBtGroupX(1);

    // Left DI to Left BTs
    if (floorData.distribuidores[0]) {
      for (let i = 0; i < 4; i++) {
        if (floorData.tomasLeft[i]) {
          // Only draw if toma exists at this slot
          const btSlotY = this._getBtSlotY(floorY, i);
          this._drawCable(
            leftDIX - halfSize,
            floorY,
            leftBTGroupX + halfSize,
            btSlotY,
            CABLE_DI_BT,
            isCurrentActive,
          );
        }
      }
    }

    // Right DI to Right BTs
    if (floorData.distribuidores[1]) {
      for (let i = 0; i < 4; i++) {
        if (floorData.tomasRight[i]) {
          // Only draw if toma exists at this slot
          const btSlotY = this._getBtSlotY(floorY, i);
          this._drawCable(
            rightDIX + halfSize,
            floorY,
            rightBTGroupX - halfSize,
            btSlotY,
            CABLE_DI_BT,
            isCurrentActive,
          );
        }
      }
    }
  }

  /**
   * Draws vertical connections between floors (DE to DE).
   * @private
   * @param {Array<number>} sortedFloorNumbers - Array of floor numbers
   */
  _drawInterFloorConnections(sortedFloorNumbers) {
    const { DE_X, COMPONENT_SIZE, CABLE_INTERFLOOR } = this.LAYOUT;
    const halfSize = COMPONENT_SIZE / 2;

    // Sort floors numerically to ensure proper adjacency checking
    const sortedFloors = [...sortedFloorNumbers].sort((a, b) => a - b);

    for (let i = 0; i < sortedFloors.length - 1; i++) {
      const floor1 = sortedFloors[i];
      const floor2 = sortedFloors[i + 1];

      // Only connect if floors are numerically adjacent (differ by 1)
      if (floor2 - floor1 === 1) {
        const floorData1 = this.floorComponents.get(floor1);
        const floorData2 = this.floorComponents.get(floor2);

        // Only draw if both floors have a DE
        if (floorData1?.derivador && floorData2?.derivador) {
          // Get the visual indices
          const y1 = this._getFloorCenterY(i);
          const y2 = this._getFloorCenterY(i + 1);

          // Connect bottom of DE on floor i to top of DE on floor i+1
          this._drawCable(DE_X, y1 - halfSize, DE_X, y2 + halfSize, CABLE_INTERFLOOR, true); // Dashed line
        }
      }
    }
  }

  /**
   * Draws a cable line with optional length label and dashing.
   * @private
   * @param {number} x1 - Start X coordinate
   * @param {number} y1 - Start Y coordinate
   * @param {number} x2 - End X coordinate
   * @param {number} y2 - End Y coordinate
   * @param {string|null} [lengthLabel=null] - Optional cable length label
   * @param {boolean} [dashed=false] - Whether to draw dashed line
   * @param {boolean} [isCurrentActive=false] - Whether on active floor
   */
  _drawCable(x1, y1, x2, y2, lengthLabel = null, dashed = false, isCurrentActive = false) {
    this.ctx.save();
    this.ctx.strokeStyle = this._getColor('cable', isCurrentActive ? 'dark' : 'light');
    this.ctx.lineWidth = this.LAYOUT.CONNECTION_LINE_WIDTH / this.scale;
    if (dashed) {
      this.ctx.setLineDash([5 / this.scale, 5 / this.scale]);
    }

    this.ctx.beginPath();
    this.ctx.moveTo(x1, y1);
    this.ctx.lineTo(x2, y2);
    this.ctx.stroke();

    // Reset dash
    if (dashed) {
      this.ctx.setLineDash([]);
    }

    // Draw length label
    if (lengthLabel) {
      const midX = (x1 + x2) / 2;
      const midY = (y1 + y2) / 2;
      const angle = Math.atan2(y2 - y1, x2 - x1);
      const labelFontSize = this.LAYOUT.FONT_SIZE_LABEL / this.scale;

      this.ctx.translate(midX, midY);
      this.ctx.rotate(angle);
      // Rotate text slightly differently if line is vertical for readability
      if (Math.abs(angle) > Math.PI / 2 - 0.1 && Math.abs(angle) < Math.PI / 2 + 0.1) {
        // No flip needed if textBaseline is 'bottom' or 'top'
        // If using 'middle', might need rotation
      }

      this.ctx.font = `${labelFontSize}px sans-serif`;
      this.ctx.fillStyle = this._getColor('cable', isCurrentActive ? 'dark' : 'light'); // Use cable color for label
      this.ctx.textAlign = 'center';
      this.ctx.textBaseline = 'bottom';
      const textOffset = -4 / this.scale; // Offset above the line

      this.ctx.fillText(lengthLabel, 0, textOffset);
    }

    this.ctx.restore();
  }

  /**
   * Draws the background grid.
   * @private
   * @param {number} viewWidth - The viewport width
   * @param {number} viewHeight - The viewport height
   */
  _drawGrid(viewWidth, viewHeight) {
    this.ctx.save();
    this.ctx.lineWidth = 0.5 / this.scale;
    this.ctx.strokeStyle = this._getColor('grid');
    const viewLeft = -this.offsetX / this.scale;
    const viewTop = -this.offsetY / this.scale;
    const viewRight = (viewWidth - this.offsetX) / this.scale;
    const viewBottom = (viewHeight - this.offsetY) / this.scale;
    const gridSize = this.LAYOUT.GRID_SIZE;
    const startX = Math.floor(viewLeft / gridSize) * gridSize;
    const startY = Math.floor(viewTop / gridSize) * gridSize;
    const endX = Math.ceil(viewRight / gridSize) * gridSize;
    const endY = Math.ceil(viewBottom / gridSize) * gridSize;
    this.ctx.beginPath();
    for (let x = startX; x <= endX; x += gridSize) {
      this.ctx.moveTo(x, startY);
      this.ctx.lineTo(x, endY);
    }
    for (let y = startY; y <= endY; y += gridSize) {
      this.ctx.moveTo(startX, y);
      this.ctx.lineTo(endX, y);
    }
    this.ctx.stroke();
    this.ctx.restore();
  }

  /**
   * Gets sorted array of floor numbers currently in the map.
   * @private
   * @returns {Array<number>} Sorted array of floor numbers
   */
  _getSortedFloorNumbers() {
    return Array.from(this.floorComponents.keys()).sort((a, b) => a - b);
  }

  /**
   * Draws a placeholder for the next floor's DE component.
   * @private
   */
  _drawNextFloorPlaceholder() {
    const sortedFloors = this._getSortedFloorNumbers();
    if (sortedFloors.length === 0) return;
    const configSelect = document.getElementById('simulation-config');
    if (!configSelect) return;
    const selectedOption = configSelect.options[configSelect.selectedIndex];
    if (!selectedOption) return;
    const configData = JSON.parse(selectedOption.dataset.config || '{}');
    const maxFloors = configData.num_pisos || 0;
    const nextFloor = Math.max(...sortedFloors) + 1;
    if (nextFloor > maxFloors) return;
    const lastFloorIndex = sortedFloors.length - 1;
    const lastFloorY = this._getFloorCenterY(lastFloorIndex);
    const nextFloorY = lastFloorY - this.LAYOUT.FLOOR_SPACING;
    const { DE_X, COMPONENT_SIZE } = this.LAYOUT;
    const halfSize = COMPONENT_SIZE / 2;
    this.ctx.save();
    const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
    this.ctx.strokeStyle = this._getColor('placeholderStroke', theme);
    this.ctx.fillStyle = this._getColor('placeholderFill', theme);
    this.ctx.setLineDash([3 / this.scale, 3 / this.scale]);
    this.ctx.lineWidth = this.LAYOUT.COMPONENT_BORDER_WIDTH / this.scale;
    this.ctx.beginPath();
    this.ctx.rect(DE_X - halfSize, nextFloorY - halfSize, COMPONENT_SIZE, COMPONENT_SIZE);
    this.ctx.fill();
    this.ctx.stroke();
    this.ctx.setLineDash([]);
    this.ctx.fillStyle = this._getColor('placeholderStroke', theme);
    this.ctx.globalAlpha = 0.6;
    this.ctx.textAlign = 'center';
    this.ctx.textBaseline = 'middle';
    this.ctx.font = `bold ${this.LAYOUT.FONT_SIZE_TYPE / this.scale}px sans-serif`;
    this.ctx.fillText('+', DE_X, nextFloorY);
    this.ctx.globalAlpha = 1.0;
    this.ctx.font = `${this.LAYOUT.FONT_SIZE_LABEL / this.scale}px sans-serif`;
    this.ctx.fillText('Añadir Piso', DE_X, nextFloorY - halfSize - 15 / this.scale);
    this.ctx.restore();
    return {
      type: 'NEXT_FLOOR',
      x: DE_X,
      y: nextFloorY,
      size: COMPONENT_SIZE,
    };
  }

  /**
   * Main render loop - draws all components, connections, and grid.
   */
  render() {
    if (!this.ctx || !this.canvas) return;
    const dpr = window.devicePixelRatio || 1;
    const logicalWidth = this.canvas.width / dpr;
    const logicalHeight = this.canvas.height / dpr;
    this.ctx.save();
    this.ctx.clearRect(0, 0, logicalWidth, logicalHeight);
    this.ctx.translate(this.offsetX, this.offsetY);
    this.ctx.scale(this.scale, this.scale);
    this._drawGrid(logicalWidth, logicalHeight);
    const sortedFloors = this._getSortedFloorNumbers();
    if (sortedFloors.length > 0) {
      sortedFloors.forEach((floor, index) => {
        const floorY = this._getFloorCenterY(index);
        const floorData = this.floorComponents.get(floor);
        if (floorData) {
          const isCurrentActive = this.currentFloor === floor;
          this._drawFloor(floor, floorY, floorData, isCurrentActive);
        }
      });
      if (sortedFloors.length > 1) {
        this._drawInterFloorConnections(sortedFloors);
      }
      this.nextFloorPlaceholder = this._drawNextFloorPlaceholder();
    } else {
      this.ctx.save();
      const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
      this.ctx.textAlign = 'center';
      this.ctx.fillStyle = this._getColor('textDarkBg', theme);
      this.ctx.font = `italic ${14 / this.scale}px sans-serif`;
      const viewCenterX = (logicalWidth / 2 - this.offsetX) / this.scale;
      const viewCenterY = (logicalHeight / 2 - this.offsetY) / this.scale;
      this.ctx.fillText('Seleccione un piso para empezar a configurar', viewCenterX, viewCenterY);
      this.ctx.restore();
    }
    this.ctx.restore();
  }

  // --- Cleanup ---

  /**
   * Cleans up event listeners and observers.
   */
  destroy() {
    if (this.canvas) {
      this.canvas.removeEventListener('mousedown', this._handleMouseDown);
      this.canvas.removeEventListener('wheel', this._handleWheel);
      this.canvas.removeEventListener('mouseleave', this._handleMouseLeave);
    }
    window.removeEventListener('mousemove', this._handleMouseMove);
    window.removeEventListener('mouseup', this._handleMouseUp);
    this.removeExistingPopups();
    if (this.resizeObserver) {
      this.resizeObserver.disconnect();
      this.resizeObserver = null;
    }
    console.log('SchematicEditor destroyed');
  }
}