//
//  AstroWebViewAdaptor.swift
//  Astro
//
//  A base class for WKWebViewAdaptor and UIWebViewAdaptor. This class cannot be used on it's own.
//
//  Created by Jason Voll on 2015-09-10.
//  Copyright (c) 2015 Mobify Research & Development Inc. All rights reserved.
//

import Foundation
import WebKit

func isMainFrame(forRequest request: URLRequest) -> Bool {
    return request.url == request.mainDocumentURL
}

func isFragmentChange(for currentUrl: URL?, navigatingToTargetUrl targetUrl: URL?) -> Bool {
    guard let currentUrl = currentUrl, let targetUrl = targetUrl else {
        // Can't be a fragment change if either URL is nil
        return false
    }

    return targetUrl.absoluteString.range(of: "#") != nil
        && currentUrl.isEqualIgnoringFragments(to: targetUrl)
}

class AstroWebViewAdaptor: NSObject, WebBridge, WebClient, UIScrollViewDelegate {

    enum NavigationType {
        case linkActivated
        case formSubmitted
        case backForward
        case reload
        case formResubmitted
        case other

        static let allValues = [linkActivated, formSubmitted, backForward, reload, formResubmitted, other]
    }

    weak var webBridgeDelegate: WebBridgeDelegate?
    weak var webClientDelegate: WebClientDelegate?
    var navigationTypes: [NavigationType: Int]! // A generic wrapper for WKNavigationType/UIWebViewNavigationType

    @objc var nextNavigationIsExplicit = true
    @objc var isRestoring = false
    @objc var hasScrollBars = true
    @objc var hasScrollBounce = true
    @objc var hasScrolling = true
    @objc var isLoading = false
    @objc var canGoBack: Bool {
        return false
    }
    @objc var lastLoadedUrl: URL?
    @objc var currentURL: URL? {
        return nil
    }

    private var scrollOffsetWhenScrollDisabled: CGFloat = 0.0
    private var explicitNavigationPatterns = [String: NSRegularExpression]()

    @objc var contentOffsetStack = [CGPoint]()

    override init() {
        super.init()
        setupNavigationTypeWrapper()
    }

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        if !scrollView.isScrollEnabled {
            scrollView.setContentOffset(CGPoint(x: 0, y: scrollOffsetWhenScrollDisabled), animated: false)
        }
    }

    @objc final func setupScrollView() {
        if let scrollView = getScrollViewFromWebView() {
            scrollView.showsHorizontalScrollIndicator = hasScrollBars
            scrollView.showsVerticalScrollIndicator = hasScrollBars
            scrollView.bounces = hasScrollBounce
            scrollView.isScrollEnabled = hasScrolling
        }
    }

    @objc final func setScrollBarsEnabled(_ enabled: Bool) {
        hasScrollBars = enabled
        if let scrollView = getScrollViewFromWebView() {
            scrollView.showsVerticalScrollIndicator = enabled
            scrollView.showsHorizontalScrollIndicator = enabled
        }
    }

    @objc final func setScrollBounces(_ bounces: Bool) {
        hasScrollBounce = bounces

        if let scrollView = getScrollViewFromWebView() {
            scrollView.bounces = bounces
        }
    }

    @objc func setScrolling(_ scrolls: Bool) {
        hasScrolling = scrolls

        if let scrollView = getScrollViewFromWebView() {
            scrollView.isScrollEnabled = scrolls

            if scrolls == true {
                scrollOffsetWhenScrollDisabled = 0
            } else {
                scrollOffsetWhenScrollDisabled = scrollView.contentOffset.y
            }
        }
    }

    @objc final func processBridgeMessageJson(_ jsonString: Any?, error: Error?) {
        guard let json = jsonString as? String else {
            AstroLog.logger(AstroLog.WebAdaptor).error("Failed to process bridge message. json: \(jsonString.debugDescription), error: \(error.debugDescription)")
            return
        }
        guard let queue = JSON.deserialize(json) else { return }
        guard let queueMessages = queue["messages"] as? [[String:AnyObject]] else { return }

        AstroLog.logger(AstroLog.Messaging).info("Received \(queueMessages.count) messages.")
        for queueMessage in queueMessages {
            AstroLog.logger(AstroLog.Messaging).debug("<--- Received message from astro.js: \(queueMessage)")
            guard let address = queueMessage["address"] as? MessageAddress else { continue }
            guard let requestJson = queueMessage["requestJson"] as? String else { continue }
            guard let jsonObject = JSON.deserialize(requestJson) else { continue }
            let bridgeMessage = BridgeMessage(address: address, jsonObject: jsonObject)
            webBridgeDelegate?.receiveMessageFromBridge(bridgeMessage)
        }
    }

    // MARK: - Methods that *must* be overridden by child class
    @objc func setupNavigationTypeWrapper() {
        preconditionFailure("setupNavigationTypeWrapper must be overridden by a child class")
    }

    @objc func getScrollViewFromWebView() -> UIScrollView? {
        preconditionFailure("getScrollViewFromWebView must be overridden by a child class")
    }

    @objc func registerWebView(_ webView: UIView) {
        preconditionFailure("registerWebView must be overridden by a child class")
    }

    @objc func unregisterWebView() {
        preconditionFailure("unregisterWebView must be overriden by a child class")
    }

    @objc func stopLoading() {
        preconditionFailure("stopLoading must be overridden by a child class")
    }

    @objc func goBack() {
        preconditionFailure("goBack must be overridden by a child class")
    }

    @objc func reload() {
        preconditionFailure("reload must be overridden by a child class")
    }

    @objc func getCookie(named cookieName: String, completionHandler: @escaping (_ cookieValue: String?, _ error: String?) -> Void) {
        preconditionFailure("getCookie must be overriden by a child class")
    }

    @objc func deleteCookie(named cookieName: String, completionHandler: @escaping  () -> Void) {
        preconditionFailure("deleteCookie must be overriden by a child class")
    }

    @objc func load(_ request: URLRequest) {
        preconditionFailure("loadUrlRequest must be overriden by a child class")
    }

    @objc func saveOffset() {
        preconditionFailure("saveOffset must be overriden by a child class")
    }

    @objc func didNavigateBack() {
        preconditionFailure("didNavigateBack must be overriden by a child class")
    }

    // MARK: - Stubs for WebBridge protocol

    // Note: these only need to be overridden for the worker (UIWebViewAdaptor)
    @objc func sendMessage(to address: MessageAddress, data: String) {
        preconditionFailure("sendMessage:to:data must be overridden by a child class")
    }

    @objc func addScript(atURL url: String) {
        preconditionFailure("addScriptToPage should only be called from the worker and not on WKWebViewAdaptor (doesn't support the worker). Url: \(url)")
    }

    // MARK: - Methods to support shared navigation delegate code

    @objc final func failedNavigationWithError(_ error: NSError) {
        if error.domain == NSURLErrorDomain && (
            error.code == NSURLErrorNotConnectedToInternet ||
            error.code == NSURLErrorCannotFindHost ||
            error.code == NSURLErrorCannotLoadFromNetwork ||
            error.code == NSURLErrorCannotConnectToHost) {
            if let url = error.userInfo[NSURLErrorFailingURLStringErrorKey] as? String {
                webClientDelegate?.notifyNoInternetConnection(url)
            }
        }

        // When the webview is explicitly cancelled we don't consider that an error!
        if error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled {
            AstroLog.logger(AstroLog.WebAdaptor).debug("[AstroWebViewAdaptor] " +
                "failedNavigationWithError: Navigation cancelled! " +
                "Continuing with no error.  Source: \(error)")

            // Let the delegate at least clean up... page state is undefined
            // at this point!
            webClientDelegate?.pageDidFinishLoading()
        } else {
            AstroLog.logger(AstroLog.WebAdaptor).error("[AstroWebViewAdaptor] failedNavigationWithError: Page load failed. Raising error. " +
                "Source: \(error)")

            // TODO: what do we do about no internet connection? On Android we don't
            // allow the request to start loading.
            webClientDelegate?.pageDidFailLoadWithError(error)
        }
        isRestoring = false
        isLoading = false
    }

    @objc func allowExplicitNavigation(_ urlPatterns: [String]) {
        for pattern in urlPatterns {
            do {
                let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive)
                explicitNavigationPatterns[pattern] = regex
            } catch {
                AstroLog.logger(AstroLog.WebAdaptor).error("Failed to validate allowExplicitNavigation regEx pattern: \(pattern)")
            }
        }
    }

    @objc func urlNavigationIsExplicit(_ url: URL) -> Bool {
        let urlString = url.absoluteString
        for (_, regex) in explicitNavigationPatterns {
            if (regex.firstMatch(in: urlString, options:[], range: NSMakeRange(0, (urlString as NSString).length)) != nil) {
                return true
            }
        }

        return false
    }

    @objc final func allowLoad(_ reason: String,
        url: URL,
        request: URLRequest,
        log: Bool = true,
        decisionHandler: (WKNavigationActionPolicy) -> Void) -> Bool {
            if log {
                AstroLog.logger(AstroLog.WebAdaptor).info("AstroWebViewAdaptor navigation delegate [ALLOW] \(reason) to \(url)")
            }

            if isMainFrame(forRequest: request)
                && !isFragmentChange(for: lastLoadedUrl, navigatingToTargetUrl: request.url)
                && !isLoading {

                webClientDelegate?.pageDidStartLoading(url)
                isLoading = true
            }

            decisionHandler(.allow)
            return true
    }

    @objc @discardableResult
    final func denyLoad(_ reason: String,
        url: URL?,
        decisionHandler: (WKNavigationActionPolicy) -> Void) -> Bool {
            AstroLog.logger(AstroLog.WebAdaptor).info("AstroWebViewAdaptor navigation delegate [DENY] \(reason) \(url?.absoluteString ?? "No URL")")
            decisionHandler(.cancel)
            return false
    }

    @objc final func denyLoadAndDelegateToWebClient(_ request: URLRequest, decisionHandler: (WKNavigationActionPolicy) -> Void) -> Bool {
            webClientDelegate?.handleNavigationRequest(request)
            return denyLoad("sending request to WebClientDelegate", url: request.url, decisionHandler: decisionHandler)
    }

    // Returns true if we made a decision
    @objc final func shouldAllowLoadForNavigationTypeHelper(_ navigationType: Int,
        currentNavigationIsExplicit: Bool,
        url: URL,
        request: URLRequest,
        decisionHandler: (WKNavigationActionPolicy) -> Void) -> Bool {
            if currentNavigationIsExplicit {
                return allowLoad("explicit navigation", url: url, request: request, log: true, decisionHandler: decisionHandler)
            }

            if urlNavigationIsExplicit(url) {
                return allowLoad("URL explicit navigation allowed", url: url, request: request, log: true, decisionHandler: decisionHandler)
            }

            if navigationType == navigationTypes[.backForward] {
                return allowLoad("back/forward navigation", url: url, request: request, log: true, decisionHandler: decisionHandler)
            }

            if navigationType == navigationTypes[.reload] {
                return allowLoad("reload navigation", url: url, request: request, log: true, decisionHandler: decisionHandler)
            }

            // Causes double-load if we raise a `navigate` event for fragment changes
            if isFragmentChange(for: lastLoadedUrl, navigatingToTargetUrl: url) {
                return allowLoad("fragment change", url: url, request: request, log: true, decisionHandler: decisionHandler)
            }

            // This gets called in two scenarios (that we know of so far)
            // 1. When you do a GET form submit
            // 2. When you do a POST form submit that has a redirect.
            //    `shouldAllowLoadForNavigationTypeHelper` gets called again
            //    on that redirect request.
            if request.httpMethod == "GET" &&
                (navigationType == navigationTypes[.formSubmitted] || navigationType == navigationTypes[.formResubmitted]) {
                    // If it's loading, we know we are following through after a direct, in which case,
                    // we want to allow the request (for now - in the future, we likely want to give users
                    // the ability to stack web views
                    if isLoading {
                        return allowLoad("A redirect GET after a POST", url: url, request: request, log: true, decisionHandler: decisionHandler)
                    } else {
                        // If it's not loading, we know it's the GET form submit case.
                        return denyLoadAndDelegateToWebClient(request, decisionHandler: decisionHandler)
                    }
            }

            if request.httpMethod == "POST" {
                return allowLoad("post request", url: request.url!, request: request, log: true, decisionHandler: decisionHandler)
            }

            // Prevent all loads initiated from the web view unless the web view is already loading (as
            // is the case when handling a redirect or loading an iframe). In all other cases the worker
            // will call webViewPlugin.navigate(url) to complete the navigation if desired.
            if !isMainFrame(forRequest: request) {
                return allowLoad("iframe navigation", url: url, request: request, log: false, decisionHandler: decisionHandler)
            }

            // WKNavigationDelegate.webView(_:decidePolicyForNavigationAction:decisionHandler)
            // (or UIWebViewDelegate.webView(_:shouldStartLoadWithRequest:navigationType))
            // is called for the main frame and iframes.
            let isRedirect = isMainFrame(forRequest: request) && isLoading && navigationType != navigationTypes[.linkActivated]
            if isRedirect {
                return allowLoad("redirect", url: url, request: request, log: true, decisionHandler: decisionHandler)
            }

            return denyLoadAndDelegateToWebClient(request, decisionHandler: decisionHandler)
    }

    // MARK: - Static helper functions
    @objc static func isMatchIgnoringCase(_ testString: String, pattern: String) -> Bool {
        if let _ = testString.range(of: pattern, options: ([.regularExpression, .caseInsensitive])) {
            return true
        }
        return false
    }

    @objc static func isITunesURL(_ url: URL) -> Bool {
        return isMatchIgnoringCase(url.absoluteString, pattern: "\\/\\/itunes\\.apple\\.com\\/")
    }

    // aka Protocol/URL-Scheme (doesn't start with http(s):// or file:// or about:)
    @objc static func isDeepLinkSchemeURL(_ url: URL) -> Bool {
        return !isMatchIgnoringCase(url.absoluteString, pattern: "^https?:\\/\\/.|^file:\\/\\/.|^about:.")
    }

    @objc static func getCookieValueFromString(_ cookieName: String, cookie: String) -> String? {
        let cookieArr = cookie.components(separatedBy: ";")
        for cookie in cookieArr {
            // Check it if starts with the cookie name
            if let _ = cookie.range(of: cookieName, options:.caseInsensitive) {
                return cookie.components(separatedBy: "=").last
            }
        }
        return nil
    }
}
