//
//  MapViewPort.swift
//  Go Map!!
//
//  Created by Bryce Cogswell on 1/10/26.
//  Copyright © 2026 Bryce Cogswell. All rights reserved.
//

import CoreLocation
import UIKit

enum ViewPortRotationStyle { case none, smallShift, largeShift }

// Allows other layers of the map to view changes to the map view
protocol MapViewPort: AnyObject {
	var mapTransform: MapTransform { get }
}

final class MapViewPortObject: MapViewPort {
	var mapTransform = MapTransform()
}

// Add functions for retrieving metrics about the viewport (read-only)
extension MapViewPort {
	func screenCenterPoint() -> CGPoint {
		return AppDelegate.shared.mainView.mapLayersView.bounds.center()
	}

	func metersPerPixel() -> Double {
		return mapTransform.metersPerPixel(atScreenPoint: screenCenterPoint())
	}

	func pixelsPerDegree() -> OSMSize {
		let metersPerPixel = metersPerPixel()
		let metersPerDegree = MetersPerDegreeAt(latitude: screenCenterLatLon().lat)
		return OSMSize(width: metersPerDegree.x / metersPerPixel,
		               height: metersPerDegree.y / metersPerPixel)
	}

	func boundingMapRectForScreen() -> OSMRect {
		let rc = OSMRect(AppDelegate.shared.mainView.mapLayersView.bounds)
		DbgAssert(rc.size.width != 0) // Don't call before child views have been layed out
		return mapTransform.boundingMapRect(forScreenRect: rc)
	}

	func boundingLatLonForScreen() -> OSMRect {
		let rc = boundingMapRectForScreen()
		let rect = MapTransform.latLon(forMapRect: rc)
		return rect
	}

	func screenCenterLatLon() -> LatLon {
		return mapTransform.latLon(forScreenPoint: screenCenterPoint())
	}
}

// MARK: Resize & movement
private let DisplayLinkHeading = "Heading"

extension MapViewPort {

	func isLocationSpecified() -> Bool {
		return !(mapTransform.transform == .identity)
	}

	func adjustOrigin(by delta: CGPoint) {
		if delta.x == 0.0, delta.y == 0.0 {
			return
		}

		let o = OSMTransform.translation(Double(delta.x), Double(delta.y))
		let t = mapTransform.transform.concat(o)
		mapTransform.transform = t
	}

	func adjustZoom(by ratio: CGFloat, aroundScreenPoint zoomCenter: CGPoint) {
		guard
			ratio != 1.0
		else {
			return
		}

		let maxZoomIn = Double(Int(1) << 30)

		let scale = mapTransform.scale()
		var ratio = Double(ratio)
		if ratio * scale < 1.0 {
			ratio = 1.0 / scale
		}
		if ratio * scale > maxZoomIn {
			ratio = maxZoomIn / scale
		}

		let offset = mapTransform.mapPoint(forScreenPoint: OSMPoint(zoomCenter), birdsEye: false)
		var t = mapTransform.transform
		t = t.translatedBy(dx: offset.x, dy: offset.y)
		t = t.scaledBy(ratio)
		t = t.translatedBy(dx: -offset.x, dy: -offset.y)
		mapTransform.transform = t
	}

	private func rotate(by angle: CGFloat, around center: CGPoint) {
		guard angle != 0.0 else {
			return
		}
		let offset = mapTransform.mapPoint(forScreenPoint: OSMPoint(center), birdsEye: false)
		var t = mapTransform.transform
		t = t.translatedBy(dx: offset.x, dy: offset.y)
		t = t.rotatedBy(Double(angle))
		t = t.translatedBy(dx: -offset.x, dy: -offset.y)
		mapTransform.transform = t
	}

	func rotate(by deltaHeading: Double,
	            around center: CGPoint,
	            animation: ViewPortRotationStyle)
	{
		// don't rotate the long way around
		var deltaHeading = deltaHeading
		while deltaHeading < -.pi {
			deltaHeading += 2 * .pi
		}
		while deltaHeading > .pi {
			deltaHeading -= 2 * .pi
		}

		if abs(deltaHeading) < 0.00001 {
			return
		}

		switch animation {
		case .none:
			self.rotate(by: deltaHeading, around: center)

		case .smallShift:
			var currentDelta = 0.0
			DisplayLink.shared.add(.rotateScreenSmoothing, block: { [weak self] in
				guard let self else { return }
				var delta = deltaHeading - currentDelta
				if abs(delta) < 0.0001 {
					DisplayLink.shared.remove(.rotateScreenSmoothing)
				} else {
					delta *= 0.15
				}
				currentDelta += delta
				self.rotate(by: delta, around: center)
			})

		case .largeShift:
			func easeInOutQuad(_ t: Double, _ b: Double, _ c: Double, _ d: Double) -> Double {
				var t = t
				t /= d / 2
				if t < 1 {
					return c / 2 * t * t + b
				}
				t -= 1
				return -c / 2 * (t * (t - 2) - 1) + b
			}

			let duration = 0.4
			var prevHeading: Double = 0
			let startTime = CACurrentMediaTime()
			DisplayLink.shared.add(.rotateScreenSmoothing, block: { [weak self] in
				guard let self else { return }

				var elapsedTime = CACurrentMediaTime() - startTime
				if elapsedTime > duration {
					elapsedTime = CFTimeInterval(duration) // don't want to over-rotate
				}
				// Rotate using an ease-in/out curve. This ensures that small changes in direction don't cause jerkiness.
				// result = interpolated value, t = current time, b = initial value, c = delta value, d = duration
				let miniHeading = easeInOutQuad(elapsedTime, 0, deltaHeading, duration)
				self.rotate(by: CGFloat(miniHeading - prevHeading), around: center)
				prevHeading = miniHeading
				if elapsedTime >= duration {
					DisplayLink.shared.remove(.rotateScreenSmoothing)
				}
			})
		}
	}

	func rotateBirdsEye(by angle: Double) {
		var angle = angle
		// limit maximum rotation
		var t = mapTransform.transform
		let maxRotation = Double(65 * (Double.pi / 180))
#if TRANSFORM_3D
		let currentRotation = atan2(t.m23, t.m22)
#else
		let currentRotation = Double(mapTransform.birdsEyeRotation)
#endif
		if currentRotation + angle > maxRotation {
			angle = maxRotation - currentRotation
		}
		if currentRotation + Double(angle) < 0 {
			angle = -currentRotation
		}

		let offset = mapTransform.mapPoint(forScreenPoint: OSMPoint(screenCenterPoint()),
		                                   birdsEye: false)

		t = t.translatedBy(dx: offset.x, dy: offset.y)
#if TRANSFORM_3D
		t = CATransform3DRotate(t, delta, 1.0, 0.0, 0.0)
#else
		mapTransform.birdsEyeRotation += angle
#endif
		t = t.translatedBy(dx: -offset.x, dy: -offset.y)
		mapTransform.transform = t
	}

	func rotateToHeading(_ heading: Double) {
		// Rotate to face current compass heading
		let center = screenCenterPoint()
		let screenAngle = mapTransform.rotation()
		rotate(by: -(screenAngle + heading),
		       around: center,
		       animation: .largeShift)
	}
}

// Add functions for changing the location of the viewport
extension MapViewPort {

	func saveToUserDefaults() {
		let latLon = screenCenterLatLon()
		let scale = mapTransform.scale()
		UserPrefs.shared.view_scale.value = scale
		UserPrefs.shared.view_latitude.value = latLon.lat
		UserPrefs.shared.view_longitude.value = latLon.lon
	}

	func loadFromUserDefaults() throws {
		guard
			let lat = UserPrefs.shared.view_latitude.value,
			let lon = UserPrefs.shared.view_longitude.value,
			let scale = UserPrefs.shared.view_scale.value
		else {
			throw NSError()
		}
		setTransformFor(latLon: LatLon(latitude: lat, longitude: lon),
		                scale: scale,
		                rotation: 0.0)
	}

	// MARK: Set location
	// Try not to call this directly, since scale isn't something exposed.
	// Use one of the centerOn() functions instead.
	func setTransformFor(latLon: LatLon,
	                     scale newScale: Double? = nil,
	                     rotation: Double? = nil)
	{
		// Current matrix
		let old = mapTransform.transform

		// Extract current scale from matrix magnitude
		let currentScale = mapTransform.scale()
		let scale = newScale ?? currentScale

		let a, b, c, d: Double

		if let rotation = rotation {
			// Build rotation+scale matrix from scratch
			let angle = -rotation
			let cos = Foundation.cos(angle)
			let sin = Foundation.sin(angle)
			a = cos * scale
			b = sin * scale
			c = -sin * scale
			d = cos * scale
		} else {
			// Scale the matrix without touching rotation
			let factor = scale / currentScale
			a = old.a * factor
			b = old.b * factor
			c = old.c * factor
			d = old.d * factor
		}

		// Compute translation to center on the new lat/lon
		let pt = MapTransform.mapPoint(forLatLon: latLon)
		let tx = -(pt.x * a + pt.y * c)
		let ty = -(pt.x * b + pt.y * d)

		mapTransform.transform = OSMTransform(a: a, b: b, c: c, d: d, tx: tx, ty: ty)
	}

	// center without changing zoom or rotation, such as when pressing the Center button
	func centerOn2(latLon: LatLon, zoom: Double?, rotation: Double?) {
		let scale: Double?
		if let zoom {
			scale = pow(2.0, zoom)
		} else {
			scale = nil
		}
		setTransformFor(latLon: latLon,
		                scale: scale,
		                rotation: rotation)
	}

	/// Centers on a location specified by the user, such as a selected GPX track, etc. where zoom and orientation is
	/// alway expected to change.
	/// GPS should be disabled when called.
	/// Heading is always set to North.
	func centerOn2(latLon: LatLon, metersWide: Double?, orientNorth: Bool) {
		let metersWide = metersWide ?? 20.0
		let degrees = metersToDegrees(meters: metersWide, latitude: latLon.lat)
		let scale = 360 / (degrees / 2)
		setTransformFor(latLon: latLon,
		                scale: scale,
		                rotation: orientNorth ? 0.0 : nil)
	}
}
