Source: ui/ScrollablePanel.js

// src/engine/ui/ScrollablePanel.js
import BaseUIElement from './BaseUIElement.js'

/**
 * @class ScrollablePanel
 * @extends BaseUIElement
 * @description A panel that can contain child elements and allows vertical scrolling
 * if the content height exceeds the panel height.
 */
class ScrollablePanel extends BaseUIElement {
  /**
   * Creates an instance of ScrollablePanel.
   * @param {object} options - Configuration options.
   * @param {number} [options.x=0] - The x-coordinate.
   * @param {number} [options.y=0] - The y-coordinate.
   * @param {number} [options.width=200] - The width of the panel.
   * @param {number} [options.height=150] - The height of the panel (viewport height).
   * @param {string} [options.backgroundColor=null] - Background color.
   * @param {string} [options.borderColor='gray'] - Border color.
   * @param {number} [options.borderWidth=1] - Width of the border.
   * @param {number} [options.padding=5] - Inner padding for content.
   * @param {number} [options.scrollbarWidth=10] - Width of the scrollbar.
   * @param {string} [options.scrollbarTrackColor='#333'] - Color of the scrollbar track.
   * @param {string} [options.scrollbarThumbColor='#888'] - Color of the scrollbar thumb.
   * @param {string} [options.scrollbarThumbHoverColor='#AAA'] - Color of the scrollbar thumb on hover.
   * @param {number} [options.mouseWheelSensitivity=20] - Pixels to scroll per mouse wheel event.
   */
  constructor(options = {}) {
    super(options)

    this.backgroundColor = options.backgroundColor || null
    this.borderColor = options.borderColor || 'gray'
    this.borderWidth = options.borderWidth !== undefined ? options.borderWidth : 1
    this.padding = options.padding !== undefined ? options.padding : 5

    /** @type {BaseUIElement[]} */
    this.children = []
    this.contentHeight = 0 // Total height of all children
    this.scrollTop = 0 // Current vertical scroll offset

    this.scrollbarWidth = options.scrollbarWidth || 10
    this.scrollbarTrackColor = options.scrollbarTrackColor || '#333'
    this.scrollbarThumbColor = options.scrollbarThumbColor || '#888'
    this.scrollbarThumbHoverColor = options.scrollbarThumbHoverColor || '#AAA'
    this.mouseWheelSensitivity = options.mouseWheelSensitivity || 20

    this._scrollbarTrackRect = { x: 0, y: 0, width: 0, height: 0 }
    this._scrollbarThumbRect = { x: 0, y: 0, width: 0, height: 0 }
    this._isDraggingThumb = false
    this._isMouseOverScrollbarThumb = false
    this._dragStartY = 0
    this._dragStartScrollTop = 0

    this._boundHandleMouseWheel = this._handleMouseWheel.bind(this)
    this._isMouseOverPanel = false // To manage wheel listener
  }

  setEngine(engine) {
    super.setEngine(engine)
    this.children.forEach((child) => child.setEngine(engine))
  }

  addChild(element) {
    if (element instanceof BaseUIElement) {
      this.children.push(element)
      if (this.engine) {
        element.setEngine(this.engine)
      }
      this._calculateContentHeightAndArrange() // Recalculate and potentially update scrollbar
    } else {
      console.warn('ScrollablePanel.addChild: Attempted to add a non-UIElement.', element)
    }
  }

  removeChild(element) {
    const index = this.children.indexOf(element)
    if (index > -1) {
      this.children.splice(index, 1)
      this._calculateContentHeightAndArrange()
      return true
    }
    return false
  }

  clearChildren() {
    this.children = []
    this.contentHeight = 0
    this.scrollTop = 0
    this._updateScrollbar()
  }

  _calculateContentHeightAndArrange() {
    // For this version, assume children y positions are relative to content area top (0)
    // And children are stacked vertically. A more complex layout manager could be used.
    // This method also updates the scrollbar after calculating content height.
    let currentY = this.padding
    this.children.forEach((child) => {
      child.x = this.padding // Simple horizontal positioning within padding
      child.y = currentY // Stack vertically
      currentY += child.height + this.padding // Add padding between elements
    })
    this.contentHeight = Math.max(0, currentY - this.padding) // Total height used by children
    this.scrollTo(this.scrollTop) // Re-clamp scrollTop and update scrollbar
  }

  _updateScrollbar() {
    if (this.contentHeight <= this.height - 2 * this.padding) {
      // No scrollbar needed
      this._scrollbarTrackRect.height = 0
      this._scrollbarThumbRect.height = 0
      return
    }

    const viewportInnerHeight = this.height - 2 * this.padding
    this._scrollbarTrackRect = {
      x: this.x + this.width - this.scrollbarWidth - this.padding,
      y: this.y + this.padding,
      width: this.scrollbarWidth,
      height: viewportInnerHeight,
    }

    const thumbHeightRatio = Math.min(1, viewportInnerHeight / this.contentHeight)
    this._scrollbarThumbRect.height = Math.max(10, viewportInnerHeight * thumbHeightRatio) // Min thumb height
    this._scrollbarThumbRect.width = this.scrollbarWidth
    this._scrollbarThumbRect.x = this._scrollbarTrackRect.x

    const scrollableRange = this.contentHeight - viewportInnerHeight
    const scrollRatio = scrollableRange > 0 ? this.scrollTop / scrollableRange : 0
    const availableThumbTravel = this._scrollbarTrackRect.height - this._scrollbarThumbRect.height

    this._scrollbarThumbRect.y = this._scrollbarTrackRect.y + availableThumbTravel * scrollRatio
  }

  scrollTo(newScrollTop, fromScrollbar = false) {
    const viewportInnerHeight = this.height - 2 * this.padding
    const maxScrollTop = Math.max(0, this.contentHeight - viewportInnerHeight)
    this.scrollTop = Math.max(0, Math.min(newScrollTop, maxScrollTop))
    this._updateScrollbar()
  }

  _handleMouseWheel(event) {
    if (!this.visible || !this.enabled || !this._isMouseOverPanel) return
    event.preventDefault()
    const newScrollTop = this.scrollTop + (event.deltaY > 0 ? 1 : -1) * this.mouseWheelSensitivity
    this.scrollTo(newScrollTop)
  }

  update(deltaTime, engine, mousePos) {
    super.update(deltaTime, engine, mousePos)
    if (!this.visible) {
      if (this._isMouseOverPanel) {
        // Remove listener if panel becomes invisible while mouse was over
        engine.canvas.removeEventListener('wheel', this._boundHandleMouseWheel)
        this._isMouseOverPanel = false
      }
      return
    }

    const wasMouseOverPanel = this._isMouseOverPanel
    this._isMouseOverPanel = this.containsPoint(mousePos.x, mousePos.y)

    if (this._isMouseOverPanel && !wasMouseOverPanel && this.enabled) {
      engine.canvas.addEventListener('wheel', this._boundHandleMouseWheel, { passive: false })
    } else if (!this._isMouseOverPanel && wasMouseOverPanel) {
      engine.canvas.removeEventListener('wheel', this._boundHandleMouseWheel)
    }

    if (!this.enabled) {
      if (wasMouseOverPanel) engine.canvas.removeEventListener('wheel', this._boundHandleMouseWheel)
      this._isMouseOverPanel = false
      this.isDraggingThumb = false
      return
    }

    // Scrollbar interaction
    this._isMouseOverScrollbarThumb =
      this.containsPoint(mousePos.x, mousePos.y, this._scrollbarThumbRect) &&
      this._scrollbarTrackRect.height > 0 // Only if scrollbar active

    const inputManager = engine.inputManager
    const leftButton = inputManager.constructor.MOUSE_BUTTON_LEFT

    if (inputManager.isMouseButtonJustPressed(leftButton)) {
      if (this._isMouseOverScrollbarThumb) {
        this._isDraggingThumb = true
        this._dragStartY = mousePos.y
        this._dragStartScrollTop = this.scrollTop
      } else if (
        this.containsPoint(mousePos.x, mousePos.y, this._scrollbarTrackRect) &&
        this._scrollbarTrackRect.height > 0
      ) {
        // Click on track
        const clickRatio =
          (mousePos.y - this._scrollbarTrackRect.y) / this._scrollbarTrackRect.height
        const viewportInnerHeight = this.height - 2 * this.padding
        const newScrollTop = clickRatio * this.contentHeight - viewportInnerHeight * clickRatio
        this.scrollTo(newScrollTop)
        this._isDraggingThumb = true // Allow immediate drag from new position
        this._dragStartY = mousePos.y
        this._dragStartScrollTop = this.scrollTop
      }
    }

    if (this._isDraggingThumb) {
      if (inputManager.isMouseButtonPressed(leftButton)) {
        const dy = mousePos.y - this._dragStartY
        const viewportInnerHeight = this.height - 2 * this.padding
        const scrollableRange = this.contentHeight - viewportInnerHeight
        const trackScrollableRange =
          this._scrollbarTrackRect.height - this._scrollbarThumbRect.height

        if (trackScrollableRange > 0 && scrollableRange > 0) {
          const scrollTopDelta = (dy / trackScrollableRange) * scrollableRange
          this.scrollTo(this._dragStartScrollTop + scrollTopDelta)
        }
      } else {
        this._isDraggingThumb = false
      }
    }

    // Update children: transform mouse coordinates for children
    // Children are positioned relative to the panel's content area (0,0 after padding)
    // Their y is effectively (child.y - this.scrollTop)
    const contentOriginX = this.x + this.padding
    const contentOriginY = this.y + this.padding

    for (const child of this.children) {
      if (child.visible && child.enabled) {
        // Child's actual screen Y for hit testing (considering scroll)
        const childScreenY = contentOriginY + child.y - this.scrollTop
        const childScreenX = contentOriginX + child.x // Assuming child.x is relative to panel padding

        // Check if child is visible within the clipped viewport
        const childIsVisibleInViewport =
          childScreenY < this.y + this.height - this.padding &&
          childScreenY + child.height > this.y + this.padding

        if (childIsVisibleInViewport) {
          // Create mousePos relative to the child's coordinate system IF child expects relative mouse
          // OR pass global mousePos and let child use its absolute screen coords for containsPoint
          // For now, assuming child.update takes global mousePos and child uses its screen coords for hit detection
          // We need to ensure child.x and child.y are correctly set for rendering within the scrolled view.
          // The rendering part handles this by translating context.
          // For update, the child needs to know its current screen position.
          // Let's make a temporary modification to child's x/y for its update logic.
          const originalChildX = child.x
          const originalChildY = child.y
          child.x = childScreenX // Temporarily set to screen coordinates for hit detection
          child.y = childScreenY

          child.update(deltaTime, engine, mousePos)

          child.x = originalChildX // Restore original relative x
          child.y = originalChildY // Restore original relative y
        }
      }
    }
  }

  _drawSelf(context, engine) {
    // Draw panel background
    if (this.backgroundColor) {
      context.fillStyle = this.backgroundColor
      context.fillRect(this.x, this.y, this.width, this.height)
    }

    // --- Setup Clipping for Content ---
    context.save()
    context.beginPath()
    context.rect(
      this.x + this.padding,
      this.y + this.padding,
      this.width -
        2 * this.padding -
        (this._scrollbarTrackRect.height > 0 ? this.scrollbarWidth : 0), // Adjust width for scrollbar
      this.height - 2 * this.padding,
    )
    context.clip()

    // Translate context for scrolled content
    context.translate(this.x + this.padding, this.y + this.padding - this.scrollTop)

    // Render children (they will use their y relative to content top)
    for (const child of this.children) {
      // Child's render method will check its own visibility
      child.render(context, engine)
    }

    context.restore() // Remove clipping and translation

    // --- Draw Scrollbar ---
    this._updateScrollbar() // Ensure positions are correct
    if (this._scrollbarTrackRect.height > 0) {
      // Only draw if scrollbar is active
      // Draw track
      context.fillStyle = this.scrollbarTrackColor
      context.fillRect(
        this._scrollbarTrackRect.x,
        this._scrollbarTrackRect.y,
        this._scrollbarTrackRect.width,
        this._scrollbarTrackRect.height,
      )

      // Draw thumb
      let currentThumbColor = this.scrollbarThumbColor
      if (this.enabled) {
        if (this._isDraggingThumb) {
          currentThumbColor = this.scrollbarThumbHoverColor // Or a dedicated dragging color
        } else if (this._isMouseOverScrollbarThumb) {
          currentThumbColor = this.scrollbarThumbHoverColor
        }
      }
      context.fillStyle = currentThumbColor
      context.fillRect(
        this._scrollbarThumbRect.x,
        this._scrollbarThumbRect.y,
        this._scrollbarThumbRect.width,
        this._scrollbarThumbRect.height,
      )
    }

    // Draw panel border (drawn last to be on top of content edges and scrollbar)
    if (this.borderWidth > 0) {
      context.strokeStyle = this.borderColor
      context.lineWidth = this.borderWidth
      context.strokeRect(this.x, this.y, this.width, this.height)
    }
  }

  destroy() {
    super.destroy()
    if (this.engine && this.engine.canvas && this._isMouseOverPanel) {
      this.engine.canvas.removeEventListener('wheel', this._boundHandleMouseWheel)
    }
    this.children.forEach((child) => {
      if (typeof child.destroy === 'function') child.destroy()
    })
    this.children = []
  }
}

export default ScrollablePanel