/**
 * ThreatMap - Simplified tile-based map with markers and pan/zoom
 * @countermeasure/web-components/visualization/map
 */

import { resolveContainer, uniqueId } from '../../primitives/utils'
import type { MapConfig, MapMarker, MapRegion } from './types'
import { escapeHtml, setSafeIconHtml } from '../../utils/sanitize'

export type { MapConfig, MapMarker, MapRegion } from './types'

/** Tile size in pixels (standard for web maps) */
const TILE_SIZE = 256

/** Default OpenStreetMap tile URL template */
const DEFAULT_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'

/** Default map center (0, 0) */
const DEFAULT_CENTER = { lat: 0, lng: 0 }

/** Default zoom level */
const DEFAULT_ZOOM = 2

/** Maximum number of tiles to cache (LRU eviction) */
const MAX_TILE_CACHE_SIZE = 256

/** Clamp a number between min and max */
function clampValue(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value))
}

/** Convert lat/lng to pixel coordinates at a given zoom level */
function latLngToPixel(lat: number, lng: number, zoom: number): { x: number; y: number } {
  const scale = Math.pow(2, zoom) * TILE_SIZE
  const x = ((lng + 180) / 360) * scale
  const sinLat = Math.sin((lat * Math.PI) / 180)
  const clampedSin = clampValue(sinLat, -0.9999, 0.9999)
  const y = (0.5 - Math.log((1 + clampedSin) / (1 - clampedSin)) / (4 * Math.PI)) * scale
  return { x, y }
}

export class ThreatMap {
  private readonly id: string
  private readonly container: HTMLElement
  private readonly config: MapConfig
  private readonly tileUrl: string | null
  private readonly minZoom: number
  private readonly maxZoom: number
  private readonly markers: MapMarker[]
  private readonly regions: MapRegion[]

  private wrapperEl: HTMLElement | null = null
  private tileLayerEl: HTMLElement | null = null
  private markerLayerEl: HTMLElement | null = null
  private popupEl: HTMLElement | null = null

  private centerLat: number
  private centerLng: number
  private zoom: number

  /** Pixel offset of the center relative to the world origin */
  private offsetX = 0
  private offsetY = 0

  /** Viewport dimensions */
  private viewportWidth = 0
  private viewportHeight = 0

  /** Pan state */
  private isDragging = false
  private dragStartX = 0
  private dragStartY = 0
  private dragOffsetX = 0
  private dragOffsetY = 0

  /** Bound event handlers for cleanup */
  private readonly boundMouseDown: (e: MouseEvent) => void
  private readonly boundMouseMove: (e: MouseEvent) => void
  private readonly boundMouseUp: (e: MouseEvent) => void
  private readonly boundWheel: (e: WheelEvent) => void
  private readonly boundResize: () => void

  /** Cache for loaded tile images */
  private readonly tileCache = new Map<string, HTMLImageElement>()

  constructor(config: MapConfig) {
    this.id = uniqueId('cmm-map')
    this.container = resolveContainer(config.container)
    this.config = config
    this.tileUrl = config.tileUrl ?? (config.allowExternalTiles === false ? null : DEFAULT_TILE_URL)
    this.minZoom = config.minZoom ?? 1
    this.maxZoom = config.maxZoom ?? 18
    this.markers = config.markers !== undefined ? [...config.markers] : []
    this.regions = config.regions !== undefined ? [...config.regions] : []

    const center = config.center ?? DEFAULT_CENTER
    this.centerLat = center.lat
    this.centerLng = center.lng
    this.zoom = clampValue(config.zoom ?? DEFAULT_ZOOM, this.minZoom, this.maxZoom)

    this.boundMouseDown = this.handleMouseDown.bind(this)
    this.boundMouseMove = this.handleMouseMove.bind(this)
    this.boundMouseUp = this.handleMouseUp.bind(this)
    this.boundWheel = this.handleWheel.bind(this)
    this.boundResize = this.handleResize.bind(this)

    this.render()
    this.computeOffset()
    this.renderTiles()
    this.renderMarkers()
    this.attachEvents()
  }

  /** Set map center and re-render */
  setCenter(lat: number, lng: number): void {
    this.centerLat = lat
    this.centerLng = lng
    this.closePopup()
    this.computeOffset()
    this.renderTiles()
    this.renderMarkers()
  }

  /** Set zoom level and re-render */
  setZoom(zoom: number): void {
    const newZoom = clampValue(Math.round(zoom), this.minZoom, this.maxZoom)
    if (newZoom === this.zoom) {
      return
    }
    this.zoom = newZoom
    this.closePopup()
    this.computeOffset()
    this.renderTiles()
    this.renderMarkers()
    this.updateZoomButtons()
  }

  /** Add a marker to the map */
  addMarker(marker: MapMarker): void {
    this.markers.push(marker)
    this.renderMarkers()
  }

  /** Remove a marker by id */
  removeMarker(id: string): void {
    const idx = this.markers.findIndex(m => m.id === id)
    if (idx >= 0) {
      this.markers.splice(idx, 1)
      this.renderMarkers()
    }
  }

  /** Get all stored regions (regions are data-only, not visually rendered) */
  getRegions(): readonly MapRegion[] {
    return this.regions
  }

  /** Clean up the component and remove from DOM */
  destroy(): void {
    this.detachEvents()
    this.tileCache.clear()
    this.container.innerHTML = ''
    this.wrapperEl = null
    this.tileLayerEl = null
    this.markerLayerEl = null
    this.popupEl = null
  }

  // ---------------------------------------------------------------------------
  // Rendering
  // ---------------------------------------------------------------------------

  /** Create the main DOM structure */
  private render(): void {
    this.container.innerHTML = ''

    const wrapper = document.createElement('div')
    wrapper.className = 'cmm-map'
    wrapper.id = this.id

    if (this.config.className !== undefined) {
      wrapper.classList.add(this.config.className)
    }

    // Tile layer
    const tileLayer = document.createElement('div')
    tileLayer.className = 'cmm-map__tile-layer'
    wrapper.appendChild(tileLayer)
    this.tileLayerEl = tileLayer

    // Marker layer
    const markerLayer = document.createElement('div')
    markerLayer.className = 'cmm-map__marker-layer'
    wrapper.appendChild(markerLayer)
    this.markerLayerEl = markerLayer

    // Zoom controls
    const controls = document.createElement('div')
    controls.className = 'cmm-map__controls'

    const zoomIn = document.createElement('button')
    zoomIn.className = 'cmm-map__zoom-btn'
    zoomIn.setAttribute('data-action', 'zoom-in')
    zoomIn.textContent = '+'
    zoomIn.setAttribute('aria-label', 'Zoom in')
    zoomIn.addEventListener('click', () => {
      this.setZoom(this.zoom + 1)
    })
    controls.appendChild(zoomIn)

    const zoomOut = document.createElement('button')
    zoomOut.className = 'cmm-map__zoom-btn'
    zoomOut.setAttribute('data-action', 'zoom-out')
    zoomOut.textContent = '\u2212'
    zoomOut.setAttribute('aria-label', 'Zoom out')
    zoomOut.addEventListener('click', () => {
      this.setZoom(this.zoom - 1)
    })
    controls.appendChild(zoomOut)

    wrapper.appendChild(controls)

    // Attribution
    const attribution = document.createElement('div')
    attribution.className = 'cmm-map__attribution'
    attribution.textContent = this.tileUrl === DEFAULT_TILE_URL ? 'OpenStreetMap' : ''
    wrapper.appendChild(attribution)

    this.container.appendChild(wrapper)
    this.wrapperEl = wrapper

    // Measure viewport
    this.viewportWidth = wrapper.clientWidth
    this.viewportHeight = wrapper.clientHeight
  }

  /** Compute the pixel offset of the current center */
  private computeOffset(): void {
    const center = latLngToPixel(this.centerLat, this.centerLng, this.zoom)
    this.offsetX = center.x - this.viewportWidth / 2
    this.offsetY = center.y - this.viewportHeight / 2
  }

  /** Render visible tiles for the current viewport and zoom */
  private renderTiles(): void {
    if (this.tileLayerEl === null) {
      return
    }

    this.tileLayerEl.innerHTML = ''

    if (this.tileUrl === null) {
      return
    }

    const tileCount = Math.pow(2, this.zoom)
    const maxPixel = tileCount * TILE_SIZE

    // Compute which tiles are visible
    const startTileX = Math.floor(this.offsetX / TILE_SIZE)
    const startTileY = Math.floor(this.offsetY / TILE_SIZE)
    const endTileX = Math.ceil((this.offsetX + this.viewportWidth) / TILE_SIZE)
    const endTileY = Math.ceil((this.offsetY + this.viewportHeight) / TILE_SIZE)

    for (let ty = startTileY; ty <= endTileY; ty++) {
      for (let tx = startTileX; tx <= endTileX; tx++) {
        // Wrap tile X to handle world wrapping
        const wrappedTx = ((tx % tileCount) + tileCount) % tileCount

        // Skip tiles outside Y bounds
        if (ty < 0 || ty >= tileCount) {
          continue
        }

        const pixelX = tx * TILE_SIZE - this.offsetX
        const pixelY = ty * TILE_SIZE - this.offsetY

        // Skip tiles that are completely off-screen (with a small buffer)
        if (
          pixelX + TILE_SIZE < -TILE_SIZE ||
          pixelX > this.viewportWidth + TILE_SIZE ||
          pixelY + TILE_SIZE < -TILE_SIZE ||
          pixelY > this.viewportHeight + TILE_SIZE
        ) {
          continue
        }

        // Clamp pixel position to prevent extreme offsets
        if (Math.abs(pixelX) > maxPixel || Math.abs(pixelY) > maxPixel) {
          continue
        }

        const tileKey = `${String(this.zoom)}/${String(wrappedTx)}/${String(ty)}`
        const tileUrl = this.tileUrl
          .replace('{z}', String(this.zoom))
          .replace('{x}', String(wrappedTx))
          .replace('{y}', String(ty))

        const tileDiv = document.createElement('div')
        tileDiv.className = 'cmm-map__tile cmm-map__tile--loading'
        tileDiv.style.left = `${String(pixelX)}px`
        tileDiv.style.top = `${String(pixelY)}px`

        // Check cache first (LRU: move to end on access)
        const cached = this.tileCache.get(tileKey)
        if (cached !== undefined) {
          // Move to end for LRU tracking
          this.tileCache.delete(tileKey)
          this.tileCache.set(tileKey, cached)

          const clone = cached.cloneNode(true) as HTMLImageElement
          clone.className = 'cmm-map__tile'
          clone.style.left = `${String(pixelX)}px`
          clone.style.top = `${String(pixelY)}px`
          this.tileLayerEl.appendChild(clone)
        } else {
          const img = document.createElement('img')
          img.className = 'cmm-map__tile cmm-map__tile--loading'
          img.style.left = `${String(pixelX)}px`
          img.style.top = `${String(pixelY)}px`
          img.draggable = false
          img.alt = ''
          img.src = tileUrl

          img.addEventListener('load', () => {
            img.classList.remove('cmm-map__tile--loading')
            // Store in cache with LRU eviction
            this.addToTileCache(tileKey, img.cloneNode(true) as HTMLImageElement)
          })

          img.addEventListener('error', () => {
            img.classList.remove('cmm-map__tile--loading')
            img.classList.add('cmm-map__tile--error')
          })

          this.tileLayerEl.appendChild(img)
        }
      }
    }
  }

  /** Render markers over the tile layer */
  private renderMarkers(): void {
    if (this.markerLayerEl === null) {
      return
    }

    this.markerLayerEl.innerHTML = ''

    // Also remove any popup
    this.closePopup()

    for (const marker of this.markers) {
      const pixel = latLngToPixel(marker.lat, marker.lng, this.zoom)
      const screenX = pixel.x - this.offsetX
      const screenY = pixel.y - this.offsetY

      // Skip markers off-screen
      if (
        screenX < -50 ||
        screenX > this.viewportWidth + 50 ||
        screenY < -50 ||
        screenY > this.viewportHeight + 50
      ) {
        continue
      }

      const markerEl = document.createElement('div')
      markerEl.className = 'cmm-map__marker'
      markerEl.style.left = `${String(screenX)}px`
      markerEl.style.top = `${String(screenY)}px`
      markerEl.setAttribute('data-marker-id', marker.id)

      // Pin SVG
      const pin = document.createElement('div')
      pin.className = 'cmm-map__marker-pin'
      const pinColor = escapeHtml(marker.color ?? 'hsl(0, 70%, 55%)')

      if (marker.icon !== undefined) {
        setSafeIconHtml(pin, marker.icon)
      } else {
        setSafeIconHtml(
          pin,
          `<svg viewBox="0 0 24 36" width="24" height="36" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M12 0C5.373 0 0 5.373 0 12c0 9 12 24 12 24s12-15 12-24C24 5.373 18.627 0 12 0z" fill="${pinColor}"/>
          <circle cx="12" cy="12" r="5" fill="white" opacity="0.8"/>
        </svg>`
        )
      }
      markerEl.appendChild(pin)

      // Label
      if (marker.label !== undefined) {
        const label = document.createElement('div')
        label.className = 'cmm-map__marker-label'
        label.textContent = marker.label
        markerEl.appendChild(label)
      }

      // Click handler
      markerEl.addEventListener('click', (e: MouseEvent) => {
        e.stopPropagation()
        if (this.config.onMarkerClick !== undefined) {
          this.config.onMarkerClick(marker)
        }
        if (marker.popup !== undefined) {
          this.showPopup(marker, screenX, screenY)
        }
      })

      this.markerLayerEl.appendChild(markerEl)
    }
  }

  /** Display a popup for a marker */
  private showPopup(marker: MapMarker, x: number, y: number): void {
    this.closePopup()

    if (this.wrapperEl === null) {
      return
    }

    const popup = document.createElement('div')
    popup.className = 'cmm-map__popup'
    popup.style.left = `${String(x)}px`
    popup.style.top = `${String(y)}px`

    const closeBtn = document.createElement('button')
    closeBtn.className = 'cmm-map__popup-close'
    closeBtn.textContent = '\u00d7'
    closeBtn.addEventListener('click', (e: MouseEvent) => {
      e.stopPropagation()
      this.closePopup()
    })
    popup.appendChild(closeBtn)

    const content = document.createElement('div')
    content.textContent = marker.popup ?? ''
    popup.appendChild(content)

    this.wrapperEl.appendChild(popup)
    this.popupEl = popup
  }

  /** Close any open popup */
  private closePopup(): void {
    if (this.popupEl !== null) {
      this.popupEl.remove()
      this.popupEl = null
    }
  }

  /** Update zoom button disabled state */
  private updateZoomButtons(): void {
    if (this.wrapperEl === null) {
      return
    }

    const zoomInBtn = this.wrapperEl.querySelector<HTMLButtonElement>('[data-action="zoom-in"]')
    const zoomOutBtn = this.wrapperEl.querySelector<HTMLButtonElement>('[data-action="zoom-out"]')

    if (zoomInBtn !== null) {
      zoomInBtn.disabled = this.zoom >= this.maxZoom
    }
    if (zoomOutBtn !== null) {
      zoomOutBtn.disabled = this.zoom <= this.minZoom
    }
  }

  // ---------------------------------------------------------------------------
  // Event handling
  // ---------------------------------------------------------------------------

  /** Attach mouse/wheel events for pan and zoom */
  private attachEvents(): void {
    if (this.wrapperEl === null) {
      return
    }

    this.wrapperEl.addEventListener('mousedown', this.boundMouseDown)
    window.addEventListener('mousemove', this.boundMouseMove)
    window.addEventListener('mouseup', this.boundMouseUp)
    this.wrapperEl.addEventListener('wheel', this.boundWheel, { passive: false })
    window.addEventListener('resize', this.boundResize)
  }

  /** Detach all events */
  private detachEvents(): void {
    if (this.wrapperEl !== null) {
      this.wrapperEl.removeEventListener('mousedown', this.boundMouseDown)
      this.wrapperEl.removeEventListener('wheel', this.boundWheel)
    }
    window.removeEventListener('mousemove', this.boundMouseMove)
    window.removeEventListener('mouseup', this.boundMouseUp)
    window.removeEventListener('resize', this.boundResize)
  }

  /** Handle mousedown to start panning */
  private handleMouseDown(e: MouseEvent): void {
    // Ignore clicks on controls or popups
    const target = e.target as HTMLElement
    if (
      target.closest('.cmm-map__controls') !== null ||
      target.closest('.cmm-map__popup') !== null ||
      target.closest('.cmm-map__marker') !== null
    ) {
      return
    }

    this.isDragging = true
    this.dragStartX = e.clientX
    this.dragStartY = e.clientY
    this.dragOffsetX = this.offsetX
    this.dragOffsetY = this.offsetY
    this.closePopup()
  }

  /** Handle mousemove for panning */
  private handleMouseMove(e: MouseEvent): void {
    if (!this.isDragging) {
      return
    }

    const dx = e.clientX - this.dragStartX
    const dy = e.clientY - this.dragStartY

    this.offsetX = this.dragOffsetX - dx
    this.offsetY = this.dragOffsetY - dy

    // Clamp Y offset
    const maxPixel = Math.pow(2, this.zoom) * TILE_SIZE
    this.offsetY = clampValue(this.offsetY, 0, maxPixel - this.viewportHeight)

    this.renderTiles()
    this.renderMarkers()
  }

  /** Handle mouseup to stop panning */
  private handleMouseUp(_e: MouseEvent): void {
    if (this.isDragging) {
      this.isDragging = false
      // Update center lat/lng from new offset
      this.updateCenterFromOffset()
    }
  }

  /** Handle wheel for zoom */
  private handleWheel(e: WheelEvent): void {
    e.preventDefault()

    const direction = e.deltaY < 0 ? 1 : -1
    const newZoom = clampValue(this.zoom + direction, this.minZoom, this.maxZoom)

    if (newZoom === this.zoom) {
      return
    }

    // Zoom towards mouse position
    const rect = this.wrapperEl?.getBoundingClientRect()
    if (rect === undefined) {
      return
    }

    const mouseX = e.clientX - rect.left
    const mouseY = e.clientY - rect.top

    // World coordinates under the mouse at current zoom
    const worldX = this.offsetX + mouseX
    const worldY = this.offsetY + mouseY

    // Scale factor
    const scaleFactor = Math.pow(2, newZoom - this.zoom)

    // New world coordinates under mouse at new zoom
    const newWorldX = worldX * scaleFactor
    const newWorldY = worldY * scaleFactor

    this.zoom = newZoom
    this.offsetX = newWorldX - mouseX
    this.offsetY = newWorldY - mouseY

    // Clamp Y offset
    const maxPixel = Math.pow(2, this.zoom) * TILE_SIZE
    this.offsetY = clampValue(this.offsetY, 0, maxPixel - this.viewportHeight)

    this.updateCenterFromOffset()
    this.closePopup()
    this.renderTiles()
    this.renderMarkers()
    this.updateZoomButtons()
  }

  /** Handle viewport resize */
  private handleResize(): void {
    if (this.wrapperEl === null) {
      return
    }

    this.viewportWidth = this.wrapperEl.clientWidth
    this.viewportHeight = this.wrapperEl.clientHeight
    this.computeOffset()
    this.renderTiles()
    this.renderMarkers()
  }

  /** Add a tile to the cache with LRU eviction */
  private addToTileCache(key: string, img: HTMLImageElement): void {
    // Evict oldest entries if cache is full
    while (this.tileCache.size >= MAX_TILE_CACHE_SIZE) {
      const oldestKey = this.tileCache.keys().next().value
      if (oldestKey !== undefined) {
        this.tileCache.delete(oldestKey)
      } else {
        break
      }
    }
    this.tileCache.set(key, img)
  }

  /** Derive center lat/lng from the current pixel offset */
  private updateCenterFromOffset(): void {
    const scale = Math.pow(2, this.zoom) * TILE_SIZE
    const centerPixelX = this.offsetX + this.viewportWidth / 2
    const centerPixelY = this.offsetY + this.viewportHeight / 2

    this.centerLng = (centerPixelX / scale) * 360 - 180

    const n = Math.PI - (2 * Math.PI * centerPixelY) / scale
    this.centerLat = (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)))
  }
}
