//
//  ContentTextView.swift
//  Tusker
//
//  Created by Shadowfacts on 1/18/20.
//  Copyright © 2020 Shadowfacts. All rights reserved.
//

import UIKit
import SwiftSoup
import Pachyderm
import SafariServices
import WebURL
import WebURLFoundationExtras
import Combine

private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
private let dataDetectorsScheme = "x-apple-data-detectors"

class ContentTextView: LinkTextView, BaseEmojiLabel {
    
    weak var navigationDelegate: TuskerNavigationDelegate?
    weak var overrideMastodonController: MastodonController?
    var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }

    private var htmlConverter = HTMLConverter()
    var defaultFont: UIFont {
        _read { yield htmlConverter.font }
        _modify { yield &htmlConverter.font }
    }
    var monospaceFont: UIFont {
        _read { yield htmlConverter.monospaceFont }
        _modify { yield &htmlConverter.monospaceFont }
    }
    var defaultColor: UIColor {
        _read { yield htmlConverter.color }
        _modify { yield &htmlConverter.color }
    }
    var paragraphStyle: NSParagraphStyle {
        _read { yield htmlConverter.paragraphStyle }
        _modify { yield &htmlConverter.paragraphStyle }
    }
    
    private(set) var hasEmojis = false
    
    var emojiIdentifier: AnyHashable?
    var emojiRequests: [ImageCache.Request] = []
    var emojiFont: UIFont { defaultFont }
    var emojiTextColor: UIColor { defaultColor }
    
    // The link range currently being previewed
    private var currentPreviewedLinkRange: NSRange?
    // The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing.
    private weak var currentTargetedPreview: UITargetedPreview?
    
    private var underlineTextLinksCancellable: AnyCancellable?
    
    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        commonInit()
    }
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    
    private func commonInit() {
        delegate = self
        
        // Disable layer masking, otherwise the context menu opening animation
        // may be clipped if it's at an edge of the text view
        layer.masksToBounds = false
        addInteraction(UIContextMenuInteraction(delegate: self))
        
        textDragInteraction?.isEnabled = false
        
        textContainerInset = .zero
        textContainer.lineFragmentPadding = 0
        
        linkTextAttributes = [
            .foregroundColor: UIColor.tintColor
        ]
        updateLinkUnderlineStyle()
        
        // the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer
        let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
        addGestureRecognizer(recognizer)
        
        NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil)
        underlineTextLinksCancellable =
            Preferences.shared.$underlineTextLinks
                .sink { [unowned self] in
                    self.updateLinkUnderlineStyle(preference: $0)
                }
    }
    
    @objc private func _updateLinkUnderlineStyle() {
        updateLinkUnderlineStyle()
    }
    
    private func updateLinkUnderlineStyle(preference: Bool = Preferences.shared.underlineTextLinks) {
        if UIAccessibility.buttonShapesEnabled || preference {
            linkTextAttributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
        } else {
            linkTextAttributes.removeValue(forKey: .underlineStyle)
        }
    }
    
    // MARK: - Emojis
    func setEmojis<ID: Hashable>(_ emojis: [Emoji], identifier: ID?) {
        replaceEmojis(in: attributedText!, emojis: emojis, identifier: identifier) { attributedString, didReplaceEmojis in
            guard didReplaceEmojis else {
                return
            }
            self.attributedText = attributedString
            self.setNeedsLayout()
            self.setNeedsDisplay()
        }
    }

    // MARK: - HTML Parsing
    func setTextFromHtml(_ html: String) {
        self.attributedText = htmlConverter.convert(html)
    }
    
    // MARK: - Interaction
    
    // only accept touches that are over a link
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
        if getLinkAtPoint(point) != nil || isSelectable {
            return super.hitTest(point, with: event)
        } else {
            return nil
        }
    }
    
    @objc func textTapped(_ recognizer: UITapGestureRecognizer) {
        // if there currently is a selection, deselct it on single-tap
        if selectedRange.length > 0 {
            // location doesn't matter since we are non-editable and the cursor isn't visible
            selectedRange = NSRange(location: 0, length: 0)
        }
        
        let location = recognizer.location(in: self)
        if let (link, range) = getLinkAtPoint(location),
           link.scheme != dataDetectorsScheme {
            let text = (self.text as NSString).substring(with: range)
            handleLinkTapped(url: link, text: text)
        }
    }

    func getLinkAtPoint(_ point: CGPoint) -> (URL, NSRange)? {
        let locationInTextContainer = CGPoint(x: point.x - textContainerInset.left, y: point.y - textContainerInset.top)
        if #available(iOS 16.0, *),
           let textLayoutManager {
            guard let fragment = textLayoutManager.textLayoutFragment(for: locationInTextContainer) else {
                return nil
            }
            let pointInLayoutFragment = CGPoint(x: locationInTextContainer.x - fragment.layoutFragmentFrame.minX, y: locationInTextContainer.y - fragment.layoutFragmentFrame.minY)
            guard let lineFragment = fragment.textLineFragments.first(where: { lineFragment in
                   lineFragment.typographicBounds.contains(pointInLayoutFragment)
               }) else {
                return nil
            }
            let pointInLineFragment = CGPoint(x: pointInLayoutFragment.x - lineFragment.typographicBounds.minX, y: pointInLayoutFragment.y - lineFragment.typographicBounds.minY)
            let charIndex = lineFragment.characterIndex(for: pointInLineFragment)
            
            var range = NSRange()
            // sometimes characterIndex(for:) returns NSNotFound even for points that are in the line fragment's typographic bounds (see #183), so we check just in case
            guard charIndex != NSNotFound,
                let link = lineFragment.attributedString.attribute(.link, at: charIndex, longestEffectiveRange: &range, in: lineFragment.attributedString.fullRange) as? URL else {
                return nil
            }
            // lineFragment.attributedString is the NSTextLayoutFragment's string, and so range is in its index space
            // but we need to return a range in our whole attributedString's space, so convert it
            let textLayoutFragmentStart = textLayoutManager.offset(from: textLayoutManager.documentRange.location, to: fragment.rangeInElement.location)
            let rangeInSelf = NSRange(location: range.location + textLayoutFragmentStart, length: range.length)
            return (link, rangeInSelf)
        } else {
            var partialFraction: CGFloat = 0
            let characterIndex = layoutManager.characterIndex(for: locationInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: &partialFraction)
            guard characterIndex < textStorage.length && partialFraction < 1 else {
                return nil
            }
            
            var range = NSRange()
            guard let link = textStorage.attribute(.link, at: characterIndex, longestEffectiveRange: &range, in: textStorage.fullRange) as? URL else {
                return nil
            }
            return (link, range)
        }
    }
    
    func handleLinkTapped(url: URL, text: String) {
        if let mention = getMention(for: url, text: text) {
            navigationDelegate?.selected(mention: mention)
        } else if let tag = getHashtag(for: url, text: text) {
            navigationDelegate?.selected(tag: tag)
        } else {
            navigationDelegate?.selected(url: url)
        }
    }
    
    // MARK: - Navigation
    
    func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController? {
        let text = (self.text as NSString).substring(with: range)
        
        if let mention = getMention(for: url, text: text),
           let mastodonController {
            return ProfileViewController(accountID: mention.id, mastodonController: mastodonController)
        } else if let tag = getHashtag(for: url, text: text),
                  let mastodonController {
            return HashtagTimelineViewController(for: tag, mastodonController: mastodonController)
        } else if url.scheme == "https" || url.scheme == "http" {
            let vc = SFSafariViewController(url: url)
            vc.preferredControlTintColor = Preferences.shared.accentColor.color
            return vc
        } else {
            return nil
        }
    }
    
    open func getMention(for url: URL, text: String) -> Mention? {
        return nil
    }
    
    open func getHashtag(for url: URL, text: String) -> Hashtag? {
        if text.starts(with: "#") {
            let tag = String(text.dropFirst())
            return Hashtag(name: tag, url: url)
        } else {
            return nil
        }
    }

}

extension ContentTextView: UITextViewDelegate {
    func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
        // the builtin data detectors use the x-apple-data-detectors scheme, and we allow the text view to handle those itself
        if URL.scheme == dataDetectorsScheme {
            return true
        } else {
            // otherwise, regular taps are handled by the gesture recognizer, but the accessibility interaction to select links with the rotor goes through here
            // and this seems to be the only way of overriding what it does
            if interaction == .invokeDefaultAction {
                handleLinkTapped(url: URL, text: (text as NSString).substring(with: characterRange))
            }
            return false
        }
    }
}

extension ContentTextView: MenuActionProvider {
    var toastableViewController: ToastableViewController? {
        // todo: pass this down through the text view
        nil
    }
}

extension ContentTextView: UIContextMenuInteractionDelegate {
    func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
        if let (link, range) = getLinkAtPoint(location) {
            // Store the previewed link range for use in the previewForHighlighting method
            currentPreviewedLinkRange = range
            
            let preview: UIContextMenuContentPreviewProvider = {
                self.getViewController(forLink: link, inRange: range)
            }
            let actions: UIContextMenuActionProvider = { (_) in
                let text = (self.text as NSString).substring(with: range)
                let actions: [UIMenuElement]
                if let mention = self.getMention(for: link, text: text) {
                    actions = self.actionsForProfile(accountID: mention.id, source: .view(self))
                } else if let tag = self.getHashtag(for: link, text: text) {
                    actions = self.actionsForHashtag(tag, source: .view(self))
                } else {
                    actions = self.actionsForURL(link, source: .view(self))
                }
                return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
            }
            
            return UIContextMenuConfiguration(identifier: nil, previewProvider: preview, actionProvider: actions)
        } else {
            currentPreviewedLinkRange = nil
            return nil
        }
    }
    
    func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
        // If there isn't a link range, use the default system-generated preview.
        guard let range = currentPreviewedLinkRange else {
            return nil
        }
        currentPreviewedLinkRange = nil
        
        // Determine the line rects that the link takes up in the coordinate space of this view.
        var rects = [CGRect]()
        if #available(iOS 16.0, *),
           let textLayoutManager,
           let contentManager = textLayoutManager.textContentManager {
            // convert from NSRange to NSTextRange
            // i have no idea under what circumstances any of these calls could fail
            guard let startLoc = contentManager.location(contentManager.documentRange.location, offsetBy: range.location),
                  let endLoc = contentManager.location(startLoc, offsetBy: range.length),
                  let textRange = NSTextRange(location: startLoc, end: endLoc) else {
                return nil
            }
            // .standard because i have no idea what the difference is
            textLayoutManager.enumerateTextSegments(in: textRange, type: .standard, options: .rangeNotRequired) { range, rect, float, textContainer in
                rects.append(rect)
                return true
            }
        } else {
            layoutManager.enumerateEnclosingRects(forGlyphRange: range, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: textContainer) { (rect, stop) in
                rects.append(rect)
            }
        }
        
        // Try to create a snapshot view of this view to disply as the preview.
        // If a snapshot view cannot be created, we bail and use the system-provided preview.
        guard let snapshot = self.snapshotView(afterScreenUpdates: false) else {
            return nil
        }
        
        // Mask the snapshot layer to only show the text of the link, and nothing else.
        // By default, the system-applied mask is too wide and other content may seep in.
        let path = UIBezierPath(wrappingAround: rects)
        let maskLayer = CAShapeLayer()
        maskLayer.path = path.cgPath
        snapshot.layer.mask = maskLayer
        
        // The preview parameters describe how the preview view is shown inside the preview.
        let parameters = UIPreviewParameters(textLineRects: rects as [NSValue])
        
        // Calculate the smallest rect enclosing all of the text line rects, in the coordinate space of this view.
        var minX: CGFloat = .greatestFiniteMagnitude, maxX: CGFloat = -.greatestFiniteMagnitude, minY: CGFloat = .greatestFiniteMagnitude, maxY: CGFloat = -.greatestFiniteMagnitude
        for rect in rects {
            minX = min(rect.minX, minX)
            maxX = max(rect.maxX, maxX)
            minY = min(rect.minY, minY)
            maxY = max(rect.maxY, maxY)
        }
        // The center point of the the minimum enclosing rect in our coordinate space is the point where the
        // center of the preview should be, since that's also in this view's coordinate space.
        let rectsCenter = CGPoint(x: (minX + maxX) / 2, y: (minY + maxY) / 2)
        
        // The preview target describes how the preview is positioned.
        let target = UIPreviewTarget(container: self, center: rectsCenter)
        
        // Create a dummy containerview for the snapshot view, since using a view with a CALayer mask and UIPreviewParameters(textLineRects:)
        // causes the mask to be ignored. See FB7832297
        let snapshotContainer = UIView(frame: snapshot.bounds)
        snapshotContainer.backgroundColor = .appBackground
        snapshotContainer.addSubview(snapshot)
        
        let preview = UITargetedPreview(view: snapshotContainer, parameters: parameters, target: target)
        currentTargetedPreview = preview
        return preview
    }
    
    func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
        // Use the same preview for dismissing as was used for highlighting, so that the link animates back to the original position.
        return currentTargetedPreview
    }
    
    func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
        if let viewController = animator.previewViewController {
            animator.preferredCommitStyle = .pop
            animator.addCompletion {
                self.navigationDelegate?.show(viewController)
            }
        }
    }
}
