//
//  OsmBaseObject.swift
//  Go Map!!
//
//  Created by Wolfgang Timme on 1/18/20.
//  Copyright © 2020 Bryce Cogswell. All rights reserved.
//

import UIKit

enum ONEWAY: Int {
	case BACKWARD = -1
	case NONE = 0
	case FORWARD = 1
}

enum TRISTATE: Int {
	case UNKNOWN
	case YES
	case NO
}

let PATH_SCALING = (256 * 256.0)

typealias OsmIdentifier = Int64

struct OsmExtendedIdentifier: Equatable, Hashable {
	private static let typeWidth = 2
	private static let identWidth = Int64.bitWidth - typeWidth // 62
	private static let typeMask = (1 << typeWidth) - 1 // 0b11

	private let rawValue: Int64

	init(_ type: OSM_TYPE, _ ident: OsmIdentifier) {
		rawValue = (ident << Self.typeWidth) | Int64(type.rawValue & Self.typeMask)
	}

	var type: OSM_TYPE {
		OSM_TYPE(rawValue: Int(rawValue) & Self.typeMask)!
	}

	var ident: OsmIdentifier {
		rawValue >> Self.typeWidth // arithmetic shift sign-extends automatically
	}

	var debugDescription: String {
		"\(type.string)-\(ident)"
	}
}

enum GEOMETRY: String, Codable {
	case POINT = "point"
	case LINE = "line"
	case AREA = "area"
	case VERTEX = "vertex"
}

@objcMembers
class OsmBaseObject: NSObject, NSCoding, NSCopying {
	private(set) final var ident: OsmIdentifier
	private(set) final var user: String
	private(set) final var timestamp: String
	private(set) final var version: Int
	private(set) final var changeset: OsmIdentifier
	private(set) final var uid: Int
	private(set) final var visible: Bool
	final weak var mapData: OsmMapData?

	final var isShown = TRISTATE.UNKNOWN

	// extra stuff

	final var _constructed = false

	private(set) final var deleted = false

	final var renderInfo: RenderInfo?
	private(set) final var modifyCount: Int32 = 0
	final var notificationService = NotificationService<OsmBaseObject>()

	var parentRelations: [OsmRelation] {
		return mapData?.parentRelations(for: extendedIdentifier) ?? []
	}

	override init() {
		ident = 0
		user = ""
		timestamp = ""
		version = 0
		changeset = 0
		uid = 0
		visible = false
		deleted = false
		modifyCount = 0
	}

	override var hash: Int {
		var hasher = Hasher()
		hasher.combine(ident)
		return hasher.finalize()
	}

	// The default NSObject isEqual checks for object equivalence which is incorrect for us
	override func isEqual(_ other: Any?) -> Bool {
		guard let other = other as? OsmBaseObject
		else {
			DbgAssert(false)
			return false
		}
		if self === other {
			return true
		}
		// Occasionally we'll build a temporary object and then see if that object already exists,
		// in which case don't assert if either object is not marked as constructed
		let equal = ident == other.ident && type(of: self) === type(of: other)
		DbgAssert(!equal
			|| !constructed()
			|| !other.constructed()) // There should never be more than one copy of an object
		return equal
	}

	func encode(with coder: NSCoder) {
		coder.encode(NSNumber(value: ident), forKey: "ident")
		coder.encode(user, forKey: "user")
		coder.encode(timestamp, forKey: "timestamp")
		coder.encode(Int(version), forKey: "version")
		coder.encode(Int(changeset), forKey: "changeset")
		coder.encode(Int(uid), forKey: "uid")
		coder.encode(visible, forKey: "visible")
		coder.encode(tags, forKey: "tags")
		coder.encode(deleted, forKey: "deleted")
		coder.encode(modifyCount, forKey: "modified")
	}

	required init?(coder: NSCoder) {
		ident = (coder.decodeObject(forKey: "ident") as? NSNumber)?.int64Value ?? 0
		user = coder.decodeObject(forKey: "user") as? String ?? ""
		timestamp = coder.decodeObject(forKey: "timestamp") as? String ?? ""
		version = Int(coder.decodeInt32(forKey: "version"))
		changeset = OsmIdentifier(coder.decodeInteger(forKey: "changeset"))
		uid = Int(coder.decodeInt32(forKey: "uid"))
		visible = coder.decodeBool(forKey: "visible")
		tags = coder.decodeObject(forKey: "tags") as? [String: String] ?? [:]
		deleted = coder.decodeBool(forKey: "deleted")
		modifyCount = coder.decodeInt32(forKey: "modified")
		super.init()
		assert(ident != 0)
	}

	init(withVersion version: Int,
	     changeset: Int64,
	     user: String,
	     uid: Int,
	     ident: Int64,
	     timestamp: String,
	     tags: [String: String])
	{
		var timestamp = timestamp
		if timestamp == "" {
			timestamp = OsmBaseObject.rfc3339DateFormatter().string(from: Date())
		}
		self.version = version
		self.changeset = changeset
		self.user = user
		self.uid = uid
		visible = true
		self.ident = ident
		self.timestamp = timestamp
		self.tags = tags
		super.init()
	}

	/// Initialize with XML downloaded from OSM server
	init?(fromXmlDict attributeDict: [String: String]) {
		if let version2 = attributeDict["version"],
		   let version = Int(version2),
		   let changeset2 = attributeDict["changeset"],
		   let changeset = Int64(changeset2),
		   let ident2 = attributeDict["id"],
		   let ident = Int64(ident2),
		   let timestamp = attributeDict["timestamp"]
		{
			self.version = version
			self.changeset = changeset
			user = attributeDict["user"] ?? "" // can be missing on old objects
			uid = Int(attributeDict["uid"] ?? "") ?? 0 // can be missing on old objects
			visible = true
			self.ident = ident
			self.timestamp = timestamp
			tags = [:]
			super.init()
		} else {
			return nil
		}
	}

	var osmType: OSM_TYPE { return self is OsmNode ? .NODE : self is OsmWay ? .WAY : .RELATION }

	var extendedIdentifier: OsmExtendedIdentifier {
		return OsmExtendedIdentifier(osmType, ident)
	}

	override var debugDescription: String {
		return description
	}

	// attributes

	private(set) var tags: [String: String] = [:]

	public var _boundingBox: OSMRect? // public only so subclasses can set it
	var boundingBox: OSMRect {
		if _boundingBox == nil {
			computeBoundingBox()
		}
		return _boundingBox!
	}

	var shapeLayers: [CALayer & LayerPropertiesProviding]?

	override var description: String {
		var text =
			"id=\(ident) constructed=\(_constructed ? "Yes" : "No") deleted=\(deleted ? "Yes" : "No") modifyCount=\(modifyCount)"
		for (key, value) in tags {
			text += "\n  '\(key)' = '\(value)'"
		}
		return text
	}

	func hasInterestingTags() -> Bool {
		for (key, _) in tags {
			if OsmTags.IsInterestingKey(key) {
				return true
			}
		}
		return false
	}

	func isCoastline() -> Bool {
		guard let natural = tags["natural"] else {
			return false
		}
		if natural == "coastline" {
			return self is OsmWay
		}
		if natural == "water" {
			if let way = self as? OsmWay {
				// if it is closed it's a lake
				return !way.isClosed()
			} else if let relation = self as? OsmRelation {
				// If the way is part of a larger body of water then process it as coastline,
				// since we need to connect the various pieces together for rendering
				return relation.isMultipolygon()
			}
		}
		return false
	}

	func isNode() -> OsmNode? {
		return nil
	}

	func isWay() -> OsmWay? {
		return nil
	}

	func isRelation() -> OsmRelation? {
		return nil
	}

	public func computeBoundingBox() {
		// This is specialized for OsmNode, OsmWay, OsmRelation so we should never get here
		fatalError()
	}

	func distance(toLineSegment point1: OSMPoint, point point2: OSMPoint) -> Double {
		// This is specialized for OsmNode, OsmWay, OsmRelation so we should never get here
		fatalError()
	}

	func selectionPoint() -> LatLon {
		// This is specialized for OsmNode, OsmWay, OsmRelation so we should never get here
		fatalError()
	}

	func latLonOnObject(forLatLon target: LatLon) -> LatLon {
		// This is specialized for OsmNode, OsmWay, OsmRelation so we should never get here
		fatalError()
	}

	func linePathForObject(withOptionalRefPoint refPoint: UnsafeMutablePointer<OSMPoint>?) -> CGPath? {
		let wayList: [OsmWay]
		if let way = self as? OsmWay {
			wayList = [way]
		} else if let rel = self as? OsmRelation {
			wayList = rel.waysInMultipolygon()
		} else {
			return nil
		}

		let path = CGMutablePath()
		var initial = OSMPoint(x: 0, y: 0)
		var haveInitial = false

		for way in wayList {
			var first = true
			for node in way.nodes {
				var pt = MapTransform.mapPoint(forLatLon: node.latLon)
				if pt.x.isInfinite {
					break
				}
				if !haveInitial {
					initial = pt
					haveInitial = true
				}
				pt.x -= initial.x
				pt.y -= initial.y
				pt.x *= PATH_SCALING
				pt.y *= PATH_SCALING
				if first {
					path.move(to: CGPoint(x: pt.x, y: pt.y))
					first = false
				} else {
					path.addLine(to: CGPoint(x: pt.x, y: pt.y))
				}
			}
		}

		if refPoint != nil, haveInitial {
			// place refPoint at upper-left corner of bounding box so it can be the origin for the frame/anchorPoint
			let bbox = path.boundingBoxOfPath
			if !bbox.origin.x.isInfinite {
				var tran = CGAffineTransform(translationX: -bbox.origin.x, y: -bbox.origin.y)
				let path2 = path.copy(using: &tran)!
				refPoint!.pointee = OSMPoint(x: initial.x + Double(bbox.origin.x) / PATH_SCALING,
				                             y: initial.y + Double(bbox.origin.y) / PATH_SCALING)
				return path2
			} else {
#if DEBUG
				DLog("bad path: \(self)")
#endif
			}
		}
		return path
	}

	func linePathForObject(withRefPoint refPoint: UnsafeMutablePointer<OSMPoint>) -> CGPath? {
		return linePathForObject(withOptionalRefPoint: refPoint)
	}

	func linePathForObject() -> CGPath? {
		return linePathForObject(withOptionalRefPoint: nil)
	}

	// suitable for drawing polygon areas with holes, etc.
	func shapePathForObject(withRefPoint pRefPoint: UnsafeMutablePointer<OSMPoint>) -> CGPath? {
		assertionFailure()
		return nil
	}

	static var _nextUnusedIdentifier = UserPrefs.shared.nextUnusedIdentifier.value ?? 0
	static func nextUnusedIdentifier() -> Int64 {
		Self._nextUnusedIdentifier -= 1
		UserPrefs.shared.nextUnusedIdentifier.value = Self._nextUnusedIdentifier
		return Int64(Self._nextUnusedIdentifier)
	}

	// MARK: Construction

	func constructTag(_ tag: String, value: String) {
		// drop deprecated tags
		if tag == "created_by" {
			return
		}

		assert(!_constructed)
		tags[tag] = value
	}

	func constructed() -> Bool {
		return _constructed
	}

	func setConstructed() {
		_constructed = true
		modifyCount = 0
	}

	static let _rfc3339DateFormatter: DateFormatter = {
		let format = DateFormatter()
		format.locale = NSLocale(localeIdentifier: "en_US_POSIX") as Locale
		format.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'"
		format.timeZone = NSTimeZone(forSecondsFromGMT: 0) as TimeZone
		return format
	}()

	static func rfc3339DateFormatter() -> DateFormatter {
		return _rfc3339DateFormatter
	}

	func dateForTimestamp() -> Date {
		if let date = Self.rfc3339DateFormatter().date(from: timestamp) {
			return date
		}
		return Date()
	}

	func setTimestamp(_ date: Date, undo: MyUndoManager?) {
		if _constructed {
			undo?.registerUndo(
				withTarget: self,
				selector: #selector(setTimestamp(_:undo:)),
				objects: [dateForTimestamp(), undo!])
		}
		timestamp = Self.rfc3339DateFormatter().string(from: date)
	}

	/// Invalidates all cached rendering state for this object, forcing a full re-render on the next draw cycle.
	func clearCachedProperties() {
		renderInfo = nil
		isShown = .UNKNOWN
		_boundingBox = nil
		_geometry = nil

		for layer in shapeLayers ?? [] {
			layer.removeFromSuperlayer()
		}
		shapeLayers = nil

		// need to do this async because changes won't have been applied to the object yet
		DispatchQueue.main.async {
			self.notificationService.notify(self)
		}
	}

	func isModified() -> Bool {
		return modifyCount > 0
	}

	func incrementModifyCount(_ undo: MyUndoManager?) {
		assert(modifyCount >= 0)
		if _constructed {
			assert(undo != nil)
			// [undo registerUndoWithTarget:self selector:@selector(incrementModifyCount:) objects:@[undo]];
		}
		if undo?.isUndoing ?? false {
			modifyCount -= 1
		} else {
			modifyCount += 1
		}
		assert(modifyCount >= 0)

		// update cached values
		clearCachedProperties()
	}

	func resetModifyCount() {
		modifyCount = 0
		clearCachedProperties()
	}

	// MARK: Update properties with refreshed data from server

	func serverUpdate(ident: OsmIdentifier,
	                  version: Int,
	                  changeset: OsmIdentifier,
	                  timestamp: Date)
	{
		DbgAssert((self.ident < 0 && ident > 0) || self.ident == ident)
		self.ident = ident
		self.version = version
		self.changeset = changeset
		setTimestamp(timestamp, undo: nil)
	}

	func serverUpdate(with newerVersion: OsmBaseObject) {
		assert(ident == newerVersion.ident)
		assert(version < newerVersion.version)
		tags = newerVersion.tags
		user = newerVersion.user
		timestamp = newerVersion.timestamp
		version = newerVersion.version
		changeset = newerVersion.changeset
		uid = newerVersion.uid
		// derived data
		clearCachedProperties()
	}

	func setDeleted(_ deleted: Bool, undo: MyUndoManager?) {
		if _constructed {
			assert(undo != nil)
			incrementModifyCount(undo)
			undo!.registerUndo(
				withTarget: self,
				selector: #selector(setDeleted(_:undo:)),
				objects: [NSNumber(value: self.deleted), undo!])
		}
		self.deleted = deleted
	}

	func setTags(_ tags: [String: String], undo: MyUndoManager?) {
		if _constructed {
			assert(undo != nil)
			incrementModifyCount(undo!)
			undo!.registerUndo(withTarget: self, selector: #selector(setTags(_:undo:)), objects: [self.tags, undo!])
		}
		self.tags = tags
		clearCachedProperties()
	}

	// get all keys that contain another part, like "restriction:conditional"
	func extendedKeys(forKey key: String) -> [String] {
		return tags.keys.filter({
			$0.hasPrefix(key) && $0.dropFirst(key.count).first == ":"
		})
	}

	func nodeSet() -> Set<OsmNode> {
		fatalError()
	}

	func overlapsBox(_ box: OSMRect) -> Bool {
		return boundingBox.intersectsRect(box)
	}

	private enum Uses: Int {
		case name = 1
		case ref = 2
	}

	private static let givenNameHighwayTypes: [String: Uses] = [
		"motorway": .ref,
		"trunk": .ref,
		"primary": .ref,
		"secondary": .ref,
		"tertiary": .ref,
		"unclassified": .name,
		"residential": .name,
		"road": .name,
		"living_street": .name
	]
	func givenName() -> String? {
		// first try the name in the user's locale
		var preferredLocale = PresetLanguages.preferredLanguageCode()
		if let name = tags["name:\(preferredLocale)"] {
			return name
		}
		// if the language code has a dash then try it without it
		while let index = preferredLocale.lastIndex(of: "-") {
			preferredLocale = String(preferredLocale[..<index])
			if let name = tags["name:\(preferredLocale)"] {
				return name
			}
		}
		// then try name tag
		if let name = tags["name"] {
			return name
		}
		// then try name:en
		if let name = tags["name:en"] {
			return name
		}
		// then try any other name:* tag
		if let name = tags.first(where: { key, _ in
			key.starts(with: "name:")
		}) {
			return name.value
		}
		// then try addr:housename
		if let name = tags["addr:housename"] {
			return name
		}
		// for ways, use ref tag
		if isWay() != nil,
		   let highway = tags["highway"],
		   let uses = OsmBaseObject.givenNameHighwayTypes[highway],
		   uses == .ref,
		   let name = tags["ref"]
		{
			return name
		}
		// final fallback use brand
		return tags["brand"]
	}

	func friendlyDescription(withDetails details: Bool) -> String {
		if let name = givenName() {
			return name
		}

		let location = AppDelegate.shared.mainView.currentRegion
		if let feature = PresetsDatabase.shared.presetFeatureMatching(tags: tags,
		                                                              geometry: geometry(),
		                                                              location: location,
		                                                              includeNSI: true),
			!feature.isGeneric()
		{
			return feature.friendlyName()
		}

		if isRelation() != nil {
			var restriction = tags["restriction"]
			if restriction == nil {
				let a = extendedKeys(forKey: "restriction")
				if let key = a.last {
					restriction = tags[key]
				}
			}
			if let restriction = restriction {
				if restriction.hasPrefix("no_left_turn") {
					return NSLocalizedString("No Left Turn restriction", comment: "")
				}
				if restriction.hasPrefix("no_right_turn") {
					return NSLocalizedString("No Right Turn restriction", comment: "")
				}
				if restriction.hasPrefix("no_straight_on") {
					return NSLocalizedString("No Straight On restriction", comment: "")
				}
				if restriction.hasPrefix("only_left_turn") {
					return NSLocalizedString("Only Left Turn restriction", comment: "")
				}
				if restriction.hasPrefix("only_right_turn") {
					return NSLocalizedString("Only Right Turn restriction", comment: "")
				}
				if restriction.hasPrefix("only_straight_on") {
					return NSLocalizedString("Only Straight On restriction", comment: "")
				}
				if restriction.hasPrefix("no_u_turn") {
					return NSLocalizedString("No U-Turn restriction", comment: "")
				}
				return String.localizedStringWithFormat(NSLocalizedString("Restriction: %@", comment: ""), restriction)
			} else {
				let type = tags["type"] ?? "<>"
				return String.localizedStringWithFormat(NSLocalizedString("Relation: %@", comment: ""), type)
			}
		}

		// look for a feature key
		let featureKeys = PresetsDatabase.shared.allFeatureKeys()
		for (key, value) in tags {
			if featureKeys.contains(key) {
				return "\(key) = \(value)"
			}
		}

		// addresses
		for key in ["addr:housename", "addr:housenumber"] {
			if let value = tags[key] {
				return "\(key) = \(value)"
			}
		}

		// any non-ignored key
		for (key, value) in tags {
			if OsmTags.IsInterestingKey(key) {
				return "\(key) = \(value)"
			}
		}

		if let node = isNode() {
			if node.wayCount > 0 {
				return details
					? String.localizedStringWithFormat(
						NSLocalizedString("node %@ (in way)", comment: ""), "\(ident)")
					: NSLocalizedString("(node in way)", comment: "")
			}
			return details
				? String.localizedStringWithFormat(NSLocalizedString("node %@", comment: ""), "\(ident)")
				: NSLocalizedString("(node)", comment: "")
		}

		if isWay() != nil {
			return details
				? String.localizedStringWithFormat(NSLocalizedString("way %@", comment: ""), "\(ident)")
				: NSLocalizedString("(way)", comment: "")
		}

		if isRelation() != nil {
			if let type = tags["type"] {
				if let name = tags[type] {
					return "\(type) (\(name))"
				} else {
					return String.localizedStringWithFormat(NSLocalizedString("%@ (relation)", comment: ""), type)
				}
			}
			return String.localizedStringWithFormat(
				NSLocalizedString("(relation %@)", comment: ""), "\(ident)")
		}
		return NSLocalizedString("other object", comment: "")
	}

	func friendlyDescription() -> String {
		return friendlyDescription(withDetails: false)
	}

	func friendlyDescriptionWithDetails() -> String {
		return friendlyDescription(withDetails: true)
	}

	func copy(with zone: NSZone? = nil) -> Any {
		return self
	}

	private func computeGeometry() -> GEOMETRY {
		if let way = isWay() {
			if way.isArea() {
				return GEOMETRY.AREA
			} else {
				return GEOMETRY.LINE
			}
		} else if let node = isNode() {
			if node.wayCount > 0 {
				return GEOMETRY.VERTEX
			} else {
				return GEOMETRY.POINT
			}
		} else if let relation = isRelation() {
			if relation.isMultipolygon() {
				return GEOMETRY.AREA
			} else {
				return GEOMETRY.LINE
			}
		}
		fatalError()
	}

	var _geometry: GEOMETRY?
	func geometry() -> GEOMETRY {
		if _geometry == nil {
			_geometry = computeGeometry()
		}
		return _geometry!
	}

	func geometryName() -> String {
		return geometry().rawValue
	}
}
