//
//  EditorMapLayer+Edit.swift
//  Go Map!!
//
//  Created by Bryce Cogswell on 6/3/21.
//  Copyright © 2021 Bryce Cogswell. All rights reserved.
//

import UIKit
import UniformTypeIdentifiers

extension EditorMapLayer {
	func undo() {
		mapData.undo()
		setNeedsLayout()
	}

	func redo() {
		mapData.redo()
		setNeedsLayout()
	}

	// MARK: Copy/Paste

	private var copyPasteTags: [String: String] {
		get {
			// This will generate a user prompt on iOS 16 or later:
			if let text = UIPasteboard.general.string,
			   let dict = OsmTags.tagsForString(text)
			{
				return dict
			}
			return [:]
		}
		set {
			let text = OsmTags.stringForTags(newValue)
			UIPasteboard.general.string = text
		}
	}

	func copyTags(_ object: OsmBaseObject) -> Bool {
		guard object.tags.count > 0 else { return false }
		copyPasteTags = object.tags
		return true
	}

	func canPasteTags() -> Bool {
		// We used to test the pasteboard to see if there's anything to paste,
		// but now it throws an access prompt to the user so just return true.
		return true
	}

	private func pasteTagsMerge(_ object: OsmBaseObject, tags: [String: String]) {
		// Merge tags
		let newTags = OsmTags.Merge(ourTags: object.tags, otherTags: tags, allowConflicts: true)!
		mapData.setTags(newTags, for: object)
		setNeedsLayout()
		owner.didUpdateObject()
	}

	private func pasteTagsReplace(_ object: OsmBaseObject, tags: [String: String]) {
		// Replace all tags
		mapData.setTags(tags, for: object)
		setNeedsLayout()
		owner.didUpdateObject()
	}

	/// Offers the option to either merge tags or replace them with the copied tags.
	func pasteTags(string: String?) {
		let pastedTags: [String: String]
		if let string {
			pastedTags = OsmTags.tagsForString(string) ?? [:]
		} else {
			// This will throw a prompt to the user
			pastedTags = copyPasteTags
		}
		guard pastedTags.count > 0 else {
			display.showAlert(NSLocalizedString("No tags to paste", comment: ""), message: nil)
			return
		}
		if selectedPrimary == nil {
			// pasting to brand new object, so we need to create it first
			setTagsForCurrentObject([:])
		}
		guard let selectedPrimary = selectedPrimary else { return }

		if selectedPrimary.tags.count > 0 {
			let question = String.localizedStringWithFormat(
				NSLocalizedString("Pasting %ld tag(s)", comment: ""),
				pastedTags.count)
			let alertPaste = UIAlertController(
				title: NSLocalizedString("Paste", comment: ""),
				message: question,
				preferredStyle: .alert)
			alertPaste.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""),
			                                   style: .cancel,
			                                   handler: nil))
			alertPaste.addAction(UIAlertAction(title: NSLocalizedString("Merge Tags", comment: ""),
			                                   style: .default, handler: { [self] _ in
			                                   	self.pasteTagsMerge(selectedPrimary, tags: pastedTags)
			                                   }))
			alertPaste.addAction(UIAlertAction(title: NSLocalizedString("Replace Tags", comment: ""),
			                                   style: .default,
			                                   handler: { [self] _ in
			                                   	self.pasteTagsReplace(selectedPrimary, tags: pastedTags)
			                                   }))
			presentAlert(alert: alertPaste, location: .none)
		} else {
			pasteTagsReplace(selectedPrimary, tags: pastedTags)
		}
	}

	/// Called by the tag editor when user finally commits changes.
	/// This method creates a node at the pushpin if there is nothing selected.
	func setTagsForCurrentObject(_ tags: [String: String]) {
		if let selectedPrimary = selectedPrimary {
			// update current object
			mapData.setTags(tags, for: selectedPrimary)
			owner.didUpdateObject()
		} else {
			// create new object
			guard let pushpin = owner.pushpinView() else {
				// shouldn't ever happen but there have been a few crashes so it does
				return
			}
			let point = pushpin.arrowPoint
			let node = createNode(atScreenPoint: point)
			mapData.setTags(tags, for: node)
			selectedNode = node
			// create new pushpin for new object
			owner.placePushpinForSelection(at: nil)
		}
		setNeedsLayout()
		dragState.confirmDrag = false
		owner.didUpdateObject()
	}

	// MARK: Selection

	func selectObjectAtPoint(_ point: CGPoint) {
		owner.unblinkObject() // used by Mac Catalyst, harmless otherwise

		if selectedWay != nil,
		   // check for selecting node inside previously selected way
		   let hit = osmHitTestNode(inSelectedWay: point, radius: Self.DefaultHitTestRadius)
		{
			selectedNode = hit

		} else {
			// hit test anything
			var segment = -1
			if let hit = osmHitTest(
				point,
				radius: Self.DefaultHitTestRadius,
				isDragConnect: false,
				ignoreList: [],
				segment: &segment)
			{
				if let hit = hit as? OsmNode {
					selectedNode = hit
					selectedWay = nil
					selectedRelation = nil
				} else if let hit = hit as? OsmWay {
					if let selectedRelation = selectedRelation,
					   hit.parentRelations.contains(selectedRelation)
					{
						// selecting way inside previously selected relation
						selectedNode = nil
						selectedWay = hit
					} else if hit.parentRelations.count > 0 {
						// select relation the way belongs to
						var relations = hit.parentRelations.filter { relation in
							relation.isMultipolygon() || relation.isBoundary() || relation.isWaterway()
						}
						if relations.count == 0, !hit.hasInterestingTags() {
							// if the way doesn't have tags then always promote to containing relation
							relations = hit.parentRelations
						}
						if let relation = relations.first {
							selectedNode = nil
							selectedWay = nil
							selectedRelation = relation
						} else {
							selectedNode = nil
							selectedWay = hit
							selectedRelation = nil
						}
					} else {
						selectedNode = nil
						selectedWay = hit
						selectedRelation = nil
					}
				} else if let hit = hit as? OsmRelation {
					selectedNode = nil
					selectedWay = nil
					selectedRelation = hit
				} else {
					fatalError()
				}
			} else {
				selectedNode = nil
				selectedWay = nil
				selectedRelation = nil
			}
		}

		owner.removePin()

		if let selectedPrimary = selectedPrimary {
			// adjust tap point to touch object
			var latLon = viewPort.mapTransform.latLon(forScreenPoint: point)
			latLon = selectedPrimary.latLonOnObject(forLatLon: latLon)
			let point = viewPort.mapTransform.screenPoint(forLatLon: latLon, birdsEye: true)

			owner.placePushpin(at: point, object: selectedPrimary)

			if selectedPrimary is OsmWay || selectedPrimary is OsmRelation {
				// if they later try to drag this way ask them if they really wanted to
				dragState.confirmDrag = selectedPrimary.modifyCount == 0
			}
		}
	}

	// MARK: Dragging pushPin

	func dragBegin(from: CGPoint) {
		mapData.beginUndoGrouping()
		dragState.startPoint = viewPort.mapTransform.latLon(forScreenPoint: from)
		dragState.didMove = false
	}

	func dragContinue(object: OsmBaseObject,
	                  toPoint: CGPoint,
	                  isRotateObjectMode: (rotateObjectOverlay: CAShapeLayer, rotateObjectCenter: LatLon)?)
	{
		// don't accumulate undo moves
		if dragState.didMove {
			mapData.endUndoGrouping()
			silentUndo = true
			let dict = mapData.undo()
			silentUndo = false
			mapData.beginUndoGrouping()
			if let dict = dict as? [String: String] {
				// maintain the original pin location:
				mapData.registerUndoCommentContext(dict)
			}
		}
		dragState.didMove = true

		let p1 = viewPort.mapTransform.screenPoint(forLatLon: dragState.startPoint, birdsEye: true)
		let totalMovement = toPoint.minus(p1)

		// move all dragged nodes
		if let rotate = isRotateObjectMode {
			// rotate object
			let delta = Double(-(totalMovement.x + totalMovement.y) / 100)
			let axis = viewPort.mapTransform.screenPoint(forLatLon: rotate.rotateObjectCenter,
			                                             birdsEye: true)
			let nodeSet = (object.isNode() != nil) ? selectedWay?.nodeSet() : object.nodeSet()
			for node in nodeSet ?? [] {
				let pt = viewPort.mapTransform.screenPoint(forLatLon: node.latLon, birdsEye: true)
				let diff = OSMPoint(x: Double(pt.x - axis.x), y: Double(pt.y - axis.y))
				let radius = hypot(diff.x, diff.y)
				var angle = atan2(diff.y, diff.x)
				angle += delta
				let new = OSMPoint(
					x: Double(axis.x) + radius * Double(cos(angle)),
					y: Double(axis.y) + Double(radius * sin(angle)))
				let dist = CGPoint(x: new.x - Double(pt.x), y: -(new.y - Double(pt.y)))
				adjust(node, byScreenDistance: dist)
			}
		} else {
			// drag object
			let delta = CGPoint(x: totalMovement.x,
			                    y: -totalMovement.y)
			for node in object.nodeSet() {
				adjust(node, byScreenDistance: delta)
			}
		}

		// do hit testing for connecting to other objects
		if selectedWay != nil, object.isNode() != nil {
			var segment = -1
			if let hit = hitTestDragConnection(for: object as! OsmNode, segment: &segment),
			   hit.isWay() != nil || hit.isNode() != nil
			{
				owner.blink(hit, segment: segment)
			} else {
				owner.unblinkObject()
			}
		}
	}

	func dragFinish(object: OsmBaseObject, isRotate: Bool) {
		mapData.endUndoGrouping()

		if let way = object.isWay() {
			// update things if we dragged a multipolygon inner member to become outer
			mapData.updateParentMultipolygonRelationRoles(for: way)
		} else if let selectedWay = selectedWay,
		          object.isNode() != nil
		{
			// you can also move an inner to an outer by dragging nodes one at a time
			mapData.updateParentMultipolygonRelationRoles(for: selectedWay)
		}

		if let selectedWay = selectedWay,
		   let dragNode = object.isNode()
		{
			// dragging a node that is part of a way
			let dragWay = selectedWay
			var segment = -1
			let hit = hitTestDragConnection(for: dragNode, segment: &segment)
			if var hit = hit as? OsmNode {
				// replace dragged node with hit node
				do {
					let merge = try mapData.canMerge(dragNode, into: hit)
					hit = merge()
					if dragWay.isArea() {
						selectedNode = nil
						let pt = viewPort.mapTransform.screenPoint(forLatLon: hit.latLon, birdsEye: true)
						owner.placePushpin(at: pt, object: dragWay)
					} else {
						selectedNode = hit
						owner.placePushpinForSelection(at: nil)
					}
				} catch {
					display.showAlert(error.localizedDescription, message: nil)
					return
				}
			} else if let hit = hit as? OsmWay {
				// add new node to hit way
				let pt = hit.latLonOnObject(forLatLon: dragNode.latLon)
				mapData.setLatLon(pt, forNode: dragNode)
				do {
					let add = try canAddNode(toWay: hit, atIndex: segment + 1)
					add(dragNode)
				} catch {
					display.showAlert(
						NSLocalizedString("Error connecting to way", comment: ""),
						message: error.localizedDescription)
				}
			}
			return
		}
		if isRotate {
			return
		}
		if let selectedWay = selectedWay,
		   selectedWay.tags.count == 0,
		   selectedWay.parentRelations.count == 0
		{
			return
		}
		if selectedWay != nil, selectedNode != nil {
			return
		}
		if dragState.confirmDrag {
			dragState.confirmDrag = false

			let alertMove = UIAlertController(
				title: NSLocalizedString("Confirm move", comment: ""),
				message: NSLocalizedString("Move selected object?", comment: ""),
				preferredStyle: .alert)
			alertMove.addAction(UIAlertAction(title: NSLocalizedString("Undo", comment: ""),
			                                  style: .cancel,
			                                  handler: { _ in
			                                  	// cancel move
			                                  	self.mapData.undo()
			                                  	self.mapData.removeMostRecentRedo()
			                                  	self.owner.unselectAll()
			                                  	self.setNeedsLayout()
			                                  }))
			alertMove.addAction(UIAlertAction(title: NSLocalizedString("Move", comment: ""),
			                                  style: .default,
			                                  handler: { _ in
			                                  	// okay
			                                  }))
			presentAlert(alert: alertMove, location: .none)
		}
	}

	func hitTestDragConnection(for node: OsmNode, segment: inout Int) -> OsmBaseObject? {
		guard let way = selectedWay,
		      let index = way.nodes.firstIndex(of: node),
		      let point = owner.pushpinView()?.arrowPoint
		else { return nil }

		var ignoreList: [OsmBaseObject] = []
		let parentWays = node.wayCount == 1 ? [way] : mapData.waysContaining(node)
		if way.nodes.count < 3 {
			ignoreList = parentWays + way.nodes
		} else if index == 0 {
			// if end-node then okay to connect to self-nodes except for adjacent
			let nodes = [way.nodes[0],
			             way.nodes[1],
			             way.nodes[2]]
			ignoreList = parentWays + nodes
		} else if index == way.nodes.count - 1 {
			// if end-node then okay to connect to self-nodes except for adjacent
			let nodes = [way.nodes[index],
			             way.nodes[index - 1],
			             way.nodes[index - 2]]
			ignoreList = parentWays + nodes
		} else {
			// if middle node then never connect to self
			if !parentWays.isEmpty {
				ignoreList = parentWays + way.nodes
			}
		}
		let hit = osmHitTest(point,
		                     radius: Self.DragConnectHitTestRadius,
		                     isDragConnect: true,
		                     ignoreList: ignoreList,
		                     segment: &segment)
		return hit
	}

	// MARK: Rotate

	func rotateBegin() {
		mapData.beginUndoGrouping()
		dragState.didMove = false
	}

	func rotateContinue(delta: CGFloat,
	                    rotate: (rotateObjectOverlay: CAShapeLayer, rotateObjectCenter: LatLon))
	{
		if dragState.didMove {
			// don't allows undo list to accumulate
			mapData.endUndoGrouping()
			silentUndo = true
			mapData.undo()
			silentUndo = false
			mapData.beginUndoGrouping()
		}

		dragState.didMove = true

		let axis = viewPort.mapTransform.screenPoint(forLatLon: rotate.rotateObjectCenter, birdsEye: true)
		let rotatedObject = selectedRelation ?? selectedWay
		if let nodeSet = rotatedObject?.nodeSet() {
			for node in nodeSet {
				let pt = viewPort.mapTransform.screenPoint(forLatLon: node.latLon, birdsEye: true)
				let diff = OSMPoint(x: Double(pt.x - axis.x), y: Double(pt.y - axis.y))
				let radius = hypot(diff.x, diff.y)
				var angle = atan2(diff.y, diff.x)

				angle += Double(delta)
				let new = OSMPoint(x: Double(axis.x) + radius * cos(angle), y: Double(axis.y) + radius * sin(angle))
				let dist = CGPoint(x: CGFloat(new.x) - pt.x, y: -(CGFloat(new.y) - pt.y))
				adjust(node, byScreenDistance: dist)
			}
		}
	}

	func rotateFinish() {
		mapData.endUndoGrouping()
	}

	// MARK: Editing

	func adjust(_ node: OsmNode, byScreenDistance delta: CGPoint) {
		var pt = viewPort.mapTransform.screenPoint(forLatLon: node.latLon, birdsEye: true)
		pt.x += delta.x
		pt.y -= delta.y
		let loc = viewPort.mapTransform.latLon(forScreenPoint: pt)
		mapData.setLatLon(loc, forNode: node)

		setNeedsLayout()
	}

	func duplicateObject(_ object: OsmBaseObject, withOffset offset: OSMPoint) -> OsmBaseObject? {
		let newObject = mapData.duplicate(object, withOffset: offset)!
		setNeedsLayout()
		return newObject
	}

	func createNode(atScreenPoint point: CGPoint) -> OsmNode {
		let loc = viewPort.mapTransform.latLon(forScreenPoint: point)
		let node = mapData.createNode(atLocation: loc)
		setNeedsLayout()
		return node
	}

	func createWay(with node: OsmNode) -> OsmWay {
		let way = mapData.createWay()
		let add = try! mapData.canAddNode(to: way, at: 0)
		add(node)
		setNeedsLayout()
		return way
	}

	func canAddNode(toWay way: OsmWay, atIndex index: Int) throws -> EditActionWithNode {
		let action = try mapData.canAddNode(to: way, at: index)
		return { [self] node in
			action(node)
			setNeedsLayout()
		}
	}

	func canDeleteSelectedObject() throws -> EditAction {
		if let selectedNode = selectedNode {
			// delete node from selected way
			let action: EditAction
			if let selectedWay = selectedWay {
				action = try mapData.canDelete(selectedNode, from: selectedWay)
			} else {
				action = try mapData.canDelete(selectedNode)
			}
			let way = selectedWay
			return { [self] in
				// deselect node after we've removed it from ways
				action()
				self.selectedNode = nil
				if way?.deleted ?? false {
					self.selectedWay = nil
				}
				setNeedsLayout()
			}
		} else if let selectedWay = selectedWay {
			// delete way
			if let action = try? mapData.canDelete(selectedWay) {
				return { [self] in
					action()
					self.selectedNode = nil
					self.selectedWay = nil
					setNeedsLayout()
				}
			}
		} else if let selectedRelation = selectedRelation {
			if let action = try? mapData.canDelete(selectedRelation) {
				return { [self] in
					action()
					self.selectedNode = nil
					self.selectedWay = nil
					self.selectedRelation = nil
					setNeedsLayout()
				}
			}
		}
		throw EditError.text("")
	}

	func deleteCurrentSelection() {
		guard let selectedPrimary = selectedPrimary,
		      let pushpinView = owner.pushpinView()
		else { return }

		let deleteHandler: ((_ action: UIAlertAction?) -> Void) = { [self] _ in
			do {
				let canDelete = try self.canDeleteSelectedObject()
				let deletedNode: Int? = selectedNode == nil ? nil : selectedWay?.nodes.firstIndex(of: selectedNode!)
				canDelete()
				var pos = pushpinView.arrowPoint
				owner.removePin()
				// update location of pushpin
				if let primary = self.selectedPrimary {
					if let way = primary as? OsmWay,
					   let nodeIndex = deletedNode,
					   let node = nodeIndex == 0 ? way.nodes.first : nodeIndex == way.nodes.count ? way.nodes.last : nil
					{
						selectedNode = node
						pos = viewPort.mapTransform.screenPoint(on: node, forScreenPoint: pos)
						owner.placePushpin(at: pos, object: node)
					} else {
						pos = viewPort.mapTransform.screenPoint(on: primary, forScreenPoint: pos)
						owner.placePushpin(at: pos, object: primary)
					}
				}
			} catch {
				display.showAlert(NSLocalizedString("Delete failed", comment: ""), message: error.localizedDescription)
			}
		}

		if selectedRelation?.isMultipolygon() ?? false, selectedPrimary.isWay() != nil {
			// delete way from relation
			let alertDelete = UIAlertController(
				title: NSLocalizedString("Delete", comment: ""),
				message: NSLocalizedString("Member of multipolygon relation", comment: ""),
				preferredStyle: .actionSheet)
			alertDelete.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""),
			                                    style: .cancel,
			                                    handler: { _ in
			                                    }))
			alertDelete.addAction(UIAlertAction(title: NSLocalizedString("Delete completely", comment: ""),
			                                    style: .default,
			                                    handler: deleteHandler))
			alertDelete.addAction(UIAlertAction(
				title: NSLocalizedString("Detach from relation", comment: ""),
				style: .default,
				handler: { [self] _ in
					do {
						let canRemove = try self.mapData.canRemove(selectedPrimary,
						                                           from: self.selectedRelation!)
						canRemove()
						self.selectedRelation = nil
						owner.didUpdateObject()
					} catch {
						display.showAlert(
							NSLocalizedString("Delete failed", comment: ""),
							message: error.localizedDescription)
					}
				}))
			presentAlert(alert: alertDelete, location: .editBar)

		} else {
			// regular delete
			let name = selectedPrimary.friendlyDescription()
			let question = String.localizedStringWithFormat(NSLocalizedString("Delete %@?",
			                                                                  comment: "Confirm deleting a node/way"),
			                                                name)
			let alertDelete = UIAlertController(
				title: NSLocalizedString("Delete", comment: ""),
				message: question,
				preferredStyle: .alert)
			alertDelete
				.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel, handler: nil))
			alertDelete
				.addAction(UIAlertAction(title: NSLocalizedString("Delete", comment: ""),
				                         style: .destructive, handler: deleteHandler))
			presentAlert(alert: alertDelete, location: .none)
		}
	}

	func editActionsAvailable() -> [EDIT_ACTION] {
		var actionList: [EDIT_ACTION] = (selectedPrimary?.tags.isEmpty ?? true) ? [] : [.COPYTAGS]
		if let selectedWay = selectedWay {
			if let selectedNode = selectedNode {
				// node in way
				let parentWays: [OsmWay] = mapData.waysContaining(selectedNode)
				let disconnect = parentWays.count > 1 ||
					selectedNode.hasInterestingTags() ||
					selectedWay.isSelfIntersection(selectedNode)
				let extract = selectedNode.hasInterestingTags()
				let split = selectedWay.isClosed() ||
					(selectedNode != selectedWay.nodes[0] && selectedNode != selectedWay.nodes.last)
				let join = parentWays.count > 1
				let restriction = owner.useTurnRestrictions()
					&& self.selectedWay?.tags["highway"] != nil
					&& parentWays.count > 1

				if disconnect {
					actionList.append(.DISCONNECT)
				}
				if extract {
					actionList.append(.EXTRACTNODE)
				}
				if split {
					actionList.append(.SPLIT)
				}
				if join {
					actionList.append(.JOIN)
				}
				actionList.append(.ROTATE)
				if restriction {
					actionList.append(.RESTRICT)
				}
			} else {
				if selectedWay.isClosed() {
					// polygon
					actionList += [
						.RECTANGULARIZE,
						.CIRCULARIZE,
						.ROTATE,
						.DUPLICATE,
						.REVERSE,
						.CREATE_RELATION
					]
				} else {
					// line
					actionList += [.STRAIGHTEN, .REVERSE, .DUPLICATE, .CREATE_RELATION]
				}
			}
		} else if selectedNode != nil {
			// node
			actionList += [.DUPLICATE]
		} else if let selectedRelation = selectedRelation {
			// relation
			if selectedRelation.isMultipolygon() {
				actionList += [.ROTATE, .DUPLICATE]
			} else {
				actionList += [.PASTETAGS]
			}
		} else {
			// nothing selected
			return []
		}
		return actionList
	}

	/// Performs the selected action on the currently selected editor objects
	func performEdit(_ action: EDIT_ACTION) {
		// if trying to edit a node in a way that has no tags assume user wants to edit the way instead
		switch action {
		case .RECTANGULARIZE, .STRAIGHTEN, .REVERSE, .DUPLICATE, .ROTATE, .CIRCULARIZE, .COPYTAGS, .PASTETAGS,
		     .EDITTAGS, .CREATE_RELATION:
			if let way = selectedWay,
			   let node = selectedNode,
			   node.tags.count == 0,
			   way.tags.count == 0,
			   !way.isMultipolygonMember()
			{
				// promote the selection to the way
				selectedNode = nil
				owner.didUpdateObject()
			}
		case .SPLIT, .JOIN, .DISCONNECT, .EXTRACTNODE, .RESTRICT, .ADDNOTE, .DELETE, .MORE:
			break
		}

		do {
			switch action {
			case .COPYTAGS:
				if let selectedPrimary = selectedPrimary {
					if !copyTags(selectedPrimary) {
						throw EditError.text(NSLocalizedString("The object does not contain any tags", comment: ""))
					}
				}
			case .PASTETAGS:
				// This will throw an alert to the user:
				pasteTags(string: nil)
			case .DUPLICATE:
				guard let primary = selectedPrimary,
				      let pushpinView = owner.pushpinView()
				else { return }
				let delta = CGPoint(x: viewPort.screenCenterPoint().x - pushpinView.arrowPoint.x,
				                    y: viewPort.screenCenterPoint().y - pushpinView.arrowPoint.y)
				var offset: OSMPoint
				if hypot(delta.x, delta.y) > 20 {
					// move to position of crosshairs
					let p1 = viewPort.mapTransform.latLon(forScreenPoint: pushpinView.arrowPoint)
					let p2 = viewPort.screenCenterLatLon()
					offset = OSMPoint(x: p2.lon - p1.lon, y: p2.lat - p1.lat)
				} else {
					offset = OSMPoint(x: 0.00005, y: -0.00005)
				}
				guard let newObject = duplicateObject(primary, withOffset: offset)
				else {
					throw EditError.text(NSLocalizedString("Could not duplicate object", comment: ""))
				}
				selectedNode = newObject.isNode()
				selectedWay = newObject.isWay()
				selectedRelation = newObject.isRelation()
				owner.placePushpinForSelection(at: nil)
			case .ROTATE:
				guard selectedWay != nil || (selectedRelation?.isMultipolygon() ?? false) else {
					throw EditError.text(NSLocalizedString("Only ways/multipolygons can be rotated", comment: ""))
				}
				owner.startObjectRotation()
			case .RECTANGULARIZE:
				guard let way = selectedWay else { return }
				if way.ident >= 0, !viewPort.boundingLatLonForScreen().containsRect(way.boundingBox) {
					throw EditError.text(NSLocalizedString("The selected way must be completely visible",
					                                       comment: "")) // avoid bugs where nodes are deleted from other objects
				}
				let rect = try mapData.canOrthogonalizeWay(way)
				rect()
			case .REVERSE:
				let reverse = try mapData.canReverse(selectedWay!)
				reverse()
			case .JOIN:
				guard let way = selectedWay,
				      let node = selectedNode
				else { return }
				let join = try mapData.canJoin(way, at: node)
				selectedWay = join()
			case .DISCONNECT:
				let disconnect = try mapData.canDisconnectWay(selectedWay!, at: selectedNode!)
				selectedNode = disconnect()
				owner.placePushpinForSelection(at: nil)
			case .EXTRACTNODE:
				let extract = try mapData.canExtractNode(selectedNode!)
				extract()
			case .SPLIT:
				let split = try mapData.canSplitWay(selectedWay!, at: selectedNode!)
				_ = split()
			case .STRAIGHTEN:
				if let selectedWay = selectedWay {
					let boundingBox = selectedWay.boundingBox
					if selectedWay.ident >= 0, !viewPort.boundingLatLonForScreen().containsRect(boundingBox) {
						throw EditError.text(NSLocalizedString("The selected way must be completely visible",
						                                       comment: "")) // avoid bugs where nodes are deleted from other objects
					} else {
						let straighten = try mapData.canStraightenWay(selectedWay)
						straighten()
					}
				}
			case .CIRCULARIZE:
				let circle = try mapData.canCircularizeWay(selectedWay!)
				circle()
			case .EDITTAGS:
				owner.presentTagEditor(nil)
			case .ADDNOTE:
				owner.addNote()
			case .DELETE:
				deleteCurrentSelection()
			case .MORE:
				owner.presentEditActionSheet(nil)
			case .RESTRICT:
				owner.presentTurnRestrictionEditor()
			case .CREATE_RELATION:
				guard let selectedPrimary = selectedPrimary else { return }
				let create: ((_ type: String) -> Void) = { [self] type in
					do {
						let relation = self.mapData.createRelation()
						var tags = selectedPrimary.tags
						tags["type"] = type
						self.mapData.setTags(tags, for: relation)
						// need the relation type to be set before adding
						let add = try self.mapData.canAdd(selectedPrimary,
						                                  to: relation,
						                                  withRole: "outer")
						add()
						self.mapData.setTags([:], for: selectedPrimary)
						self.selectedNode = nil
						self.selectedWay = nil
						self.selectedRelation = relation
						self.setNeedsLayout()
						owner.didUpdateObject()
						display.showAlert(
							NSLocalizedString("Adding members:", comment: ""),
							message: NSLocalizedString(
								"To add another member to the relation 'long press' on the way to be added",
								comment: ""))
					} catch {
						display.showAlert(error.localizedDescription, message: nil)
					}
				}
				let actionSheet = UIAlertController(
					title: NSLocalizedString("Create Relation Type", comment: ""),
					message: nil,
					preferredStyle: .actionSheet)
				actionSheet.addAction(UIAlertAction(title: NSLocalizedString("Multipolygon", comment: ""),
				                                    style: .default,
				                                    handler: { _ in
				                                    	create("multipolygon")
				                                    }))
				actionSheet.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""),
				                                    style: .cancel,
				                                    handler: nil))
				let rc = CGRect(origin: owner.pushpinView()?.arrowPoint ?? .zero, size: .zero)
				presentAlert(alert: actionSheet, location: .rect(rc))
				return
			}
		} catch {
			display.showAlert(error.localizedDescription, message: nil)
		}
		setNeedsLayout()
		owner.didUpdateObject()
	}

	// MARK: Create node/ways

	func longPressAtPoint(_ point: CGPoint) {
		let objects = osmHitTestMultiple(point, radius: Self.DefaultHitTestRadius)
		if objects.count == 0 {
			return
		}

		// special case for adding members to relations:
		if let relation = selectedPrimary as? OsmRelation,
		   relation.isMultipolygon()
		{
			let ways = objects.compactMap({ $0 as? OsmWay })
			if ways.count == 1,
			   let way = ways.first,
			   !relation.members.contains(where: { $0.obj == way })
			{
				let confirm = UIAlertController(
					title: NSLocalizedString("Add way to multipolygon?", comment: ""),
					message: nil,
					preferredStyle: .alert)
				let addMmember: ((String?) -> Void) = { [self] role in
					do {
						let add = try self.mapData.canAdd(way,
						                                  to: self.selectedRelation!,
						                                  withRole: role)
						add()
						display.flashMessage(title: nil,
						                     message: NSLocalizedString("added to multipolygon relation", comment: ""))
						self.setNeedsLayout()
					} catch {
						display.showAlert(NSLocalizedString("Error", comment: ""),
						                  message: error.localizedDescription)
					}
				}
				confirm.addAction(UIAlertAction(
					title: NSLocalizedString("Add outer member", comment: "Add to relation"),
					style: .default,
					handler: { _ in
						addMmember("outer")
					}))
				confirm.addAction(UIAlertAction(
					title: NSLocalizedString("Add inner member", comment: "Add to relation"),
					style: .default,
					handler: { _ in
						addMmember("inner")
					}))
				confirm
					.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""),
					                         style: .cancel,
					                         handler: nil))
				presentAlert(alert: confirm, location: .none)
				return
			}
		}

		let multiSelectSheet = UIAlertController(
			title: NSLocalizedString("Select Object", comment: ""),
			message: nil,
			preferredStyle: .actionSheet)
		for object in objects {
			var title = object.friendlyDescription()
			if !title.hasPrefix("(") {
				// indicate what type of object it is
				if object.isNode() != nil {
					title = title + NSLocalizedString(" (node)", comment: "")
				} else if object.isWay() != nil {
					title = title + NSLocalizedString(" (way)", comment: "")
				} else if object.isRelation() != nil {
					let type = object.tags["type"] ?? NSLocalizedString("relation", comment: "")
					title = title + " (\(type))"
				}
			}
			multiSelectSheet.addAction(UIAlertAction(title: title, style: .default, handler: { [self] _ in
				// processing for selecting one of multipe objects
				self.owner.unselectAll()
				if let node = object.isNode() {
					// select the way containing the node, then select the node in the way
					self.selectedWay = objects
						.first(where: { ($0 as? OsmWay)?.nodes.contains(node) ?? false }) as? OsmWay
					self.selectedNode = node
				} else if object.isWay() != nil {
					self.selectedWay = object.isWay()
				} else if object.isRelation() != nil {
					self.selectedRelation = object.isRelation()
				}
				let pos = viewPort.mapTransform.screenPoint(on: object, forScreenPoint: point)
				owner.placePushpin(at: pos, object: object)
			}))
		}
		multiSelectSheet.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""),
		                                         style: .cancel,
		                                         handler: nil))
		let rc = CGRect(x: point.x, y: point.y, width: 0.0, height: 0.0)
		presentAlert(alert: multiSelectSheet, location: .rect(rc))
	}

	func extendSelectedWay(to newPoint: CGPoint, from pinPoint: CGPoint) -> Result<CGPoint, EditError> {
		if let way = selectedWay,
		   selectedNode == nil
		{
			// insert a new node into way at pushPin location
			let pt = viewPort.mapTransform.latLon(forScreenPoint: pinPoint)
			guard
				way.nodes.allSatisfy({ $0.latLon != pt })
			else {
				// user attempted to add a node at exactly the same location as an existing node,
				// probably by accidently tapping + too many times.
				return .failure(EditError.text(""))
			}
			let segment = way.segmentClosestToPoint(pt)
			do {
				let add = try canAddNode(toWay: way, atIndex: segment + 1)
				let newNode = createNode(atScreenPoint: pinPoint)
				add(newNode)
				selectedNode = newNode
				return .success(newPoint)
			} catch {
				return .failure(error as! EditError)
			}
		}

		let prevNode: OsmNode
		let way: OsmWay
		if let selectedNode = selectedNode,
		   let selectedWay = selectedWay,
		   selectedWay.nodes.count > 0,
		   selectedWay.isClosed() || (selectedNode != selectedWay.nodes.first && selectedNode != selectedWay.nodes.last)
		{
			// both a node and way are selected but selected node is not an endpoint (or way is closed),
			// so we will create a new way "T" from that node
			prevNode = selectedNode // use the existing node selected by user
			way = createWay(with: prevNode) // create a new way extending off of it
		} else {
			// we're either extending a way from it's end, or creating a new way with
			// the pushpin as one end of it and crosshairs (or mouse click) as the other
			if let selectedNode {
				// we are extending an existing way
				prevNode = selectedNode
			} else {
				prevNode = createNode(atScreenPoint: pinPoint)
			}
			way = selectedWay ?? createWay(with: prevNode)
		}

		let prevIndex = way.nodes.firstIndex(of: prevNode)!
		var nextIndex = prevIndex
		if nextIndex == way.nodes.count - 1 {
			nextIndex += 1
		}
		// add new node at point
		var newPoint = newPoint
		let prevPrevNode = way.nodes.count >= 2 ? way.nodes[way.nodes.count - 2] : nil
		let prevPrevPoint = prevPrevNode != nil ? viewPort.mapTransform.screenPoint(
			forLatLon: prevPrevNode!.latLon,
			birdsEye: true) : CGPoint.zero

		if hypot(pinPoint.x - newPoint.x, pinPoint.y - newPoint.y) > 10.0,
		   prevPrevNode == nil || hypot(prevPrevPoint.x - newPoint.x, prevPrevPoint.y - newPoint.y) > 10.0
		{
			// it's far enough from previous point to use
		} else {
			// compute a good place for next point
			if way.nodes.count < 2 {
				// create 2nd point in the direction of the center of the screen
				let vert = abs(Float(pinPoint.x - newPoint.x)) < abs(Float(pinPoint.y - newPoint.y))
				if vert {
					newPoint.x = pinPoint.x
					newPoint.y = abs(newPoint.y - pinPoint.y) < 30 ? pinPoint.y + 60 : 2 * newPoint.y - pinPoint.y
				} else {
					newPoint.x = abs(newPoint.x - pinPoint.x) < 30 ? pinPoint.x + 60 : 2 * newPoint.x - pinPoint.x
					newPoint.y = pinPoint.y
				}
			} else if way.nodes.count == 2 {
				// create 3rd point 90 degrees from first 2
				let n1 = way.nodes[1 - prevIndex]
				let p1 = viewPort.mapTransform.screenPoint(forLatLon: n1.latLon, birdsEye: true)
				var delta = CGPoint(x: p1.x - pinPoint.x, y: p1.y - pinPoint.y)
				let len = hypot(delta.x, delta.y)
				if len > 100 {
					delta.x *= CGFloat(100 / len)
					delta.y *= CGFloat(100 / len)
				}
				let np1 = CGPoint(x: pinPoint.x - delta.y, y: pinPoint.y + delta.x)
				let np2 = CGPoint(x: pinPoint.x + delta.y, y: pinPoint.y - delta.x)
				if OSMPoint(np1).distanceToPoint(OSMPoint(newPoint)) < OSMPoint(np2)
					.distanceToPoint(OSMPoint(newPoint))
				{
					newPoint = np1
				} else {
					newPoint = np2
				}
			} else {
				// create 4th point and beyond following angle of previous 3
				let n1 = prevIndex == 0 ? way.nodes[1] : way.nodes[prevIndex - 1]
				let n2 = prevIndex == 0 ? way.nodes[2] : way.nodes[prevIndex - 2]
				let p1 = viewPort.mapTransform.screenPoint(forLatLon: n1.latLon, birdsEye: true)
				let p2 = viewPort.mapTransform.screenPoint(forLatLon: n2.latLon, birdsEye: true)
				let d1 = OSMPoint(x: Double(pinPoint.x - p1.x), y: Double(pinPoint.y - p1.y))
				let d2 = OSMPoint(x: Double(p1.x - p2.x), y: Double(p1.y - p2.y))
				var a1 = atan2(d1.y, d1.x)
				let a2 = atan2(d2.y, d2.x)
				var dist = hypot(d1.x, d1.y)
				// if previous angle was 90 degrees then match length of first leg to make a rectangle
				if way.nodes.count == 3 || way.nodes.count == 4, abs(fmod(abs(Float(a1 - a2)), .pi) - .pi / 2) < 0.1 {
					dist = hypot(d2.x, d2.y)
				} else if dist > 100 {
					dist = 100
				}
				a1 += a1 - a2
				newPoint = CGPoint(x: Double(pinPoint.x) + dist * cos(a1),
				                   y: Double(Double(pinPoint.y) + dist * sin(a1)))
			}
			// make sure selected point is on-screen
			var rc = bounds.insetBy(dx: 20.0, dy: 20.0)
			rc.size.height -= 190
			newPoint.x = CGFloat(max(newPoint.x, rc.origin.x))
			newPoint.x = CGFloat(min(newPoint.x, rc.origin.x + rc.size.width))
			newPoint.y = CGFloat(max(newPoint.y, rc.origin.y))
			newPoint.y = CGFloat(min(newPoint.y, rc.origin.y + rc.size.height))
		}

		if way.nodes.count >= 2 {
			let start = prevIndex == 0 ? way.nodes.last! : way.nodes[0]
			let s = viewPort.mapTransform.screenPoint(forLatLon: start.latLon, birdsEye: true)
			let d = hypot(s.x - newPoint.x, s.y - newPoint.y)
			if d < 3.0 {
				// join first to last
				if let action = try? canAddNode(toWay: way, atIndex: nextIndex) {
					action(start)
					selectedWay = way
					selectedNode = nil
					return .success(s)
				} else {
					// fall through to non-joining case
				}
			}
		}

		let addNodeToWay: EditActionWithNode
		do {
			addNodeToWay = try canAddNode(toWay: way, atIndex: nextIndex)
		} catch {
			return .failure(error as! EditError)
		}
		let node2 = createNode(atScreenPoint: newPoint)
		selectedWay = way // set selection before perfoming add-node action so selection is recorded in undo stack
		selectedNode = node2
		addNodeToWay(node2)
		return .success(newPoint)
	}

	func addNode(at dropPoint: CGPoint) {
		if isHidden {
			display.flashMessage(title: nil,
			                     message: NSLocalizedString("Editing layer not visible", comment: ""))
			return
		}

		// we are either creating a brand new node unconnected to an existing way,
		// converting a dropped pin to a way by adding a new node
		// or adding a node to a selected way/node combination
		guard
			let pushpinView = owner.pushpinView(),
			selectedNode == nil || selectedWay != nil
		else {
			// drop a new pin
			owner.unselectAll()
			owner.placePushpin(at: dropPoint, object: nil)
			return
		}

		func offscreenWarning() {
			self.display.flashMessage(title: nil,
			                          message: NSLocalizedString("Selected object is off screen", comment: ""))
		}

		let prevPointIsOffScreen = !bounds.contains(pushpinView.arrowPoint)

		if let selectedWay = selectedWay,
		   let selectedNode = selectedNode
		{
			// already editing a way so try to extend it
			if selectedWay
				.isClosed() || !(selectedNode == selectedWay.nodes.first || selectedNode == selectedWay.nodes.last)
			{
				if prevPointIsOffScreen {
					offscreenWarning()
					return
				}
			}
		} else if selectedPrimary == nil {
			// just dropped a pin, so convert it into a way
		} else if selectedWay != nil, selectedNode == nil {
			// add a new node to a way at location of pushpin
			if prevPointIsOffScreen {
				offscreenWarning()
				return
			}
		} else {
			// not supported
			return
		}

		switch extendSelectedWay(to: dropPoint, from: pushpinView.arrowPoint) {
		case let .success(pt):
			owner.placePushpinForSelection(at: pt)
		case let .failure(error):
			if case let .text(text) = error {
				display.showAlert(NSLocalizedString("Can't extend way", comment: ""), message: text)
			}
		}
	}
}

// We can set EditorMapLayer to be the target of a UIPasteControl.
// This conformance allows the control to properly present it's state
// and to activate a paste operation.
@available(iOS 14.0, *)
extension EditorMapLayer: UIPasteConfigurationSupporting {
	var pasteConfiguration: UIPasteConfiguration? {
		get {
			UIPasteConfiguration(acceptableTypeIdentifiers: [UTType.text.identifier])
		}
		set {}
	}

	func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
		let can = itemProviders.contains { $0.hasItemConformingToTypeIdentifier(UTType.text.identifier) }
		return can
	}

	func paste(itemProviders: [NSItemProvider]) {
		for item in itemProviders {
			if item.canLoadObject(ofClass: String.self) {
				_ = item.loadObject(ofClass: String.self) { [weak self] text, _ in
					if let text {
						DispatchQueue.main.async {
							self?.pasteTags(string: text)
						}
					}
				}
				break
			}
		}
	}
}
