//
//  GeoJSON.swift
//  Go Map!!
//
//  Created by Bryce Cogswell on 10/25/22.
//  Copyright © 2022 Bryce Cogswell. All rights reserved.
//

import Foundation
import UIKit.UIBezierPath

enum GeoJsonError: Error {
	case invalidDataShapeForType
	case missingType
	case lineStringHasTooFewPoints
	case polygonHasTooFewPoints
	case badCoordinateArray(Int)
	case invalidGeometryType(String)
}

struct GeoJSONFeatureCollection: Decodable {
	let type: FeatureCollectionType // always "FeatureCollection"
	let features: [GeoJSONFeature]

	enum FeatureCollectionType: String, Codable {
		case FeatureCollection
	}

	init(data: Data) throws {
		self = try JSONDecoder().decode(Self.self, from: data)
	}

	init(url: URL) throws {
		let data = try Data(contentsOf: url)
		try self.init(data: data)
	}

	func firstPoint() -> LatLon? {
		// First try to get a point connected to a line
		if let pt = features.lazy.compactMap({ $0.geometry?.firstLinePoint() }).first {
			return pt
		}
		// If that fails then take any point
		return features.first?.geometry?.firstPoint()
	}
}

struct GeoJSONFeature: Decodable {
	let type: FeatureType // always "Feature"
	let id: String? // String or Number
	let geometry: GeoJSONGeometry?
	let properties: AnyJSON?

	enum FeatureType: String, Codable {
		case Feature
	}

	init(type: FeatureType,
	     id: String?,
	     geometry: GeoJSONGeometry?,
	     properties: AnyJSON?)
	{
		self.type = type
		self.id = id
		self.geometry = geometry
		self.properties = properties
	}

	// Use this for an empty Feature
	init() {
		self.init(type: .Feature,
		          id: nil,
		          geometry: nil,
		          properties: nil)
	}

	init(from decoder: Decoder) throws {
		enum CodingKeys: String, CodingKey {
			case type
			case id
			case geometry
			case properties
		}
		let container = try decoder.container(keyedBy: CodingKeys.self)
		type = try container.decode(FeatureType.self, forKey: .type)
		do {
			id = try container.decodeIfPresent(String.self, forKey: .id)
		} catch {
			let num = try container.decode(Int.self, forKey: .id)
			id = "\(num)"
		}
		geometry = try container.decodeIfPresent(GeoJSONGeometry.self, forKey: .geometry)
		properties = try container.decodeIfPresent(AnyJSON.self, forKey: .properties)
	}
}

extension LatLon {
	init(array: [Double]) throws {
		guard (2...3).contains(array.count) else {
			throw GeoJsonError.badCoordinateArray(array.count)
		}
		lon = array[0]
		lat = array[1]
		// also permissible to have an altitude component, which we ignore
	}

	init(array: [NSNumber]) throws {
		guard (2...3).contains(array.count) else {
			throw GeoJsonError.badCoordinateArray(array.count)
		}
		lon = array[0].doubleValue
		lat = array[1].doubleValue
		// also permissible to have an altitude component, which we ignore
	}

	init(from decoder: Decoder) throws {
		do {
			enum CodingKeys: String, CodingKey {
				case lat
				case lon
			}
			let container = try decoder.container(keyedBy: CodingKeys.self)
			let lat = try container.decode(Double.self, forKey: .lat)
			let lon = try container.decode(Double.self, forKey: .lon)
			self.init(lon: lon, lat: lat)
		} catch {
			// try decoding as [Double]
			let container = try decoder.singleValueContainer()
			let array = try container.decode([Double].self)
			try self.init(array: array)
		}
	}
}

struct GeoJSONGeometry: Codable {
	let geometryPoints: Geometry
	let latLonBezierPath: UIBezierPath?
	let uuid = UUID()

	typealias LineString = [LatLon]
	typealias Polygon = [[LatLon]]

	enum GeometryType: String, Codable {
		case Point
		case MultiPoint
		case LineString
		case MultiLineString
		case Polygon
		case MultiPolygon
		case GeometryCollection
	}

	enum Geometry: Codable {
		case point(points: LatLon)
		case multiPoint(points: [LatLon])
		case lineString(points: LineString)
		case multiLineString(points: [LineString])
		case polygon(points: Polygon)
		case multiPolygon(points: [Polygon])
		case geometryCollection(points: [GeoJSONGeometry])

		private enum CodingKeys: String, CodingKey {
			case type
			case coordinates
			case geometries
		}

		func isPoint() -> Bool {
			if case .point = self {
				return true
			}
			return false
		}

		// This is called when parsing NSI geojsons, CountryCoder, etc
		init(from decoder: Decoder) throws {
			do {
				let container = try decoder.container(keyedBy: CodingKeys.self)
				let type = try container.decode(GeometryType.self, forKey: .type)
				switch type {
				case .Point:
					let points = try container.decode(LatLon.self, forKey: .coordinates)
					self = .point(points: points)
				case .MultiPoint:
					let points = try container.decode([LatLon].self, forKey: .coordinates)
					self = .multiPoint(points: points)
				case .LineString:
					let points = try container.decode([LatLon].self, forKey: .coordinates)
					self = .lineString(points: points)
				case .MultiLineString:
					let points = try container.decode([[LatLon]].self, forKey: .coordinates)
					self = .multiLineString(points: points)
				case .Polygon:
					let points = try container.decode([[LatLon]].self, forKey: .coordinates)
					self = .polygon(points: points)
				case .MultiPolygon:
					let points = try container.decode([[[LatLon]]].self, forKey: .coordinates)
					self = .multiPolygon(points: points)
				case .GeometryCollection:
					let points = try container.decode([GeoJSONGeometry].self, forKey: .geometries)
					self = .geometryCollection(points: points)
				}
			} catch {
				print("\(error)")
				throw error
			}
		}

		func encode(to encoder: any Encoder) throws {
			// not implemented
			fatalError()
		}

		// Everything below is for when we decoded the JSON explicitely:

		private init(pointJSON json: Any) throws {
			guard let nsPoints = json as? [NSNumber] else { throw GeoJsonError.invalidDataShapeForType }
			self = try .point(points: LatLon(array: nsPoints))
		}

		private init(multiPointJSON json: Any) throws {
			guard let nsPoints = json as? [[NSNumber]] else { throw GeoJsonError.invalidDataShapeForType }
			self = try .multiPoint(points: nsPoints.map { try LatLon(array: $0) })
		}

		private init(lineStringJSON json: Any) throws {
			guard let nsPoints = json as? [[NSNumber]] else { throw GeoJsonError.invalidDataShapeForType }
			self = try .lineString(points: nsPoints.map { try LatLon(array: $0) })
		}

		private init(multiLineStringJSON json: Any) throws {
			guard let nsPoints = json as? [[[NSNumber]]] else { throw GeoJsonError.invalidDataShapeForType }
			self = try .multiLineString(points: nsPoints.map { try $0.map { try LatLon(array: $0) }})
		}

		private init(polygonJSON json: Any) throws {
			guard let nsPoints = json as? [[[NSNumber]]] else { throw GeoJsonError.invalidDataShapeForType }
			self = try .polygon(points: nsPoints.map { try $0.map { try LatLon(array: $0) }})
		}

		private init(multiPolygonJSON json: Any) throws {
			guard let nsPoints = json as? [[[[NSNumber]]]] else { throw GeoJsonError.invalidDataShapeForType }
			self = try .multiPolygon(points: nsPoints.map { try $0.map { try $0.map { try LatLon(array: $0) }}})
		}

		private init(geometryCollectionJSON json: Any) throws {
			guard let list = json as? [[String: Any]] else { throw GeoJsonError.invalidDataShapeForType }
			let geomList = try list.map { try GeoJSONGeometry(geometry: $0) }
			self = .geometryCollection(points: geomList)
		}

		// This init is used by TileServerList, where the JSON is already decoded.
		// Also used by CountryCoder
		init(json: [String: Any]) throws {
			guard
				let type = json["type"] as? String,
				let type = GeometryType(rawValue: type),
				let points = json["coordinates"]
			else {
				throw GeoJsonError.missingType
			}
			switch type {
			case .Point:
				self = try Geometry(pointJSON: points)
			case .MultiPoint:
				self = try Geometry(multiPointJSON: points)
			case .LineString:
				self = try Geometry(lineStringJSON: points)
			case .MultiLineString:
				self = try Geometry(multiLineStringJSON: points)
			case .Polygon:
				self = try Geometry(polygonJSON: points)
			case .MultiPolygon:
				self = try Geometry(multiPolygonJSON: points)
			case .GeometryCollection:
				self = try Geometry(geometryCollectionJSON: points)
			}
		}
	}

	init(geometry: Geometry) {
		geometryPoints = geometry
		do {
			latLonBezierPath = try geometryPoints.bezierPath()
		} catch {
			print("GeoJSON bezier path: \(error)")
			latLonBezierPath = nil
		}
	}

	init(geometry: [String: Any]) throws {
		try self.init(geometry: Geometry(json: geometry))
	}

	init?(geometry: [String: Any]?) throws {
		guard let geometry = geometry else { return nil }
		try self.init(geometry: geometry)
	}

	init(from decoder: Decoder) throws {
		do {
			try self.init(geometry: Geometry(from: decoder))
		} catch {
			print("\(error)")
			throw error
		}
	}

	func firstPoint() -> LatLon? {
		switch geometryPoints {
		case let .point(points: pt):
			return pt
		case let .multiPoint(points: pts):
			return pts.first
		default:
			if let pt = latLonBezierPath?.cgPath.getPoints().first {
				return LatLon(lon: pt.x, lat: pt.y)
			}
		}
		return nil
	}

	func firstLinePoint() -> LatLon? {
		if let pt = latLonBezierPath?.cgPath.getPoints().first {
			return LatLon(lon: pt.x, lat: pt.y)
		}
		return nil
	}

	func encode(to encoder: Encoder) throws {
		try geometryPoints.encode(to: encoder)
	}

	func contains(_ point: CGPoint) -> Bool {
		return latLonBezierPath?.contains(point) ?? false
	}

	func contains(_ latLon: LatLon) -> Bool {
		let cgPoint = CGPoint(x: latLon.lon, y: latLon.lat)
		return contains(cgPoint)
	}
}

// MARK: Bezier path stuff

extension GeoJSONGeometry.Geometry {
	private static func cgForPoint(_ point: LatLon) -> CGPoint {
		return CGPoint(x: point.lon, y: point.lat)
	}

	private static func addLineStringPoints(_ points: [LatLon], to path: UIBezierPath) throws {
		if points.count < 2 {
			throw GeoJsonError.lineStringHasTooFewPoints
		}
		path.move(to: Self.cgForPoint(points[0]))
		for pt in points.dropFirst() {
			path.addLine(to: Self.cgForPoint(pt))
		}
	}

	private static func addLoopPoints(_ points: [LatLon], to path: UIBezierPath) throws {
		if points.count < 4 {
			throw GeoJsonError.polygonHasTooFewPoints
		}
		path.move(to: Self.cgForPoint(points[0]))
		for pt in points.dropFirst() {
			path.addLine(to: Self.cgForPoint(pt))
		}
		path.close()
	}

	// A Polygon is an outer ring plus optional holes
	private static func addPolygonPoints(_ points: [[LatLon]], to path: UIBezierPath) throws {
		for loop in points {
			try Self.addLoopPoints(loop, to: path)
		}
	}

	// A MultiPolygon is a list of Polygons
	private static func addMultiPolygonPoints(_ points: [[[LatLon]]], to path: UIBezierPath) throws {
		for loop in points {
			try Self.addPolygonPoints(loop, to: path)
		}
	}

	static func bezierPathFor(lineString points: [LatLon]) throws -> UIBezierPath {
		let path = UIBezierPath()
		try Self.addLineStringPoints(points, to: path)
		return path
	}

	static func bezierPathFor(multiLineString points: [[LatLon]]) throws -> UIBezierPath {
		let path = UIBezierPath()
		for line in points {
			try Self.addLineStringPoints(line, to: path)
		}
		return path
	}

	static func bezierPathFor(polygon points: [[LatLon]]) throws -> UIBezierPath {
		let path = UIBezierPath()
		try Self.addPolygonPoints(points, to: path)
		return path
	}

	static func bezierPathFor(multipolygon points: [[[LatLon]]]) throws -> UIBezierPath {
		let path = UIBezierPath()
		try Self.addMultiPolygonPoints(points, to: path)
		return path
	}

	func bezierPath() throws -> UIBezierPath {
		switch self {
		case .point, .multiPoint:
			return UIBezierPath()
		case let .lineString(points):
			return try Self.bezierPathFor(lineString: points)
		case let .multiLineString(points):
			return try Self.bezierPathFor(multiLineString: points)
		case let .polygon(points):
			return try Self.bezierPathFor(polygon: points)
		case let .multiPolygon(points):
			return try Self.bezierPathFor(multipolygon: points)
		case let .geometryCollection(points):
			let all = UIBezierPath()
			for geo in points {
				let path = try geo.geometryPoints.bezierPath()
				all.append(path)
			}
			return all
		}
	}
}
