//
//  AppleVenueMapController.swift
//  TestApp
//
//  Hosts a venue map with two coexisting basemap stacks:
//    1. MapLibre (default) - VMVectorMapView with MapTiler streets style.
//    2. Apple Maps (lazy)  - MKMapView base + VMVectorMapView overlay.
//
//  A nav-bar UISegmentedControl toggles between the two while preserving the
//  camera position. Mirrors the Android demo's basemap toggle architecture.
//
//  Copyright © 2018 Aegir LLC. All rights reserved.
//

import Foundation
import Mapbox
import MapKit
import UIKit
import VMSDK_Core
import VMSDK_Wayfinding

private enum BasemapMode {
  case mapLibre
  case appleMaps
}

private struct CameraSnapshot {
  let target: CLLocationCoordinate2D
  let zoom: Float
  let bearing: Double
  let tilt: Double
}

// MapTiler key is supplied at runtime via Info.plist's `MapTilerAPIKey`,
// fed by `LocalKeys.xcconfig` (gitignored) for local development. The
// release packaging step (`build-tools/package.sh`) zeroes this entry
// in the published zip so consumers must inject their own key. Mirrors
// the Android demo's `MAPTILER_API_KEY` in `vmms/local.properties`.
private let MAPTILER_API_KEY: String = {
  Bundle.main.object(forInfoDictionaryKey: "MapTilerAPIKey") as? String ?? ""
}()
// The iOS SDK rewrites the style's `sources` dict to inject `openmaptiles`
// pointing at MapTiler tiles authenticated with `maptilerAccessToken`. The
// style JSON must therefore reference an `openmaptiles` vector source by name.
// MapTiler's own streets-v2 style does not, so we use the Aegir-hosted style.
private let MAPLIBRE_STREETS_STYLE_URL =
  "https://style.aegirmaps.com/styles/maplibre_streets_style.json"

// Zoom margin subtracted from the computed bounds-fit so the ground-floor
// outline isn't framed edge-to-edge on initial load.
private let CAMERA_FIT_PADDING: Float = 1.5

class AppleVenueMapController: UIViewController, FloorSwitcherDelegate {

  // MARK: - Properties

  var venueConfig: VenueConfig!

  private var mapContainerView: ZeroSafeAreaView?
  private var mapView: VMVectorMapView?
  private var vmd: VMMSMap!
  private var parser: VMDParser?
  private var floorSwitcher: FloorSwitcherView?
  private var bearingCompass: BearingCompassView?
  private var tapInfoLabel: UILabel?
  private var tapInfoTimer: Timer?

  // Dual-basemap state
  private var currentMode: BasemapMode = .mapLibre
  private var mapLibreVenueView: VMVectorMapView?
  private var appleVenueView: VMVectorMapView?
  private var appleBaseMapView: MKMapView?
  private var appleStackInitialized = false
  private var basemapSegmentedControl: UISegmentedControl?
  private var lastCamera: CameraSnapshot?

  // Per-building active floor. Every non-selected building stays pinned to its
  // lowest-ordinal (ground) floor so other buildings render as ground-floor
  // shapes (matching Android) instead of a solid footprint mass + name label.
  // activeIndoorFloors is always the union of these values.
  private var activeFloorByBuilding: [String: VMMSBaseFloor] = [:]
  private var currentSelectedFloor: VMMSBaseFloor?

  // Custom top overlay (mirrors Android's MapTopBar)
  private var topOverlayBar: UIView?

  // MARK: - Lifecycle

  override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = UIColor.lightGray

    installTopOverlayBar()

    let fileCollection = VMDFileCollection(
      basePath: venueConfig.basePath, andVenueId: venueConfig.venueId)
    self.parser = VMDParser(withFileCollection: fileCollection, delegate: self)
    self.parser?.parse(withMapProvider: .apple)
  }

  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    // Custom overlay top bar replaces the system nav bar.
    navigationController?.setNavigationBarHidden(true, animated: animated)
  }

  override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    navigationController?.setNavigationBarHidden(false, animated: animated)
  }

  // MARK: - Top overlay bar (mirrors Android MapTopBar)

  private func installTopOverlayBar() {
    let bar = UIView()
    bar.translatesAutoresizingMaskIntoConstraints = false
    bar.backgroundColor = .clear
    view.addSubview(bar)

    NSLayoutConstraint.activate([
      bar.topAnchor.constraint(
        equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8),
      bar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8),
      bar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8),
      bar.heightAnchor.constraint(equalToConstant: 44),
    ])

    let backButton = makeCircleIconButton(
      systemImage: "chevron.left",
      action: #selector(handleBackTapped))
    bar.addSubview(backButton)

    let seg = UISegmentedControl(items: ["MapLibre", "Apple"])
    seg.selectedSegmentIndex = currentMode == .mapLibre ? 0 : 1
    seg.addTarget(self, action: #selector(handleBasemapToggle(_:)), for: .valueChanged)
    seg.translatesAutoresizingMaskIntoConstraints = false
    bar.addSubview(seg)
    basemapSegmentedControl = seg

    let floorListButton = makeCircleIconButton(
      systemImage: "list.bullet",
      action: #selector(handleFloorListTapped))
    bar.addSubview(floorListButton)

    let reloadButton = makeCircleIconButton(
      systemImage: "arrow.clockwise",
      action: #selector(handleReloadTapped))
    bar.addSubview(reloadButton)

    NSLayoutConstraint.activate([
      backButton.leadingAnchor.constraint(equalTo: bar.leadingAnchor),
      backButton.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
      backButton.widthAnchor.constraint(equalToConstant: 36),
      backButton.heightAnchor.constraint(equalToConstant: 36),

      reloadButton.trailingAnchor.constraint(equalTo: bar.trailingAnchor),
      reloadButton.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
      reloadButton.widthAnchor.constraint(equalToConstant: 36),
      reloadButton.heightAnchor.constraint(equalToConstant: 36),

      floorListButton.trailingAnchor.constraint(equalTo: reloadButton.leadingAnchor, constant: -8),
      floorListButton.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
      floorListButton.widthAnchor.constraint(equalToConstant: 36),
      floorListButton.heightAnchor.constraint(equalToConstant: 36),

      seg.centerXAnchor.constraint(equalTo: bar.centerXAnchor),
      seg.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
      seg.widthAnchor.constraint(equalToConstant: 180),
      seg.heightAnchor.constraint(equalToConstant: 32),
    ])

    topOverlayBar = bar
  }

  private func makeCircleIconButton(systemImage: String, action: Selector) -> UIButton {
    let button = UIButton(type: .system)
    button.translatesAutoresizingMaskIntoConstraints = false
    button.setImage(UIImage(systemName: systemImage), for: .normal)
    button.tintColor = .systemBlue
    button.backgroundColor = UIColor.white.withAlphaComponent(0.85)
    button.layer.cornerRadius = 18
    button.layer.shadowColor = UIColor.black.cgColor
    button.layer.shadowOpacity = 0.15
    button.layer.shadowRadius = 2
    button.layer.shadowOffset = CGSize(width: 0, height: 1)
    button.addTarget(self, action: action, for: .touchUpInside)
    return button
  }

  @objc private func handleBackTapped() {
    navigationController?.popViewController(animated: true)
  }

  @objc private func handleBasemapToggle(_ sender: UISegmentedControl) {
    let target: BasemapMode = sender.selectedSegmentIndex == 0 ? .mapLibre : .appleMaps
    toggleBasemap(to: target)
  }

  /// Reload the venue: replace self in the nav stack with a fresh instance
  /// using the same VenueConfig (mirrors Android `recreate()`).
  @objc private func handleReloadTapped() {
    guard let nav = navigationController, let cfg = venueConfig else { return }
    let fresh = AppleVenueMapController()
    fresh.venueConfig = cfg
    var stack = nav.viewControllers
    if let idx = stack.firstIndex(where: { $0 === self }) {
      stack[idx] = fresh
      nav.setViewControllers(stack, animated: false)
    }
  }

  @objc private func handleFloorListTapped() {
    presentFloorListSheet()
  }

  private func presentFloorListSheet() {
    guard let vmd = self.vmd else { return }
    let groups = floorGroups(from: vmd)
    let current = currentSelectedFloor
      ?? mapLibreVenueView?.activeIndoorFloors.first
      ?? appleVenueView?.activeIndoorFloors.first
    let picker = FloorListController(
      groups: groups,
      current: current
    ) { [weak self] floor in
      guard let self else { return }
      self.dismiss(animated: true) {
        self.applyFloorSelection(floor)
      }
    }
    let nav = UINavigationController(rootViewController: picker)
    nav.modalPresentationStyle = .pageSheet
    if let sheet = nav.sheetPresentationController {
      sheet.detents = [.medium(), .large()]
      sheet.prefersGrabberVisible = true
    }
    present(nav, animated: true)
  }

  private func applyFloorSelection(_ floor: VMMSBaseFloor) {
    selectIndoorFloor(floor)
  }

  /// Union of the per-building active floors. Non-selected buildings remain
  /// pinned to their ground floor so their ground-floor shapes stay visible.
  private func activeIndoorUnion() -> [VMMSBaseFloor] {
    Array(activeFloorByBuilding.values)
  }

  /// Change the active floor for the selected floor's building only; all other
  /// buildings keep their current (ground) floor. Applies the resulting union
  /// to both stacks so a basemap toggle cannot collapse it to one floor.
  private func selectIndoorFloor(_ floor: VMMSBaseFloor) {
    if let buildingId = floor.mapBuilding?.uid {
      activeFloorByBuilding[buildingId] = floor
    }
    currentSelectedFloor = floor
    let union = activeIndoorUnion()
    mapLibreVenueView?.activeIndoorFloors = union
    appleVenueView?.activeIndoorFloors = union
    if let vmd = self.vmd {
      floorSwitcher?.configure(
        floors: allFloorsSorted(from: vmd), currentFloor: floor)
    }
  }

  // MARK: - Map Setup

  /// Creates an empty container that the lazy Apple stack can later populate
  /// with the MKMapView. The MapLibre stack ignores this container.
  private func ensureMapContainer() {
    guard mapContainerView == nil else { return }
    let container = ZeroSafeAreaView(frame: view.bounds)
    container.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    view.insertSubview(container, at: 0)
    mapContainerView = container
  }

  /// Default basemap stack: a single VMVectorMapView rendering MapTiler tiles
  /// plus the venue overlay. No external base map view.
  private func setupMapLibreStack() {
    guard let vmd = self.vmd, mapLibreVenueView == nil else { return }
    ensureMapContainer()

    let venueId = venueConfig.venueId
    let style = createVenueStyle(for: venueId)

    let styleURL = URL(string: MAPLIBRE_STREETS_STYLE_URL)
    let mv = VMVectorMapView(
      frame: view.bounds, vmd: vmd, styleURL: styleURL,
      maptilerAccessToken: MAPTILER_API_KEY)
    mv.autoresizingMask = [.flexibleWidth, .flexibleHeight]

    let floors = allFloorsSorted(from: vmd)
    if activeFloorByBuilding.isEmpty {
      activeFloorByBuilding = groundFloorByBuilding(from: vmd)
      currentSelectedFloor = floors.first
    }
    mv.activeIndoorFloors = activeIndoorUnion()

    configureVenueTileURLs(on: mv)
    mv.style = style
    mv.tiltEnabled = false
    mv.minZoom = 15.0
    mv.maxZoom = 22.0
    mv.attachInView(view, aboveView: mapContainerView)
    // MapTiler tiles are the basemap; keep the MGLMapView opaque.
    mv.setBackgroundAlpha(1)
    let initial =
      groundFloorFitCamera(for: vmd, size: view.bounds.size, maxZoom: mv.maxZoom)
      ?? CameraSnapshot(
        target: vmd.centerPoint, zoom: vmd.initialZoomLevel - 1,
        bearing: Double(vmd.initialRotation), tilt: 0)
    mv.setMapPosition(
      target: initial.target, bearing: Float(initial.bearing), zoomLevel: initial.zoom)
    mv.delegate = self

    mapLibreVenueView = mv
    self.mapView = mv

    setupSharedOverlaysIfNeeded(
      activeFloor: currentSelectedFloor ?? mv.activeIndoorFloors.first, allFloors: floors)
  }

  /// Lazy-initialized Apple Maps stack: MKMapView basemap + transparent
  /// VMVectorMapView overlay synced via VMAppleBaseMap.
  private func setupAppleStack() {
    guard let vmd = self.vmd, !appleStackInitialized else { return }
    ensureMapContainer()
    guard let container = mapContainerView else { return }

    let baseMap = MKMapView(frame: container.bounds)
    baseMap.autoresizingMask = [.flexibleWidth, .flexibleHeight]

    let earthCircumference: Double = 40075017.0
    let latitude = vmd.centerPoint.latitude
    let zoomLevel = Double(vmd.initialZoomLevel)
    let altitude = earthCircumference * cos(latitude * .pi / 180.0) / pow(2.0, zoomLevel)

    baseMap.camera = MKMapCamera(
      lookingAtCenter: vmd.centerPoint, fromDistance: altitude, pitch: 0,
      heading: CLLocationDirection(vmd.initialRotation))
    baseMap.showsCompass = false
    container.addSubview(baseMap)
    appleBaseMapView = baseMap

    let venueId = venueConfig.venueId
    let style = createVenueStyle(for: venueId)

    let mv = VMVectorMapView(frame: view.bounds, vmd: vmd)
    mv.autoresizingMask = [.flexibleWidth, .flexibleHeight]

    if activeFloorByBuilding.isEmpty {
      activeFloorByBuilding = groundFloorByBuilding(from: vmd)
      currentSelectedFloor = allFloorsSorted(from: vmd).first
    }
    mv.activeIndoorFloors = activeIndoorUnion()

    configureVenueTileURLs(on: mv)
    mv.style = style
    mv.tiltEnabled = false
    mv.minZoom = 15.0
    mv.maxZoom = 22.0
    mv.attachInView(view, aboveView: container)
    // Transparent so the MKMapView shows through.
    mv.setBackgroundAlpha(0)

    let initial = lastCamera
      ?? groundFloorFitCamera(for: vmd, size: view.bounds.size, maxZoom: mv.maxZoom)
      ?? CameraSnapshot(
        target: vmd.centerPoint, zoom: vmd.initialZoomLevel - 1,
        bearing: Double(vmd.initialRotation), tilt: 0)
    mv.setMapPosition(
      target: initial.target, bearing: Float(initial.bearing), zoomLevel: initial.zoom)

    mv.delegate = self
    mv.baseMap = VMAppleBaseMap(mapView: baseMap)

    appleVenueView = mv
    appleStackInitialized = true
  }

  private func toggleBasemap(to target: BasemapMode) {
    guard target != currentMode else { return }
    captureCurrentCamera()

    switch target {
    case .appleMaps:
      if !appleStackInitialized {
        setupAppleStack()
      }
      mapLibreVenueView?.isHidden = true
      appleBaseMapView?.isHidden = false
      appleVenueView?.isHidden = false
      mapView = appleVenueView
    case .mapLibre:
      appleBaseMapView?.isHidden = true
      appleVenueView?.isHidden = true
      mapLibreVenueView?.isHidden = false
      mapView = mapLibreVenueView
    }

    if let cam = lastCamera, let mv = mapView {
      mv.setMapPosition(
        target: cam.target, bearing: Float(cam.bearing), zoomLevel: cam.zoom)
    }

    currentMode = target
    basemapSegmentedControl?.selectedSegmentIndex = target == .mapLibre ? 0 : 1
  }

  private func captureCurrentCamera() {
    guard let mv = mapView else { return }
    guard CLLocationCoordinate2DIsValid(mv.centerCoordinate) else { return }
    lastCamera = CameraSnapshot(
      target: mv.centerCoordinate,
      zoom: mv.zoomLevel,
      bearing: mv.bearing,
      tilt: mv.tilt)
  }

  /// Resolve venue tile URLs for the supplied VMVectorMapView. Both stacks
  /// pull from the venue's `apple/` subfolder since VMD parsing uses
  /// `.apple` map provider.
  private func configureVenueTileURLs(on mv: VMVectorMapView) {
    if venueConfig.isRemote {
      mv.tileBaseURL = "\(venueConfig.basePath)/apple/vector-tiles"
      mv.vectorCommonBaseURL = "\(venueConfig.basePath)/apple/vector-common"
    } else {
      let baseURL = URL(fileURLWithPath: venueConfig.basePath)
      mv.tileBaseURL = baseURL.appendingPathComponent("apple/vector-tiles").absoluteString
      mv.vectorCommonBaseURL = baseURL.appendingPathComponent("apple/vector-common").absoluteString
    }
  }

  private func createVenueStyle(for venueId: String) -> VMVenueStyle {
    if let url = Bundle.main.url(forResource: "style_default", withExtension: "json") {
      return VMVenueStyle(config: url, venueId: "venue_map_\(venueId)")
    }
    let style = VMVenueStyle(id: "test_style", venueId: "venue_map_\(venueId)")
    let layer = VMVenueLayerStyle(hidden: true)
    layer.layerId = "venue"
    style.layerStyles.append(layer)
    return style
  }

  /// One-shot: install bearing compass, floor switcher, tap-info label.
  /// Re-invocations are no-ops so toggles don't duplicate overlays.
  private func setupSharedOverlaysIfNeeded(
    activeFloor: VMMSBaseFloor?, allFloors: [VMMSBaseFloor]
  ) {
    if bearingCompass == nil {
      let compass = BearingCompassView()
      compass.translatesAutoresizingMaskIntoConstraints = false
      view.addSubview(compass)
      let topAnchor = topOverlayBar?.bottomAnchor ?? view.safeAreaLayoutGuide.topAnchor
      NSLayoutConstraint.activate([
        compass.topAnchor.constraint(equalTo: topAnchor, constant: 8),
        compass.trailingAnchor.constraint(
          equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -8),
      ])
      bearingCompass = compass
    }

    if floorSwitcher == nil {
      setupFloorSwitcher(floors: allFloors, currentFloor: activeFloor)
    }

    if let compass = bearingCompass {
      view.bringSubviewToFront(compass)
    }
    if let bar = topOverlayBar {
      view.bringSubviewToFront(bar)
    }

    if tapInfoLabel == nil {
      let info = UILabel()
      info.translatesAutoresizingMaskIntoConstraints = false
      info.font = .monospacedSystemFont(ofSize: 12, weight: .medium)
      info.textColor = .white
      info.backgroundColor = UIColor.black.withAlphaComponent(0.75)
      info.textAlignment = .center
      info.numberOfLines = 0
      info.layer.cornerRadius = 8
      info.layer.masksToBounds = true
      info.isHidden = true
      view.addSubview(info)
      NSLayoutConstraint.activate([
        info.topAnchor.constraint(
          equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 44),
        info.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
        info.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
      ])
      tapInfoLabel = info
    }
  }

  private func showTapInfo(_ text: String, duration: TimeInterval = 4) {
    guard let label = tapInfoLabel else { return }
    label.text = "  \(text)  "
    label.isHidden = false
    view.bringSubviewToFront(label)
    tapInfoTimer?.invalidate()
    tapInfoTimer = Timer.scheduledTimer(withTimeInterval: duration, repeats: false) { [weak self] _ in
      self?.tapInfoLabel?.isHidden = true
    }
  }

  private func showErrorOverlay(_ message: String) {
    let label = UILabel()
    label.translatesAutoresizingMaskIntoConstraints = false
    label.text = message
    label.textColor = .white
    label.backgroundColor = UIColor.systemRed.withAlphaComponent(0.85)
    label.font = .monospacedSystemFont(ofSize: 13, weight: .medium)
    label.textAlignment = .center
    label.numberOfLines = 0
    label.layer.cornerRadius = 10
    label.layer.masksToBounds = true
    view.addSubview(label)
    NSLayoutConstraint.activate([
      label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
      label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
      label.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 24),
      label.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -24),
    ])
  }

  // MARK: - Floor Switcher

  func setupFloorSwitcher(floors: [VMMSBaseFloor], currentFloor: VMMSBaseFloor?) {
    let switcher = FloorSwitcherView()
    switcher.translatesAutoresizingMaskIntoConstraints = false
    switcher.delegate = self
    switcher.configure(floors: floors, currentFloor: currentFloor)
    view.addSubview(switcher)

    NSLayoutConstraint.activate([
      switcher.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
      switcher.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
      switcher.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
      switcher.heightAnchor.constraint(greaterThanOrEqualToConstant: 50),
    ])
    view.bringSubviewToFront(switcher)
    self.floorSwitcher = switcher
  }

  func floorSwitcher(_ switcher: FloorSwitcherView, didSelectFloor floor: VMMSBaseFloor) {
    selectIndoorFloor(floor)
  }
}

// MARK: - Helpers

// Deterministic, cross-platform-stable order for ordinal ties:
// ordinal asc, then name (case-insensitive) asc, then uid asc.
private func sortFloors(_ floors: [VMMSBaseFloor]) -> [VMMSBaseFloor] {
  floors.sorted { a, b in
    if a.ordinal != b.ordinal { return a.ordinal < b.ordinal }
    let cmp = a.name.caseInsensitiveCompare(b.name)
    if cmp != .orderedSame { return cmp == .orderedAscending }
    return a.uid < b.uid
  }
}

private func allFloorsSorted(from vmd: VMMSMap) -> [VMMSBaseFloor] {
  vmd.buildings.values
    .sorted { $0.uid < $1.uid }
    .flatMap { sortFloors($0.floors.allValues as! [VMMSBaseFloor]) }
}

/// Lowest-ordinal (ground) floor for each real building, keyed by building uid.
/// Excludes the `venue_outdoors` pseudo-building (outdoor floors are handled
/// separately via activeOutdoorFloors).
private func groundFloorByBuilding(from vmd: VMMSMap) -> [String: VMMSBaseFloor] {
  var result: [String: VMMSBaseFloor] = [:]
  for building in vmd.buildings.values where building.uid != "venue_outdoors" {
    let floors = building.floors.allValues as! [VMMSBaseFloor]
    if let ground = floors.min(by: { $0.ordinal < $1.ordinal }) {
      result[building.uid] = ground
    }
  }
  return result
}

/// Fresh-load camera framing the union of every real building's outline
/// (excluding the venue_outdoors pseudo-building), falling back to their
/// ordinal-0 floor outlines. Returns nil when no such geometry exists (e.g.
/// outdoor-only venues) so callers can fall back to the VMD centerPoint.
private func groundFloorFitCamera(for vmd: VMMSMap, size: CGSize, maxZoom: Float)
  -> CameraSnapshot?
{
  guard size.width > 0, size.height > 0 else { return nil }
  let buildings = vmd.buildings.values.filter { $0.uid != "venue_outdoors" }
  var boundsList =
    buildings
    .filter { !$0.coordinates.isEmpty }
    .map { VMCoordinateBounds(multiCoordinates: $0.coordinates) }

  if boundsList.isEmpty {
    let floors = buildings
      .flatMap { $0.floors.allValues as! [VMMSBaseFloor] }
      .filter { $0.ordinal == 0 && !$0.coordinates.isEmpty }
    boundsList = floors.map { VMMapUtil.boundsForFloor($0) }
  }
  guard !boundsList.isEmpty else { return nil }

  let bounds = VMMapUtil.unionOfCoordinateBounds(boundsList)
  guard CLLocationCoordinate2DIsValid(bounds.northEast),
    CLLocationCoordinate2DIsValid(bounds.southWest)
  else { return nil }

  let center = CLLocationCoordinate2D(
    latitude: (bounds.northEast.latitude + bounds.southWest.latitude) / 2.0,
    longitude: (bounds.northEast.longitude + bounds.southWest.longitude) / 2.0)
  let fitZoom = VMMapUtil.getBoundsZoomLevel(
    bounds, mapWidth: Int(size.width), mapHeight: Int(size.height), maxZoom: maxZoom)
  return CameraSnapshot(
    target: center, zoom: max(0, fitZoom - CAMERA_FIT_PADDING), bearing: 0, tilt: 0)
}

private func floorGroups(from vmd: VMMSMap) -> [FloorListController.Group] {
  vmd.buildings.values
    .sorted { $0.uid < $1.uid }
    .map { building in
      FloorListController.Group(
        buildingId: building.uid,
        buildingName: building.name,
        floors: sortFloors(building.floors.allValues as! [VMMSBaseFloor]))
    }
}

// MARK: - VMDParserDelegate

extension AppleVenueMapController: VMDParserDelegate {

  @objc func didFinishLoadingVenueMapData(_ vmd: VMMSMap) {
    guard CLLocationCoordinate2DIsValid(vmd.centerPoint) else {
      NSLog("ERROR: VMD has invalid centerPoint: \(vmd.centerPoint)")
      showErrorOverlay("VMD has invalid centerPoint: \(vmd.centerPoint)")
      return
    }

    self.vmd = vmd
    self.vmd.initialZoomLevel = 19
    self.vmd.initialRotation = 0

    setupMapLibreStack()
  }

  @objc func didFailToLoadVenueMapData(error: (any Error)?) {
    let msg = error?.localizedDescription ?? "unknown error"
    NSLog("Failed to load venue map data: \(msg)")
    DispatchQueue.main.async { [weak self] in
      self?.showErrorOverlay("Failed to load venue data\n\(msg)")
    }
  }
}

// MARK: - VMMapViewDelegate

extension AppleVenueMapController: VMMapViewDelegate {

  func didFinishLoadingMapView(_ mapView: VMMapView) {
    // Apply captured camera (set by toggleBasemap) if available; otherwise
    // VMD defaults. lastCamera is only ever populated by captureCurrentCamera,
    // so it cannot be polluted by the load-animation frames.
    let cam = lastCamera
      ?? groundFloorFitCamera(for: vmd, size: view.bounds.size, maxZoom: mapView.maxZoom)
      ?? CameraSnapshot(
        target: vmd.centerPoint,
        zoom: self.vmd.initialZoomLevel - 1,
        bearing: Double(vmd.initialRotation),
        tilt: 0)
    self.mapView?.setMapPosition(
      target: cam.target, bearing: Float(cam.bearing), zoomLevel: cam.zoom)
    bearingCompass?.update(bearing: cam.bearing)
    floorSwitcher.map { view.bringSubviewToFront($0) }
    bearingCompass.map { view.bringSubviewToFront($0) }
    removeBasemap3DBuildingExtrusions(on: mapView)
    // Hide MapLibre's native compass. It fades in on rotation at the top-right,
    // directly behind our reload button (looks like a stray shadow), and is
    // redundant with our BearingCompassView. compassVisibility (not isHidden)
    // is required, as MapLibre's adaptive logic re-shows it on rotation.
    // Hide MapLibre's native compass. It fades in on rotation at the top-right,
    // directly behind our reload button (looks like a stray shadow), and is
    // redundant with our BearingCompassView. compassVisibility (not isHidden)
    // is required, as MapLibre's adaptive logic re-shows it on rotation.
    mapView.mapView?.compassView.compassVisibility = .hidden
  }

  /// Strip any fill-extrusion layers from the underlying MapLibre style so
  /// the giant translucent 3D building shapes from the MapTiler streets style
  /// (e.g. `building-3d`) do not occlude the venue overlay. Venue layers use
  /// MGLFill/Line/Symbol/Raster/Background style classes, never extrusion,
  /// so this is safe to apply unconditionally on both stacks.
  private func removeBasemap3DBuildingExtrusions(on mapView: VMMapView) {
    guard let style = mapView.mapView?.style else { return }
    for layer in style.layers where layer is MGLFillExtrusionStyleLayer {
      style.removeLayer(layer)
    }
  }

  func didChangeCameraPosition(toLocation newLocation: CLLocationCoordinate2D, toZoom newZoom: Float, toBearing newBearing: Double, toTilt newTilt: Double) {
    // Intentionally NOT writing lastCamera here: load-animation frames would
    // pollute it with zoom=1 etc. captureCurrentCamera() reads live values
    // directly from the active mapView at toggle time.
    bearingCompass?.update(bearing: newBearing)
  }

  func canSelectUnit(_ unit: VMMSMapUnit) -> Bool { true }

  func didSelectUnit(_ unit: VMMSMapUnit?) {
    guard let u = unit else {
      showTapInfo("Deselected")
      return
    }
    let floor = u.floor?.uid ?? "?"
    var parts = [
      "uid: \(u.uid)",
      u.name.isEmpty ? nil : "name: \(u.name)",
      u.category.isEmpty ? nil : "cat: \(u.category)",
      "floor: \(floor)",
    ].compactMap { $0 }
    if let sqft = GeoJSONAreaUtil.squareFeet(from: u.geojsonCoordinates) {
      parts.append(String(format: "area: %.0f sq ft", sqft))
    }
    showTapInfo(parts.joined(separator: "\n"))
  }

  func didTapAtLocationCoordinate(_ location: CLLocationCoordinate2D) {
    guard let mglView = mapView?.mapView else { return }
    let screenPoint = mglView.convert(location, toPointTo: mglView)

    var lines: [String] = [String(format: "tap: %.6f, %.6f", location.latitude, location.longitude)]

    for layer in mglView.style?.layers ?? [] {
      let hits = mglView.visibleFeatures(at: screenPoint, styleLayerIdentifiers: [layer.identifier])
      if hits.isEmpty { continue }

      let srcId: String
      if let fg = layer as? MGLForegroundStyleLayer, let s = fg.sourceIdentifier {
        srcId = s
      } else {
        srcId = "?"
      }
      let origin = classifySource(srcId)

      for feature in hits.prefix(3) {
        let a = feature.attributes
        let cls = a["class"] as? String ?? ""
        let fid = a["feature_id"] as? String ?? a["FEATURE_ID"] as? String ?? ""
        let floorId = a["floor_id"] as? String ?? a["FLOOR_ID"] as? String ?? ""
        lines.append("layer: \(layer.identifier)\n  source: \(origin)\n  class: \(cls)  fid: \(fid)  floor: \(floorId)")
      }
      if hits.count > 3 { lines.append("  ...+\(hits.count - 3) more in \(layer.identifier)") }
    }

    if lines.count == 1 {
      lines.append("(no rendered features)")
    }
    showTapInfo(lines.joined(separator: "\n"), duration: 30)
  }

  func didFailToLoadMapView(error: Error) {
    NSLog("Failed to load mapView: \(error.localizedDescription)")
    DispatchQueue.main.async { [weak self] in
      self?.showErrorOverlay("Failed to load map view\n\(error.localizedDescription)")
    }
  }

  private func classifySource(_ srcId: String) -> String {
    if srcId == "geojson_common_labels" { return "GeoJSON labels file" }
    if srcId == "geojson_common_amenities" { return "GeoJSON amenities file" }
    if srcId.hasPrefix("geojson_") { return "GeoJSON outlines (runtime from VMD coords)" }
    if srcId.hasPrefix("floor_common_") { return "MVT common tiles (icons/labels from SVG)" }
    if srcId.hasPrefix("floor_art_") { return "Raster art tiles (PNG)" }
    if srcId.hasPrefix("building_common_") { return "GeoJSON building labels (runtime)" }
    if srcId == "openmaptiles" { return "OpenMapTiles base map" }
    if srcId.hasPrefix("floor_") || srcId.hasPrefix("outdoors") { return "MVT floor tiles (geometry from SVG)" }
    if srcId == "?" { return "unknown" }
    return "MVT floor tiles (geometry from SVG)"
  }
}
