import UIKit
import WebKit

private let scriptHandlerName = "dynamicEmbeddedWebView"
private let navigationDecisionTimeout: TimeInterval = 5.0

// Cleartext http is only permitted in debug builds (e.g. Metro at
// http://localhost:4202). Release builds reject http top-frame and sub-frame
// navigations regardless of what the JS allowlist says — so a JS compromise
// cannot trick production into loading http content.
#if DEBUG
private let allowsHttpScheme = true
#else
private let allowsHttpScheme = false
#endif

public typealias EmbeddedWebViewEventEmitter = (String, [String: Any]) -> Void

public final class EmbeddedWebViewController: NSObject {
  public static let shared = EmbeddedWebViewController()

  public var eventEmitter: EmbeddedWebViewEventEmitter?

  private var window: UIWindow?
  private var webView: WKWebView?
  private var debuggingEnabled = false
  private var pendingNavigationDecisions: [String: (WKNavigationActionPolicy) -> Void] = [:]
  private var navigationTimers: [String: Timer] = [:]
  private var emitterToken: UUID?
  // Origin pre-approved by `setUrl`. When `decidePolicyFor` sees a top-frame
  // navigation whose origin matches, it allows it immediately — no JS
  // round-trip required. Cleared after first use so subsequent navigations
  // still go through the JS allowlist.
  private var preApprovedOrigin: String?

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

  // Module-side hook: assign the emitter under a token. The token lets
  // OnDestroy avoid clobbering an emitter that a newer OnCreate has already
  // installed — relevant during dev hot reloads where module instances briefly
  // overlap.
  @discardableResult
  public func setEmitter(_ emitter: @escaping EmbeddedWebViewEventEmitter) -> UUID {
    let token = UUID()
    emitterToken = token
    eventEmitter = emitter
    return token
  }

  public func clearEmitter(token: UUID) {
    if emitterToken == token {
      eventEmitter = nil
      emitterToken = nil
    }
  }

  // MARK: - Public API (must be called on main thread)

  public func setUrl(_ url: String) {
    // Match react-native-webview parity: any invalid URL surfaces as a load
    // error instead of silently no-op'ing. WKWebView accepts URLs without a
    // scheme but never emits a navigation event for them — fail fast here so
    // the JS side throws WebViewFailedToLoadError.
    guard let parsed = URL(string: url), parsed.scheme != nil else {
      emitInvalidUrlError(url: url)
      return
    }
    ensureWebView()
    // Pre-approve the origin so the first `decidePolicyFor` call can skip
    // the JS round-trip. On cold boot the JS thread may be too congested to
    // respond within the navigation-decision timeout, silently
    // cancelling the load.
    preApprovedOrigin = originString(for: parsed)
    webView?.load(URLRequest(url: parsed))
  }

  private func emitInvalidUrlError(url: String) {
    eventEmitter?("onLoadError", [
      "url": url,
      "code": -1,
      "domain": "EmbeddedWebViewInvalidUrl",
      "description": "Invalid URL: \(url)",
      "isProvisional": true,
    ])
  }

  public func setVisible(_ visible: Bool) {
    ensureWebView()
    // We deliberately do NOT toggle `isHidden`. WKWebView attached to a hidden
    // UIWindow gets throttled by iOS — JS timers slow, fetch callbacks stall,
    // message events stop dispatching — so the page effectively pauses until
    // the window becomes visible again. By keeping the window in the visible
    // window stack and toggling `alpha` + interaction instead, the WKWebView
    // stays "foreground" from the OS's POV and continues running normally.
    if visible {
      window?.alpha = 1
      window?.isUserInteractionEnabled = true
      window?.makeKeyAndVisible()
    } else {
      window?.alpha = 0
      window?.isUserInteractionEnabled = false
    }
  }

  public func setDebuggingEnabled(_ enabled: Bool) {
    debuggingEnabled = enabled
    if let webView = webView, #available(iOS 16.4, *) {
      webView.isInspectable = enabled
    }
  }

  public func destroy() {
    webView?.stopLoading()
    if let userContentController = webView?.configuration.userContentController {
      userContentController.removeAllUserScripts()
      userContentController.removeScriptMessageHandler(forName: scriptHandlerName)
    }
    webView?.navigationDelegate = nil
    webView?.removeFromSuperview()
    webView = nil

    window?.isHidden = true
    window?.rootViewController = nil
    window = nil

    // WKWebView contract: every policy decision handler must be invoked
    // exactly once. Cancel any in-flight decisions before clearing.
    pendingNavigationDecisions.values.forEach { $0(.cancel) }
    pendingNavigationDecisions.removeAll()
    navigationTimers.values.forEach { $0.invalidate() }
    navigationTimers.removeAll()
    preApprovedOrigin = nil
  }

  public func postMessage(_ message: String) {
    guard let webView = webView else { return }
    let escaped = jsStringLiteral(message)
    let script = "window.dispatchEvent(new MessageEvent('message', { data: \(escaped) }));"
    webView.evaluateJavaScript(script, completionHandler: nil)
  }

  public func respondToShouldStartLoad(id: String, allow: Bool) {
    DispatchQueue.main.async { [weak self] in
      guard let self = self else { return }
      guard let handler = self.pendingNavigationDecisions.removeValue(forKey: id) else { return }
      self.navigationTimers.removeValue(forKey: id)?.invalidate()
      handler(allow ? .allow : .cancel)
    }
  }

  // MARK: - Lazy creation

  private func ensureWebView() {
    if webView != nil { return }

    let configuration = WKWebViewConfiguration()
    configuration.websiteDataStore = .default()

    let userContentController = WKUserContentController()
    let polyfill = """
    (function() {
      if (window.ReactNativeWebView) return;
      window.ReactNativeWebView = {
        postMessage: function(message) {
          window.webkit.messageHandlers.\(scriptHandlerName).postMessage(message);
        }
      };
    })();
    """
    let userScript = WKUserScript(
      source: polyfill,
      injectionTime: .atDocumentStart,
      forMainFrameOnly: false
    )
    userContentController.addUserScript(userScript)
    userContentController.add(self, name: scriptHandlerName)
    configuration.userContentController = userContentController

    let frame = UIScreen.main.bounds
    let webView = WKWebView(frame: frame, configuration: configuration)
    webView.navigationDelegate = self
    webView.scrollView.contentInsetAdjustmentBehavior = .never
    webView.translatesAutoresizingMaskIntoConstraints = false

    // Make the WKWebView transparent so the RN content beneath shows through
    // when the webview-controller renders without an opaque backdrop.
    webView.isOpaque = false
    webView.backgroundColor = .clear
    webView.scrollView.backgroundColor = .clear

    if #available(iOS 16.4, *) {
      webView.isInspectable = debuggingEnabled
    }

    let rootVC = UIViewController()
    rootVC.view.backgroundColor = .clear
    rootVC.view.isOpaque = false
    rootVC.view.addSubview(webView)
    NSLayoutConstraint.activate([
      webView.topAnchor.constraint(equalTo: rootVC.view.topAnchor),
      webView.bottomAnchor.constraint(equalTo: rootVC.view.bottomAnchor),
      webView.leadingAnchor.constraint(equalTo: rootVC.view.leadingAnchor),
      webView.trailingAnchor.constraint(equalTo: rootVC.view.trailingAnchor),
    ])

    let window = UIWindow(frame: frame)
    window.windowLevel = .alert
    window.backgroundColor = .clear
    window.isOpaque = false
    window.rootViewController = rootVC
    // Keep the window in the visible window stack from creation onward so
    // iOS does not throttle the WKWebView's JS execution. We start fully
    // transparent and non-interactive — `setVisible(true)` flips `alpha` and
    // interaction back on without ever hiding the window.
    window.isHidden = false
    window.alpha = 0
    window.isUserInteractionEnabled = false

    self.webView = webView
    self.window = window
  }

  // Walk the WKWebView subview tree, find the WKContentView (the first
  // responder for keyboard input), and dynamically subclass it so its
  // `inputAccessoryView` getter returns nil. This is the same technique
  // react-native-webview uses for `hideKeyboardAccessoryView`.
  private func hideInputAccessoryView(on webView: WKWebView) {
    let hiddenClassName = "DynamicEmbeddedWebViewHiddenAccessory"
    guard let contentView = findContentView(in: webView) else { return }
    if NSStringFromClass(type(of: contentView)) == hiddenClassName { return }
    guard let baseClass = object_getClass(contentView) else { return }
    let existing: AnyClass? = NSClassFromString(hiddenClassName)
    let targetClass: AnyClass
    if let existing = existing {
      targetClass = existing
    } else {
      guard let newClass = objc_allocateClassPair(baseClass, hiddenClassName, 0) else { return }
      let block: @convention(block) (Any) -> UIView? = { _ in nil }
      let imp = imp_implementationWithBlock(block)
      let selector = #selector(getter: UIResponder.inputAccessoryView)
      class_addMethod(newClass, selector, imp, "@@:")
      objc_registerClassPair(newClass)
      targetClass = newClass
    }
    object_setClass(contentView, targetClass)
  }

  private func findContentView(in view: UIView) -> UIView? {
    for subview in view.subviews {
      let className = NSStringFromClass(type(of: subview))
      if className.contains("WKContentView") { return subview }
      if let found = findContentView(in: subview) { return found }
    }
    return nil
  }

  // Extract the scheme + host + port origin from a URL (e.g.
  // "https://webview.dynamicauth.com"). Returns nil for URLs without a host.
  // Default ports (443 for HTTPS, 80 for HTTP) are omitted so that
  // "https://example.com" and "https://example.com:443" produce the same
  // origin string.
  private func originString(for url: URL?) -> String? {
    guard let url = url, let scheme = url.scheme, let host = url.host else {
      return nil
    }
    if let port = url.port {
      let isDefaultPort = (scheme == "https" && port == 443) || (scheme == "http" && port == 80)
      if !isDefaultPort {
        return "\(scheme)://\(host):\(port)"
      }
    }
    return "\(scheme)://\(host)"
  }

  // Use JSONSerialization to safely encode an arbitrary string as a JS string literal.
  private func jsStringLiteral(_ raw: String) -> String {
    guard
      let data = try? JSONSerialization.data(withJSONObject: [raw], options: []),
      let arrayString = String(data: data, encoding: .utf8),
      arrayString.count >= 2
    else {
      return "\"\""
    }
    let start = arrayString.index(after: arrayString.startIndex)
    let end = arrayString.index(before: arrayString.endIndex)
    return String(arrayString[start..<end])
  }
}

extension EmbeddedWebViewController: WKScriptMessageHandler {
  public func userContentController(
    _: WKUserContentController,
    didReceive message: WKScriptMessage
  ) {
    guard message.name == scriptHandlerName else { return }
    let payload: String
    if let asString = message.body as? String {
      payload = asString
    } else {
      payload = String(describing: message.body)
    }
    eventEmitter?("onMessage", ["message": payload])
  }
}

extension EmbeddedWebViewController: WKNavigationDelegate {
  public func webView(
    _: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  ) {
    let url = navigationAction.request.url?.absoluteString ?? ""
    let scheme = navigationAction.request.url?.scheme?.lowercased()
    let isTopFrame = navigationAction.targetFrame?.isMainFrame ?? true

    // Sub-frame requests are auto-allowed (matches Android + JS allowlist).
    // The trusted top-frame controls iframe content; iframes routinely start
    // as `about:blank` and use `blob:` / `data:` / `about:srcdoc` URLs for
    // legitimate functionality (WaaS MPC iframes, web workers, sandboxed
    // inline content), so we defer iframe trust to the top-frame.
    if !isTopFrame {
      decisionHandler(.allow)
      return
    }

    // Defense-in-depth: reject non-https top-frame schemes at the native layer
    // before prompting JS. The JS allowlist would also reject these, but a JS
    // bug approving a `javascript:` / `file:` / `data:` URL must not be enough
    // to load it. http is permitted only in debug builds.
    let isAllowedTopFrameScheme =
      scheme == "https" || (allowsHttpScheme && scheme == "http")
    if !isAllowedTopFrameScheme {
      decisionHandler(.cancel)
      eventEmitter?("onLoadError", [
        "url": url,
        "code": -1,
        "domain": "EmbeddedWebViewBlockedScheme",
        "description": "Blocked navigation to disallowed scheme: \(url)",
        "isProvisional": true,
      ])
      return
    }

    // Fast-path: if the URL's origin matches the pre-approved origin (set
    // by `setUrl`), allow immediately. This eliminates the cold-boot race
    // where the JS thread is too congested to respond to
    // `onShouldStartLoad` within the navigation-decision timeout.
    if let approved = preApprovedOrigin,
       approved == originString(for: navigationAction.request.url) {
      preApprovedOrigin = nil
      decisionHandler(.allow)
      return
    }

    let id = UUID().uuidString
    pendingNavigationDecisions[id] = decisionHandler

    let timer = Timer.scheduledTimer(withTimeInterval: navigationDecisionTimeout, repeats: false) { [weak self] _ in
      guard let self = self else { return }
      DispatchQueue.main.async {
        if let handler = self.pendingNavigationDecisions.removeValue(forKey: id) {
          self.navigationTimers.removeValue(forKey: id)?.invalidate()
          handler(.cancel)
          // Surface an explicit load error so the SDK's error path runs
          // immediately instead of waiting for the 20 s html_load timer.
          // Matches Android's EmbeddedWebViewNavigationTimeout behavior.
          let timeoutMs = Int(navigationDecisionTimeout * 1000)
          self.eventEmitter?("onLoadError", [
            "url": url,
            "code": -1,
            "domain": "EmbeddedWebViewNavigationTimeout",
            "description": "Navigation decision timed out after \(timeoutMs)ms",
            "isProvisional": true,
          ])
        }
      }
    }
    navigationTimers[id] = timer

    eventEmitter?("onShouldStartLoad", [
      "id": id,
      "url": url,
      "isTopFrame": isTopFrame,
    ])
  }

  public func webView(
    _: WKWebView,
    decidePolicyFor navigationResponse: WKNavigationResponse,
    decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
  ) {
    // WKWebView does not fire didFail / didFailProvisionalNavigation for HTTP
    // error status codes — a 404 or 500 just renders an empty page. Surface
    // main-frame HTTP errors as onLoadError so the SDK's load-error path runs.
    if let httpResponse = navigationResponse.response as? HTTPURLResponse,
      navigationResponse.isForMainFrame,
      httpResponse.statusCode >= 400 {
      let url = httpResponse.url?.absoluteString ?? ""
      let description = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)
      decisionHandler(.cancel)
      eventEmitter?("onLoadError", [
        "url": url,
        "code": httpResponse.statusCode,
        "domain": "EmbeddedWebViewHttpError",
        "description": "HTTP \(httpResponse.statusCode): \(description)",
        "isProvisional": false,
      ])
      return
    }
    decisionHandler(.allow)
  }

  public func webView(
    _ webView: WKWebView,
    didFailProvisionalNavigation _: WKNavigation!,
    withError error: Error
  ) {
    emitLoadError(error: error as NSError, isProvisional: true, url: webView.url?.absoluteString)
  }

  public func webView(
    _ webView: WKWebView,
    didFail _: WKNavigation!,
    withError error: Error
  ) {
    emitLoadError(error: error as NSError, isProvisional: false, url: webView.url?.absoluteString)
  }

  public func webView(
    _ webView: WKWebView,
    didStartProvisionalNavigation _: WKNavigation!
  ) {
    eventEmitter?("onLoadStart", [
      "url": webView.url?.absoluteString ?? "",
    ])
  }

  public func webView(_ webView: WKWebView, didCommit _: WKNavigation!) {
    // WKContentView is created lazily once content starts being committed —
    // suppress its input accessory bar here so focusing inputs in the page
    // doesn't show the system "< > Done" toolbar. Matches the
    // `hideKeyboardAccessoryView` setting used by `react-native-webview`.
    hideInputAccessoryView(on: webView)

    // didCommit fires when the first byte of the response is rendered —
    // analogous to RN's `onLoad` callback (page content has actually
    // started arriving, not just the navigation request).
    eventEmitter?("onLoad", [
      "url": webView.url?.absoluteString ?? "",
    ])
  }

  public func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
    eventEmitter?("onLoadEnd", [
      "url": webView.url?.absoluteString ?? "",
    ])
  }

  public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
    eventEmitter?("onLoadError", [
      "url": webView.url?.absoluteString ?? "",
      "code": -1,
      "domain": "EmbeddedWebViewProcessTerminated",
      "description": "WebContent process terminated",
      "isProvisional": false,
    ])
    // The content process is dead — this WKWebView can never load anything
    // again. Tear it down so the next `setUrl` recreates a fresh webview via
    // the lazy `ensureWebView` path (mirrors Android's onRenderProcessGone).
    destroy()
  }

  // Errors from cancelling our own navigation (e.g. when the JS allowlist
  // returns false or the navigation-decision timeout fires) are not real load
  // failures and should not be instrumented as such.
  private func isCancellationError(_ error: NSError) -> Bool {
    if error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled {
      return true
    }
    // WebKit "frame load interrupted" — fired when our policy decision cancels
    // the navigation.
    if error.domain == "WebKitErrorDomain" && error.code == 102 {
      return true
    }
    return false
  }

  private func emitLoadError(error: NSError, isProvisional: Bool, url: String?) {
    if isCancellationError(error) { return }
    eventEmitter?("onLoadError", [
      "url": url ?? "",
      "code": error.code,
      "domain": "EmbeddedWebViewLoadError",
      "description": error.localizedDescription,
      "isProvisional": isProvisional,
    ])
  }
}
