import SwiftUI
import WebKit
import QuickLook
import SafariServices
import CryptoKit
import AVFoundation

final class BarcodeScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
  private let onResult: (String?) -> Void
  private let promptText: String
  private var captureSession: AVCaptureSession?
  private var previewLayer: AVCaptureVideoPreviewLayer?
  private var didEmitResult = false

  init(promptText: String, onResult: @escaping (String?) -> Void) {
    self.promptText = promptText
    self.onResult = onResult
    super.init(nibName: nil, bundle: nil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    view.backgroundColor = .black
    configureCamera()
    configureOverlay()
  }

  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    previewLayer?.frame = view.bounds
  }

  private func configureCamera() {
    let session = AVCaptureSession()
    guard let videoCaptureDevice = AVCaptureDevice.default(for: .video),
      let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice)
    else {
      finish(with: nil)
      return
    }

    guard session.canAddInput(videoInput) else {
      finish(with: nil)
      return
    }
    session.addInput(videoInput)

    let metadataOutput = AVCaptureMetadataOutput()
    guard session.canAddOutput(metadataOutput) else {
      finish(with: nil)
      return
    }
    session.addOutput(metadataOutput)
    metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
    metadataOutput.metadataObjectTypes = [
      .ean8, .ean13, .upce, .code39, .code93, .code128, .pdf417, .qr, .aztec, .dataMatrix, .itf14,
    ]

    let preview = AVCaptureVideoPreviewLayer(session: session)
    preview.videoGravity = .resizeAspectFill
    preview.frame = view.layer.bounds
    view.layer.addSublayer(preview)
    previewLayer = preview
    captureSession = session

    DispatchQueue.global(qos: .userInitiated).async {
      session.startRunning()
    }
  }

  private func configureOverlay() {
    let overlay = UIView()
    overlay.backgroundColor = UIColor.black.withAlphaComponent(0.35)
    overlay.translatesAutoresizingMaskIntoConstraints = false
    view.addSubview(overlay)

    let promptLabel = UILabel()
    promptLabel.text = promptText.isEmpty ? "Scan code" : promptText
    promptLabel.textAlignment = .center
    promptLabel.textColor = .white
    promptLabel.font = .systemFont(ofSize: 17, weight: .semibold)
    promptLabel.numberOfLines = 2
    promptLabel.translatesAutoresizingMaskIntoConstraints = false
    overlay.addSubview(promptLabel)

    let cancelButton = UIButton(type: .system)
    cancelButton.setTitle("Cancel", for: .normal)
    cancelButton.setTitleColor(.white, for: .normal)
    cancelButton.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold)
    cancelButton.translatesAutoresizingMaskIntoConstraints = false
    cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
    overlay.addSubview(cancelButton)

    NSLayoutConstraint.activate([
      overlay.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
      overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
      overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
      overlay.heightAnchor.constraint(equalToConstant: 120),

      promptLabel.leadingAnchor.constraint(equalTo: overlay.leadingAnchor, constant: 16),
      promptLabel.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor, constant: -12),
      promptLabel.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),

      cancelButton.trailingAnchor.constraint(equalTo: overlay.trailingAnchor, constant: -16),
      cancelButton.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
    ])
  }

  @objc private func cancelTapped() {
    finish(with: nil)
  }

  func metadataOutput(
    _ output: AVCaptureMetadataOutput,
    didOutput metadataObjects: [AVMetadataObject],
    from connection: AVCaptureConnection
  ) {
    guard let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
      let code = object.stringValue
    else {
      return
    }
    finish(with: code)
  }

  private func finish(with value: String?) {
    guard !didEmitResult else { return }
    didEmitResult = true
    captureSession?.stopRunning()
    dismiss(animated: true) {
      self.onResult(value)
    }
  }
}

struct WebContainerView: UIViewRepresentable {
  /// Start file in the `www` folder reference (yellow folder)
  var startFile: String = "index"

  func makeCoordinator() -> Coordinator { Coordinator() }

  func makeUIView(context: Context) -> WKWebView {
    let installedBundleURL = context.coordinator.currentWebBundleStartURL(startFile: startFile)
    let forceBundledAssets = context.coordinator.forceBundledAssets
    let preferBundledAssets: Bool = {
      return context.coordinator.shouldIgnoreRemoteWebBundleUpdates
    }()
    // Config + user content
    let config = WKWebViewConfiguration()
    let ucc = WKUserContentController()
    let activeBundleVersion = context.coordinator.getCurrentWebBundleVersion()
    let effectiveBundleVersion = forceBundledAssets ? "bundled-forced" : (preferBundledAssets ? "bundled-debug" : activeBundleVersion)
    let bundleSource = forceBundledAssets ? "bundled_forced" : (preferBundledAssets ? "bundled" : "ota_or_bundled")
    let escapedBundleVersion = effectiveBundleVersion
      .replacingOccurrences(of: "\\", with: "\\\\")
      .replacingOccurrences(of: "'", with: "\\'")
    let escapedBundleSource = bundleSource
      .replacingOccurrences(of: "\\", with: "\\\\")
      .replacingOccurrences(of: "'", with: "\\'")
    let installedBundleExists = installedBundleURL != nil ? "yes" : "no"
    let escapedInstalledBundleExists = installedBundleExists
      .replacingOccurrences(of: "\\", with: "\\\\")
      .replacingOccurrences(of: "'", with: "\\'")
    print("📦 iOS bundle diagnostic: startFile=\(startFile) forceBundledAssets=\(forceBundledAssets) preferBundledAssets=\(preferBundledAssets) installedBundleExists=\(installedBundleExists) activeBundleVersion=\(activeBundleVersion) effectiveBundleVersion=\(effectiveBundleVersion) bundleSource=\(bundleSource)")
      
      // 1) Inject APP_CONFIG at document start
    if let (configJSON, apiBase) = Coordinator.readConfigJSON() {
      context.coordinator.apiBase = apiBase
      let js = """
        (function(){
          window.APP_CONFIG = \(configJSON);
          window.hasNative = true;
          window.__nativeWebBundleVersion = '\(escapedBundleVersion)';
          window.__nativeWebBundleSource = '\(escapedBundleSource)';
          window.__nativeInstalledBundleExists = '\(escapedInstalledBundleExists)';
          try { localStorage.setItem('active_ios_web_bundle_version', '\(escapedBundleVersion)'); } catch(_) {}
          try { localStorage.setItem('active_ios_web_bundle_source', '\(escapedBundleSource)'); } catch(_) {}
          try { localStorage.setItem('active_ios_installed_bundle_exists', '\(escapedInstalledBundleExists)'); } catch(_) {}
          console.log('[ios-bundle] startup source=\(escapedBundleSource) version=\(escapedBundleVersion) installed_bundle_exists=\(escapedInstalledBundleExists)');
        })();
        """
      let script = WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true)
      ucc.addUserScript(script)
    } else {
      context.coordinator.apiBase = ""
      let js = """
        (function(){
          window.APP_CONFIG = {};
          window.hasNative = true;
          window.__nativeWebBundleVersion = '\(escapedBundleVersion)';
          window.__nativeWebBundleSource = '\(escapedBundleSource)';
          window.__nativeInstalledBundleExists = '\(escapedInstalledBundleExists)';
          try { localStorage.setItem('active_ios_web_bundle_version', '\(escapedBundleVersion)'); } catch(_) {}
          try { localStorage.setItem('active_ios_web_bundle_source', '\(escapedBundleSource)'); } catch(_) {}
          try { localStorage.setItem('active_ios_installed_bundle_exists', '\(escapedInstalledBundleExists)'); } catch(_) {}
          console.log('[ios-bundle] startup source=\(escapedBundleSource) version=\(escapedBundleVersion) installed_bundle_exists=\(escapedInstalledBundleExists)');
        })();
      """
      let script = WKUserScript(source: js, injectionTime: .atDocumentStart, forMainFrameOnly: true)
      ucc.addUserScript(script)
    }

    // 2) Message handlers
    ucc.add(context.coordinator, name: "native")
    // When building the WKWebView configuration:
    ucc.add(context.coordinator, name: "api")  // ← alias so JS that uses .api keeps working
    // Optional: capture console messages if you wire them up from JS
    ucc.add(context.coordinator, name: "console")
    ucc.add(context.coordinator, name: "view")  // ✅ allow JS to call window.webkit.messageHandlers.view
      ucc.add(context.coordinator, name: "openPaymentBrowser")  // new handler
    config.userContentController = ucc

    let webView = WKWebView(frame: .zero, configuration: config)
    webView.navigationDelegate = context.coordinator
    webView.uiDelegate = context.coordinator
    context.coordinator.webView = webView
    context.coordinator.startNotificationOpenObserver()
    // in makeUIView (after creating webView)
    webView.scrollView.contentInsetAdjustmentBehavior = .automatic  // or .always
    webView.scrollView.contentInset = .zero
    webView.scrollView.scrollIndicatorInsets = .zero
    // 3) In debug/simulator, prefer bundled www so local JS changes are loaded immediately.
    if preferBundledAssets,
      let url = Bundle.main.url(forResource: startFile, withExtension: "html", subdirectory: "www")
    {
      print(forceBundledAssets ? "📦 Web source: bundled www (forced tester build)" : "📦 Web source: bundled www (debug/simulator preference)")
      let base = url.deletingLastPathComponent()
      webView.loadFileURL(url, allowingReadAccessTo: base)
    } else if let overrideUrl = installedBundleURL {
      print("📦 Web source: installed OTA bundle version=\(activeBundleVersion)")
      let base = overrideUrl.deletingLastPathComponent()
      webView.loadFileURL(overrideUrl, allowingReadAccessTo: base)
    } else if let url = Bundle.main.url(forResource: startFile, withExtension: "html", subdirectory: "www")
    {
      print("📦 Web source: bundled www (fallback)")
      let base = url.deletingLastPathComponent()
      webView.loadFileURL(url, allowingReadAccessTo: base)
    }

    return webView
  }

  func updateUIView(_ uiView: WKWebView, context: Context) {}
}

final class Coordinator: NSObject,
  WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate,
  QLPreviewControllerDataSource, QLPreviewControllerDelegate, SFSafariViewControllerDelegate {

  weak var webView: WKWebView?
    private var notificationOpenObserver: NSObjectProtocol?
    private var webViewHasFinishedLoad = false
    private var previewURL: URL?
    private var paymentPollUrl: String?
    private var paymentInvoiceId: Int?
    var apiBase: String = ""  // from Config/app_config.json
    private var safariVC: SFSafariViewController?
    private var hasCheckedAppUpdateThisSession = false
    private let appUpdatePromptKey = "last_prompted_ios_update_version"
    private let appUpdateCheckAtKey = "last_ios_update_check_at"
    private let appUpdateCheckInterval: TimeInterval = 6 * 60 * 60
    private let webBundleVersionKey = "active_ios_web_bundle_version"
    private let webBundleAppliedAtKey = "last_ios_web_bundle_applied_at"
    let forceBundledAssets: Bool = {
      let rawValue = Bundle.main.object(forInfoDictionaryKey: "GRForceEmbeddedWebAssets") as? String
      let normalized = String(rawValue ?? "")
        .trimmingCharacters(in: .whitespacesAndNewlines)
        .lowercased()
      return normalized == "yes" || normalized == "true" || normalized == "1"
    }()
    var shouldIgnoreRemoteWebBundleUpdates: Bool {
      if forceBundledAssets {
        return true
      }
      #if DEBUG
      return true
      #elseif targetEnvironment(simulator)
      return true
      #else
      return false
      #endif
    }
  private let webBundleRootDirName = "webbundle"
  private let webBundleCurrentDirName = "current"
  private let requiredBundleFiles = [
    "index.html",
    "assets/js/helpers.js",
    "assets/js/app.js"
  ]
  // MARK: - Config loader
    static func _readConfigJSON() -> (jsonString: String, apiBase: String)? {
      guard
        let url = Bundle.main.url(forResource: "app_config", withExtension: "json", subdirectory: "Config"),
        let data = try? Data(contentsOf: url),
        let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
      else {
        print("❌ Failed to load Config/app_config.json")
        return nil
      }

      // ✅ Fix trimming bug
      let rawBase = dict["API_BASE_URL"] as? String ?? ""
      let base = rawBase.trimmingCharacters(in: .whitespacesAndNewlines)

      var jsonStr = String(data: data, encoding: .utf8) ?? "{}"
      jsonStr = jsonStr.replacingOccurrences(of: "\u{2028}", with: "")
                       .replacingOccurrences(of: "\u{2029}", with: "")

      print("✅ API_BASE_URL loaded:", base)
      return (jsonStr, base)
    }
  static func readConfigJSON() -> (jsonString: String, apiBase: String)? {
      if let url = Bundle.main.url(forResource: "app_config", withExtension: "json", subdirectory: "Config") {
         print("✅ Found file at:", url)
       } else {
         print("❌ Could not find Config/app_config.json in bundle")
       }
      guard
        let url = Bundle.main.url(forResource: "app_config", withExtension: "json", subdirectory: "Config"),
        let data = try? Data(contentsOf: url),
        
        let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
      else {
        print("❌ Failed to load Config/app_config.json")
        return nil
      }
      
        
      let rawBase = dict["API_BASE_URL"] as? String ?? ""
      let base = rawBase.trimmingCharacters(in: .whitespacesAndNewlines)

    var jsonStr = String(data: data, encoding: .utf8) ?? "{}"
    // Sanitize U+2028 / U+2029 for JS strings
    jsonStr =
      jsonStr
      .replacingOccurrences(of: "\u{2028}", with: "")
      .replacingOccurrences(of: "\u{2029}", with: "")
      print("✅ API_BASE_URL loaded:", base)
    return (jsonStr, base)
  }
    // MARK: - Base64 -> file -> present

    private func handleSaveBase64File(
      _ obj: [String: Any],
      dispatchEvent: @escaping (_ name: String, _ detail: [String: Any]) -> Void
    ) {
      // --- filename (FIXED) ---
      let rawFilename = (obj["filename"] as? String) ?? ""
      let trimmedFilename = rawFilename.trimmingCharacters(in: .whitespacesAndNewlines)
      let filename = trimmedFilename.isEmpty ? "Document.pdf" : trimmedFilename

      let mime = (obj["mime"] as? String) ?? "application/pdf"
      let openExternally = (obj["openExternally"] as? Bool) ?? false

      // Accept raw base64 or data:...;base64,<data>
      var b64 = obj["data"] as? String ?? ""
      if let r = b64.range(of: "base64,") { b64 = String(b64[r.upperBound...]) }

      guard let data = Data(base64Encoded: b64, options: [.ignoreUnknownCharacters]), !data.isEmpty else {
        dispatchEvent("native:fileOpenError", ["filename": filename, "error": "invalid_base64"])
        return
      }

      // extension handling
      let hasExt = !URL(fileURLWithPath: filename).pathExtension.isEmpty
      let ext: String = {
        if hasExt { return "" }
        if mime == "application/pdf" { return ".pdf" }
        if mime.hasPrefix("image/") { return "." + (mime.split(separator: "/").last ?? "bin") }
        return ".bin"
      }()

      let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename + ext)

      do {
        if FileManager.default.fileExists(atPath: url.path) {
          try FileManager.default.removeItem(at: url)
        }
        try data.write(to: url, options: .atomic)

        DispatchQueue.main.async {
          if openExternally { self.presentShareSheet(for: url) }
          else { self.showQuickLook(for: url) }

          dispatchEvent("native:fileSaved", [
            "filename": filename + ext,
            "url": url.absoluteString,
            "mime": mime
          ])
        }
      } catch {
        dispatchEvent("native:fileOpenError", ["filename": filename, "error": error.localizedDescription])
      }
    }
    private func showQuickLook(for url: URL) {
      previewURL = url
      let ql = QLPreviewController()
      ql.dataSource = self
      ql.delegate = self
      present(ql)
    }

    private func presentShareSheet(for url: URL) {
      let av = UIActivityViewController(activityItems: [url], applicationActivities: nil)
      if let pop = av.popoverPresentationController, let view = webView {
        pop.sourceView = view
        pop.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.maxY - 44, width: 1, height: 1)
        pop.permittedArrowDirections = []
      }
      present(av)
    }

    // Present from top VC
    private func present(_ vc: UIViewController) {
      guard let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first,
            let root = scene.keyWindow?.rootViewController else {
        webView?.window?.rootViewController?.present(vc, animated: true)
        return
      }
      var top = root
      while let p = top.presentedViewController { top = p }
      top.present(vc, animated: true)
    }

    // MARK: - QLPreviewControllerDataSource
    func numberOfPreviewItems(in controller: QLPreviewController) -> Int { previewURL == nil ? 0 : 1 }
    func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
      return previewURL! as NSURL
    }  // MARK: - Utility
  private func sanitizeForJS(_ s: String) -> String {
    s.replacingOccurrences(of: "\u{2028}", with: "").replacingOccurrences(of: "\u{2029}", with: "")
  }

  private func buildURL(path: String) -> URL? {
    if let u = URL(string: path), let scheme = u.scheme, scheme.hasPrefix("http") {
      return normalizeLocalhostURL(u)
    }
    let trimmedPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
    guard !apiBase.isEmpty else { return nil }
    guard let resolved = URL(string: "\(apiBase)/\(trimmedPath)") else { return nil }
    return normalizeLocalhostURL(resolved)
  }

  private func normalizeLocalhostURL(_ url: URL) -> URL {
#if DEBUG
    guard let host = url.host?.lowercased(), host == "localhost" else { return url }
    guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
    comps.host = "127.0.0.1"
    return comps.url ?? url
#else
    return url
#endif
  }

  // MARK: - Respond back to JS (event-based like your older code)
  private func respond(id: String, status: Int, dict: [String: Any]) {
    var payload = dict
    payload["id"] = id
    payload["status"] = status

    guard
      let data = try? JSONSerialization.data(withJSONObject: payload, options: []),
      var json = String(data: data, encoding: .utf8)
    else { return }

    json = sanitizeForJS(json)

    DispatchQueue.main.async { [weak self] in
      self?.webView?.evaluateJavaScript(
        "window.dispatchEvent(new CustomEvent('native:api', { detail: \(json) }));",
        completionHandler: nil
      )
    }
  }

  private func respondError(id: String, status: Int, message: String) {
    respond(id: id, status: status, dict: ["error": message, "body": ""])
  }

  private func dispatchEvent(_ name: String, detail: [String: Any]) {
    do {
      let data = try JSONSerialization.data(withJSONObject: detail, options: [])
      guard var json = String(data: data, encoding: .utf8) else { return }
      json = sanitizeForJS(json)
      let eventPath = String(describing: detail["path"] ?? "")
      let isReportImageEvent = (name == "native:api" && eventPath.contains("/api/get-report-image"))
      if isReportImageEvent {
        let status = detail["status"] as? Int ?? 0
        let bytes = detail["bytes"] as? Int ?? 0
        let bodyLen = (detail["body"] as? String)?.count ?? 0
        print("📨 dispatchEvent native:api path=\(eventPath) status=\(status) bytes=\(bytes) bodyLen=\(bodyLen)")
      }
      DispatchQueue.main.async { [weak self] in
        self?.webView?.evaluateJavaScript(
          "window.dispatchEvent(new CustomEvent('\(name)', { detail: \(json) }));",
          completionHandler: { _, err in
            if let err = err {
              print("⚠️ dispatchEvent eval failed [\(name)]: \(err.localizedDescription)")
            } else if isReportImageEvent {
              print("✅ dispatchEvent eval delivered [\(name)] path=\(eventPath)")
            }
          }
        )
      }
    } catch {
      let fallbackId = sanitizeForJS(String(describing: detail["id"] ?? ""))
      let fallbackStatus = detail["status"] as? Int ?? 0
      print("⚠️ dispatchEvent JSON serialization failed [\(name)] keys=\(Array(detail.keys)) error=\(error.localizedDescription)")
      DispatchQueue.main.async { [weak self] in
        self?.webView?.evaluateJavaScript(
          "window.dispatchEvent(new CustomEvent('\(name)', { detail: { id: '\(fallbackId)', status: \(fallbackStatus), error: 'serialization_failed' } }));",
          completionHandler: { _, err in
            if let err = err {
              print("⚠️ dispatchEvent fallback eval failed [\(name)]: \(err.localizedDescription)")
            }
          }
        )
      }
    }
  }

  func startNotificationOpenObserver() {
    if notificationOpenObserver != nil { return }
    notificationOpenObserver = NotificationCenter.default.addObserver(
      forName: AppDelegate.notificationOpenedName,
      object: nil,
      queue: .main
    ) { [weak self] notification in
      guard let self = self else { return }
      var detail = notification.userInfo as? [String: Any] ?? [:]
      if (detail["open_view"] as? String ?? "").isEmpty {
        detail["open_view"] = "notifications"
      }
      if self.webViewHasFinishedLoad {
        AppDelegate.pendingNotificationOpen = nil
        self.dispatchEvent("native:notificationOpened", detail: detail)
      } else {
        AppDelegate.pendingNotificationOpen = detail
      }
    }
    flushPendingNotificationOpenIfReady()
  }

  private func flushPendingNotificationOpenIfReady() {
    guard webViewHasFinishedLoad, let pending = AppDelegate.pendingNotificationOpen else { return }
    AppDelegate.pendingNotificationOpen = nil
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
      self?.dispatchEvent("native:notificationOpened", detail: pending)
    }
  }

  deinit {
    if let notificationOpenObserver = notificationOpenObserver {
      NotificationCenter.default.removeObserver(notificationOpenObserver)
    }
  }

  private func emitWebBundleInstallStarted(version: String, packageType: String) {
    dispatchEvent("native:webBundleInstallStarted", detail: [
      "version": version,
      "platform": "ios",
      "packageType": packageType
    ])
  }

  private func emitWebBundleInstallFailed(version: String, reason: String, packageType: String) {
    dispatchEvent("native:webBundleInstallFailed", detail: [
      "version": version,
      "platform": "ios",
      "packageType": packageType,
      "reason": reason
    ])
  }

  private func isSafeViewName(_ s: String) -> Bool {
    let allowed = CharacterSet(
      charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-/")
    return !s.isEmpty && s.rangeOfCharacter(from: allowed.inverted) == nil && !s.contains("..")
  }

  private func b64(_ data: Data) -> String { data.base64EncodedString() }

  private func startBarcodeScan(
    field: String,
    promptText: String,
    dispatchEvent: @escaping (_ name: String, _ detail: [String: Any]) -> Void
  ) {
    let normalizedField = field.trimmingCharacters(in: .whitespacesAndNewlines)
    let normalizedPrompt = promptText.trimmingCharacters(in: .whitespacesAndNewlines)

    func emitResult(value: String?, error: String? = nil, cancelled: Bool = false) {
      var detail: [String: Any] = [
        "field": normalizedField,
        "value": (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines),
        "cancelled": cancelled
      ]
      if let error = error, !error.isEmpty {
        detail["error"] = error
      }
        dispatchEvent("native:scanResult", detail)
    }

    func presentScanner() {
      let scanner = BarcodeScannerViewController(promptText: normalizedPrompt) { code in
        let trimmed = (code ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
        if trimmed.isEmpty {
          emitResult(value: "", cancelled: true)
        } else {
          emitResult(value: trimmed, cancelled: false)
        }
      }
      scanner.modalPresentationStyle = .fullScreen
      self.present(scanner)
    }

    let authorization = AVCaptureDevice.authorizationStatus(for: .video)
    switch authorization {
    case .authorized:
      DispatchQueue.main.async {
        presentScanner()
      }
    case .notDetermined:
      AVCaptureDevice.requestAccess(for: .video) { granted in
        DispatchQueue.main.async {
          if granted {
            presentScanner()
          } else {
            emitResult(value: "", error: "camera_permission_denied", cancelled: true)
          }
        }
      }
    default:
      emitResult(value: "", error: "camera_permission_denied", cancelled: true)
    }
  }

  private func loadFileB64(path: String) -> String? {
    if let currentDir = webBundleCurrentDirectory() {
      let overrideUrl = currentDir.appendingPathComponent(path)
      if FileManager.default.fileExists(atPath: overrideUrl.path),
        let data = try? Data(contentsOf: overrideUrl) {
        return b64(data)
      }
    }

    guard let url = Bundle.main.url(forResource: path, withExtension: nil) else { return nil }
    guard let data = try? Data(contentsOf: url) else { return nil }
    return b64(data)
  }

  private func webBundleRootDirectory() -> URL? {
    do {
      let appSupport = try FileManager.default.url(
        for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
      let root = appSupport.appendingPathComponent(webBundleRootDirName, isDirectory: true)
      if !FileManager.default.fileExists(atPath: root.path) {
        try FileManager.default.createDirectory(
          at: root, withIntermediateDirectories: true, attributes: nil)
      }
      return root
    } catch {
      print("⚠️ iOS web bundle root error:", error.localizedDescription)
      return nil
    }
  }

  private func webBundleCurrentDirectory() -> URL? {
    guard let root = webBundleRootDirectory() else { return nil }
    return root.appendingPathComponent(webBundleCurrentDirName, isDirectory: true)
  }

  func currentWebBundleStartURL(startFile: String) -> URL? {
    if shouldIgnoreRemoteWebBundleUpdates {
      return nil
    }
    guard ensureUsableWebBundle() else { return nil }
    guard let currentDir = webBundleCurrentDirectory() else { return nil }
    let startUrl = currentDir.appendingPathComponent("\(startFile).html")
    guard FileManager.default.fileExists(atPath: startUrl.path) else { return nil }
    return startUrl
  }

	  func getCurrentWebBundleVersion() -> String {
	    let installedVersion = (UserDefaults.standard.string(forKey: webBundleVersionKey) ?? "")
	      .trimmingCharacters(in: .whitespacesAndNewlines)
	    if !installedVersion.isEmpty {
	      return installedVersion
	    }

	    return embeddedWebBundleVersion()
	  }

	  private func embeddedWebBundleVersion() -> String {
	    return (Bundle.main.object(forInfoDictionaryKey: "GRWebBundleVersion") as? String ?? "")
	      .trimmingCharacters(in: .whitespacesAndNewlines)
	  }

  private func clearActiveBundleVersion() {
    UserDefaults.standard.set("", forKey: webBundleVersionKey)
  }

  private func currentWebBundleSourceLabel() -> String {
    if currentWebBundleStartURL(startFile: "index") != nil {
      return "ota_or_bundled"
    }
    return "bundled"
  }

  private func syncCurrentBundleVersionToPage(_ webView: WKWebView) {
    let activeVersion = getCurrentWebBundleVersion().trimmingCharacters(in: .whitespacesAndNewlines)
    let bundleSource = currentWebBundleSourceLabel()
    let installedBundleExists = currentWebBundleStartURL(startFile: "index") != nil ? "yes" : "no"
    let escapedVersion = activeVersion
      .replacingOccurrences(of: "\\", with: "\\\\")
      .replacingOccurrences(of: "'", with: "\\'")
    let escapedSource = bundleSource
      .replacingOccurrences(of: "\\", with: "\\\\")
      .replacingOccurrences(of: "'", with: "\\'")
    let escapedInstalledBundleExists = installedBundleExists
      .replacingOccurrences(of: "\\", with: "\\\\")
      .replacingOccurrences(of: "'", with: "\\'")

    let js = """
      (function(){
        window.__nativeWebBundleVersion = '\(escapedVersion)';
        window.__nativeWebBundleSource = '\(escapedSource)';
        window.__nativeInstalledBundleExists = '\(escapedInstalledBundleExists)';
        try { localStorage.setItem('active_ios_web_bundle_version', '\(escapedVersion)'); } catch(_) {}
        try { localStorage.setItem('active_ios_web_bundle_source', '\(escapedSource)'); } catch(_) {}
        try { localStorage.setItem('active_ios_installed_bundle_exists', '\(escapedInstalledBundleExists)'); } catch(_) {}
        console.log('[ios-bundle] sync source=\(escapedSource) version=\(escapedVersion) installed_bundle_exists=\(escapedInstalledBundleExists)');
      })();
    """

    webView.evaluateJavaScript(js) { _, error in
      if let error = error {
        print("⚠️ iOS bundle sync JS failed:", error.localizedDescription)
      } else {
        print("✅ iOS bundle sync JS applied: source=\(bundleSource), version=\(activeVersion), installedBundleExists=\(installedBundleExists)")
      }
    }
  }

  private func isBundleUsable(_ directory: URL) -> Bool {
    var isDir: ObjCBool = false
    guard FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDir), isDir.boolValue else {
      return false
    }
    return requiredBundleFiles.allSatisfy { relativePath in
      FileManager.default.fileExists(atPath: directory.appendingPathComponent(relativePath).path)
    }
  }

  private func missingRequiredBundleFiles(in directory: URL) -> [String] {
    requiredBundleFiles.filter { relativePath in
      !FileManager.default.fileExists(atPath: directory.appendingPathComponent(relativePath).path)
    }
  }

  private func ensureUsableWebBundle() -> Bool {
    guard let rootDir = webBundleRootDirectory() else { return false }
    let currentDir = rootDir.appendingPathComponent(webBundleCurrentDirName, isDirectory: true)
    let previousDir = rootDir.appendingPathComponent("previous", isDirectory: true)

    if isBundleUsable(currentDir) {
      return true
    }

    if isBundleUsable(previousDir) {
      try? FileManager.default.removeItem(at: currentDir)
      do {
        try FileManager.default.moveItem(at: previousDir, to: currentDir)
        if isBundleUsable(currentDir) {
          clearActiveBundleVersion()
          return true
        }
      } catch {
        print("⚠️ iOS bundle rollback failed:", error.localizedDescription)
      }
    }

    try? FileManager.default.removeItem(at: currentDir)
    clearActiveBundleVersion()
    return false
  }

  private func isSafeBundleRelativePath(_ path: String) -> Bool {
    if path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return false }
    let normalized = path.replacingOccurrences(of: "\\", with: "/")
    if normalized.hasPrefix("/") { return false }
    if normalized.contains("..") { return false }
    return true
  }

  private func decodeBundleFileContent(_ fileObj: [String: Any]) -> Data? {
    let content = (fileObj["content"] as? String ?? "")
    let encoding = (fileObj["encoding"] as? String ?? "base64").lowercased()
    if content.isEmpty { return Data() }

    switch encoding {
    case "utf8", "text", "plain":
      return content.data(using: .utf8)
    default:
      return Data(base64Encoded: content, options: [.ignoreUnknownCharacters])
    }
  }

  private func sha256Hex(_ data: Data) -> String {
    let digest = SHA256.hash(data: data)
    return digest.map { String(format: "%02x", $0) }.joined()
  }

  private func writeWebBundleFiles(stagingDir: URL, files: [[String: Any]]) -> Bool {
    for item in files {
      let relativePath = (item["path"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
      if !isSafeBundleRelativePath(relativePath) {
        print("⚠️ iOS bundle unsafe path:", relativePath)
        return false
      }

      guard let bytes = decodeBundleFileContent(item) else {
        print("⚠️ iOS bundle decode failed:", relativePath)
        return false
      }

      let target = stagingDir.appendingPathComponent(relativePath)
      let parent = target.deletingLastPathComponent()
      do {
        try FileManager.default.createDirectory(
          at: parent, withIntermediateDirectories: true, attributes: nil)
        try bytes.write(to: target, options: .atomic)
      } catch {
        print("⚠️ iOS bundle write failed (\(relativePath)):", error.localizedDescription)
        return false
      }
    }
    return true
  }

  private func applyWebBundleDeleteList(currentDir: URL, deleteList: [Any]?) {
    guard let deleteList = deleteList else { return }
    for entry in deleteList {
      guard let relativePath = entry as? String else { continue }
      if !isSafeBundleRelativePath(relativePath) { continue }
      let target = currentDir.appendingPathComponent(relativePath)
      if FileManager.default.fileExists(atPath: target.path) {
        try? FileManager.default.removeItem(at: target)
      }
    }
  }

  private func copyDirectoryContents(from source: URL, to destination: URL) -> Bool {
    do {
      try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
      let items = try FileManager.default.contentsOfDirectory(at: source, includingPropertiesForKeys: nil)
      for item in items {
        let target = destination.appendingPathComponent(item.lastPathComponent)
        if FileManager.default.fileExists(atPath: target.path) {
          try FileManager.default.removeItem(at: target)
        }
        try FileManager.default.copyItem(at: item, to: target)
      }
      return true
    } catch {
      return false
    }
  }

  private func mergeDirectoryContents(from source: URL, to destination: URL) -> Bool {
    do {
      try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
      let items = try FileManager.default.contentsOfDirectory(at: source, includingPropertiesForKeys: nil)
      for item in items {
        let target = destination.appendingPathComponent(item.lastPathComponent)
        var isDir: ObjCBool = false
        FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir)
        if isDir.boolValue {
          if FileManager.default.fileExists(atPath: target.path) {
            var targetIsDir: ObjCBool = false
            FileManager.default.fileExists(atPath: target.path, isDirectory: &targetIsDir)
            if !targetIsDir.boolValue {
              try FileManager.default.removeItem(at: target)
              try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true)
            }
          }
          if !mergeDirectoryContents(from: item, to: target) {
            return false
          }
        } else {
          if FileManager.default.fileExists(atPath: target.path) {
            try FileManager.default.removeItem(at: target)
          }
          try FileManager.default.copyItem(at: item, to: target)
        }
      }
      return true
    } catch {
      return false
    }
  }

  private func seedStagingBundle(_ stagingDir: URL) -> Bool {
    if let currentDir = webBundleCurrentDirectory(), FileManager.default.fileExists(atPath: currentDir.path) {
      return copyDirectoryContents(from: currentDir, to: stagingDir)
    }

    guard let bundledWww = Bundle.main.resourceURL?.appendingPathComponent("www", isDirectory: true),
      FileManager.default.fileExists(atPath: bundledWww.path) else {
      return false
    }
    return copyDirectoryContents(from: bundledWww, to: stagingDir)
  }

  private func activateStagingBundle(
    stagingDir: URL,
    packageVersion: String,
    forceReload: Bool,
    deleteList: [Any]? = nil
  ) {
    guard let rootDir = self.webBundleRootDirectory() else { return }
    let currentDir = rootDir.appendingPathComponent(self.webBundleCurrentDirName, isDirectory: true)
    let previousDir = rootDir.appendingPathComponent("previous", isDirectory: true)
    guard isBundleUsable(stagingDir) else {
      let missing = missingRequiredBundleFiles(in: stagingDir)
      print("⚠️ iOS staged bundle unusable for version \(packageVersion). Missing files: \(missing)")
      try? FileManager.default.removeItem(at: stagingDir)
      self.emitWebBundleInstallFailed(
        version: packageVersion,
        reason: missing.isEmpty ? "Staged bundle missing required files" : "Staged bundle missing required files: \(missing.joined(separator: ", "))",
        packageType: "json"
      )
      return
    }

    do {
      if FileManager.default.fileExists(atPath: previousDir.path) {
        try FileManager.default.removeItem(at: previousDir)
      }
      if FileManager.default.fileExists(atPath: currentDir.path) {
        try FileManager.default.moveItem(at: currentDir, to: previousDir)
      }

      do {
        try FileManager.default.moveItem(at: stagingDir, to: currentDir)
      } catch {
        if FileManager.default.fileExists(atPath: previousDir.path) {
          try? FileManager.default.moveItem(at: previousDir, to: currentDir)
        }
        try? FileManager.default.removeItem(at: stagingDir)
        throw error
      }

      self.applyWebBundleDeleteList(currentDir: currentDir, deleteList: deleteList)
      UserDefaults.standard.set(packageVersion, forKey: self.webBundleVersionKey)
      UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: self.webBundleAppliedAtKey)
      print("✅ iOS web bundle applied:", packageVersion)
      self.dispatchEvent("native:webBundleApplied", detail: [
        "version": packageVersion,
        "platform": "ios"
      ])

      if forceReload, let reloadURL = self.currentWebBundleStartURL(startFile: "index") {
        print("🔁 iOS web bundle force reloading version \(packageVersion) from \(reloadURL.path)")
        DispatchQueue.main.async {
          let base = reloadURL.deletingLastPathComponent()
          self.webView?.loadFileURL(reloadURL, allowingReadAccessTo: base)
        }
      }
    } catch {
      print("⚠️ iOS bundle activation failed:", error.localizedDescription)
      self.emitWebBundleInstallFailed(
        version: packageVersion,
        reason: "Bundle activation failed: \(error.localizedDescription)",
        packageType: "json"
      )
    }
  }

  private func downloadAndApplyWebBundlePackage(
    packageUrl: String,
    bundleVersion: String,
    checksumSha256: String,
    forceReload: Bool
  ) {
    guard let url = URL(string: packageUrl) else { return }

    URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
      guard let self = self else { return }
      if let error = error {
        print("⚠️ iOS bundle download failed:", error.localizedDescription)
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "Download failed: \(error.localizedDescription)",
          packageType: "json"
        )
        return
      }

      guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode),
        let data = data, !data.isEmpty
      else {
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "Invalid package response",
          packageType: "json"
        )
        return
      }

      if !checksumSha256.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
        let hash = self.sha256Hex(data)
        if hash.caseInsensitiveCompare(checksumSha256) != .orderedSame {
          print("⚠️ iOS bundle checksum mismatch")
          self.emitWebBundleInstallFailed(
            version: bundleVersion,
            reason: "Checksum mismatch",
            packageType: "json"
          )
          return
        }
      }

      guard
        let packageObj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
      else {
        print("⚠️ iOS bundle invalid JSON")
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "Invalid JSON package",
          packageType: "json"
        )
        return
      }

      let packageVersion =
        ((packageObj["version"] as? String) ?? bundleVersion).trimmingCharacters(
          in: .whitespacesAndNewlines)
      let files = packageObj["files"] as? [[String: Any]] ?? []
      print("📦 iOS JSON bundle package parsed. version=\(packageVersion), files=\(files.count), forceReload=\(forceReload)")
      if packageVersion.isEmpty || files.isEmpty {
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "Package has no files",
          packageType: "json"
        )
        return
      }

      guard let rootDir = self.webBundleRootDirectory() else { return }
      let stagingDir = rootDir.appendingPathComponent(
        "staging_\(Int(Date().timeIntervalSince1970))", isDirectory: true)

      do {
        if FileManager.default.fileExists(atPath: stagingDir.path) {
          try FileManager.default.removeItem(at: stagingDir)
        }
        try FileManager.default.createDirectory(
          at: stagingDir, withIntermediateDirectories: true, attributes: nil)
      } catch {
        print("⚠️ iOS bundle staging setup failed:", error.localizedDescription)
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "Staging setup failed",
          packageType: "json"
        )
        return
      }

      if !self.writeWebBundleFiles(stagingDir: stagingDir, files: files) {
        try? FileManager.default.removeItem(at: stagingDir)
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "Failed writing package files",
          packageType: "json"
        )
        return
      }

      let missingAfterWrite = self.missingRequiredBundleFiles(in: stagingDir)
      print("📦 iOS staged bundle ready. version=\(packageVersion), missingRequired=\(missingAfterWrite)")

      self.activateStagingBundle(
        stagingDir: stagingDir,
        packageVersion: packageVersion,
        forceReload: forceReload,
        deleteList: packageObj["delete"] as? [Any]
      )
    }.resume()
  }

  private func downloadAndApplyWebBundleZipPackage(
    packageUrl: String,
    bundleVersion: String,
    checksumSha256: String,
    forceReload: Bool
  ) {
    guard let url = URL(string: packageUrl) else { return }

    URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
      guard let self = self else { return }
      if let error = error {
        print("⚠️ iOS ZIP bundle download failed:", error.localizedDescription)
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "ZIP download failed: \(error.localizedDescription)",
          packageType: "zip"
        )
        return
      }
      guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode),
        let data = data, !data.isEmpty else {
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "Invalid ZIP response",
          packageType: "zip"
        )
        return
      }

      if !checksumSha256.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
        let hash = self.sha256Hex(data)
        if hash.caseInsensitiveCompare(checksumSha256) != .orderedSame {
          print("⚠️ iOS ZIP bundle checksum mismatch")
          self.emitWebBundleInstallFailed(
            version: bundleVersion,
            reason: "ZIP checksum mismatch",
            packageType: "zip"
          )
          return
        }
      }

      guard let rootDir = self.webBundleRootDirectory() else { return }
      let stagingDir = rootDir.appendingPathComponent(
        "staging_\(Int(Date().timeIntervalSince1970))", isDirectory: true)
      let extractedDir = rootDir.appendingPathComponent(
        "unzipped_\(Int(Date().timeIntervalSince1970))", isDirectory: true)
      let tempZip = rootDir.appendingPathComponent(
        "bundle_\(Int(Date().timeIntervalSince1970)).zip",
        isDirectory: false
      )

      do {
        if FileManager.default.fileExists(atPath: stagingDir.path) {
          try FileManager.default.removeItem(at: stagingDir)
        }
        if FileManager.default.fileExists(atPath: extractedDir.path) {
          try FileManager.default.removeItem(at: extractedDir)
        }
        try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true)
        try FileManager.default.createDirectory(at: extractedDir, withIntermediateDirectories: true)
      } catch {
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "Failed creating ZIP staging directories",
          packageType: "zip"
        )
        return
      }

      guard self.seedStagingBundle(stagingDir) else {
        try? FileManager.default.removeItem(at: stagingDir)
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "Failed seeding staging bundle",
          packageType: "zip"
        )
        return
      }

      do {
        try data.write(to: tempZip, options: .atomic)
      } catch {
        try? FileManager.default.removeItem(at: stagingDir)
        self.emitWebBundleInstallFailed(
          version: bundleVersion,
          reason: "Failed writing ZIP package to disk",
          packageType: "zip"
        )
        return
      }

      try? FileManager.default.removeItem(at: tempZip)
      try? FileManager.default.removeItem(at: stagingDir)
      try? FileManager.default.removeItem(at: extractedDir)
      print("⚠️ iOS ZIP bundle apply is unavailable in this build (missing unzip support).")
      self.emitWebBundleInstallFailed(
        version: bundleVersion,
        reason: "ZIP apply unavailable in this build (missing unzip support)",
        packageType: "zip"
      )
      return
    }.resume()
  }
  // MARK: - WKScriptMessageHandler

    private func checkForAppUpdateIfNeeded(force: Bool = false) {
      if shouldIgnoreRemoteWebBundleUpdates {
        print("📦 iOS update check skipped: bundled-assets test mode is active")
        return
      }
      if hasCheckedAppUpdateThisSession && !force {
        return
      }
      hasCheckedAppUpdateThisSession = true

      guard !apiBase.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
        return
      }

      let defaults = UserDefaults.standard
      let now = Date().timeIntervalSince1970
      let lastCheckAt = defaults.double(forKey: appUpdateCheckAtKey)
      if !force && (now - lastCheckAt) < appUpdateCheckInterval {
        return
      }
      defaults.set(now, forKey: appUpdateCheckAtKey)

      let currentVersion =
        (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0"
      let currentBundleVersion = getCurrentWebBundleVersion()
      var components = URLComponents(string: "\(apiBase)/api/mobile-app-update")
      components?.queryItems = [
        URLQueryItem(name: "platform", value: "ios"),
        URLQueryItem(name: "version", value: currentVersion),
        URLQueryItem(name: "bundle_version", value: currentBundleVersion)
      ]

      guard let rawUrl = components?.url else { return }
      let url = normalizeLocalhostURL(rawUrl)

      URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
        guard let self = self else { return }
        if let error = error {
          print("⚠️ iOS update check failed:", error.localizedDescription)
          return
        }

        guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode),
              let data = data else {
          return
        }

        guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
          return
        }

        let status = obj["status"] as? Int ?? 0
        if status != 1 {
          return
        }

        let updateAvailable = obj["update_available"] as? Bool ?? false
        if !updateAvailable {
          return
        }

        let latestVersion = (obj["latest_version"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
        let appStoreUrl = (obj["app_store_url"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
        let forceUpdate = obj["force_update"] as? Bool ?? false
        let title = (obj["title"] as? String ?? "Update available").trimmingCharacters(in: .whitespacesAndNewlines)
        let message = (obj["message"] as? String ?? "A new version is available in the App Store.").trimmingCharacters(in: .whitespacesAndNewlines)

        if appStoreUrl.isEmpty {
          return
        }

        let lastPromptedVersion = defaults.string(forKey: self.appUpdatePromptKey) ?? ""
        if !forceUpdate && !latestVersion.isEmpty && lastPromptedVersion == latestVersion {
          return
        }

        DispatchQueue.main.async {
          self.showIosAppUpdateAlert(
            title: title,
            message: message,
            appStoreUrl: appStoreUrl,
            latestVersion: latestVersion,
            forceUpdate: forceUpdate
          )
        }
      }.resume()
    }

    private func showIosAppUpdateAlert(
      title: String,
      message: String,
      appStoreUrl: String,
      latestVersion: String,
      forceUpdate: Bool
    ) {
      let defaults = UserDefaults.standard
      let ac = UIAlertController(title: title, message: message, preferredStyle: .alert)

      let updateAction = UIAlertAction(title: "Update", style: .default) { _ in
        if !latestVersion.isEmpty {
          defaults.set(latestVersion, forKey: self.appUpdatePromptKey)
        }
        if let url = URL(string: appStoreUrl) {
          UIApplication.shared.open(url)
        }
      }
      ac.addAction(updateAction)

      if !forceUpdate {
        ac.addAction(UIAlertAction(title: "Later", style: .cancel) { _ in
          if !latestVersion.isEmpty {
            defaults.set(latestVersion, forKey: self.appUpdatePromptKey)
          }
        })
      }

      present(ac)
    }

  func userContentController(
    _ userContentController: WKUserContentController, didReceive message: WKScriptMessage
  ) {
    switch message.name {
    // Mirror JS console to Xcode console (optional)
        case "openPaymentBrowser":
            guard let body = message.body as? [String: Any],
                  let urlString = body["url"] as? String,
                  let url = URL(string: urlString),
                  let pollUrl = body["pollUrl"] as? String,
                  let invoiceId = body["invoiceId"] as? Int else {
                print("⚠️ openPaymentBrowser: invalid payload")
                return
            }
        self.paymentPollUrl = pollUrl
           self.paymentInvoiceId = invoiceId
        print("⚠️ safari loaded: \(url)")
            // Open SafariViewController
        DispatchQueue.main.async {
            let vc = SFSafariViewController(url: url)
            vc.delegate = self               // make sure delegate is set
            self.safariVC = vc                // retain strong reference
            self.present(vc)
        }


    
    case "console":
      if let body = message.body as? [String: Any],
        let level = body["level"] as? String,
        let args = body["args"] as? [String]
      {
        print("JS[\(level)]: \(args.joined(separator: " "))")
      }

    // Unified bridge: accept both "native" and "api"
    case "native", "api":
      guard let obj = message.body as? [String: Any] else { return }

      // 1) Logging
      if let action = obj["action"] as? String, action == "log" {
        let msg = obj["message"] as? String ?? ""
        print("📣 JS Log:", msg)
        return
      }

      // 2) Example native util: getDeviceInfo -> event 'native:getDeviceInfo'
      if let action = obj["action"] as? String, action == "getDeviceInfo" {
        let info: [String: Any] = [
          "platform": "iOS",
          "systemVersion": UIDevice.current.systemVersion,
          "model": UIDevice.current.model,
        ]
        dispatchEvent("native:getDeviceInfo", detail: info)
        return
      }
      if let action = obj["action"] as? String, action == "scanCode" {
        let field = obj["field"] as? String ?? ""
        let promptText = obj["prompt"] as? String ?? "Scan code"
        startBarcodeScan(field: field, promptText: promptText, dispatchEvent: self.dispatchEvent)
        return
      }
        if let action = obj["action"] as? String, action == "saveBase64File" {
             handleSaveBase64File(obj, dispatchEvent: self.dispatchEvent)
             return
           }
        if let action = obj["action"] as? String, action == "sendNotification" {
            let title = obj["title"] as? String ?? "Easiwrap"
                    let message = obj["message"] as? String ?? "You have a new notification"
                    print("📨 JS requested to show notification: \(title) - \(message)")
                    NotificationFetcher.shared.showNotification(title: title, message: message)
                    return
           }
        if let action = obj["action"] as? String, action == "installWebBundleUpdate" {
            if shouldIgnoreRemoteWebBundleUpdates {
                print("📦 iOS web bundle install ignored: bundled-assets test mode is active")
                return
            }
            let packageUrl = (obj["packageUrl"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
            let bundleVersion = (obj["bundleVersion"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
            let checksumSha256 = (obj["checksumSha256"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
            let packageType = (obj["packageType"] as? String ?? "zip").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
            let forceReload = obj["forceReload"] as? Bool ?? false

            guard !packageUrl.isEmpty, !bundleVersion.isEmpty else {
                emitWebBundleInstallFailed(
                  version: bundleVersion,
                  reason: "Missing package URL or bundle version",
                  packageType: packageType.isEmpty ? "zip" : packageType
                )
                return
            }

            emitWebBundleInstallStarted(
              version: bundleVersion,
              packageType: packageType.isEmpty ? "zip" : packageType
            )

            if packageType == "zip" {
                downloadAndApplyWebBundleZipPackage(
                    packageUrl: packageUrl,
                    bundleVersion: bundleVersion,
                    checksumSha256: checksumSha256,
                    forceReload: forceReload
                )
            } else {
                downloadAndApplyWebBundlePackage(
                    packageUrl: packageUrl,
                    bundleVersion: bundleVersion,
                    checksumSha256: checksumSha256,
                    forceReload: forceReload
                )
            }
            return
        }
        if let action = obj["action"] as? String, action == "registerDeviceToken" {
            func parseString(_ any: Any?) -> String? {
                if let str = any as? String, !str.isEmpty { return str }
                if let num = any as? NSNumber { return num.stringValue }
                return nil
            }

            let userId = parseString(obj["user"])
            let institutionUserId = parseString(obj["institution_user"])
            let apiToken = parseString(obj["api_token"])

            guard let userId = userId, let institutionUserId = institutionUserId else {
                print("⚠️ Missing user or institution_user for registerDeviceToken")
                return
            }

            let defaults = UserDefaults.standard
            defaults.set(userId, forKey: "user_id")
            defaults.set(institutionUserId, forKey: "institution_user_id")
            if let apiToken = apiToken, !apiToken.isEmpty {
                defaults.set(apiToken, forKey: "api_token")
            }

            // Try both keys — prefer confirmed token
            let token = defaults.string(forKey: "apns_token") ??
                        defaults.string(forKey: "pending_apns_token")

            guard let token = token else {
                print("⚠️ No APNs token found yet — requesting remote notification registration.")
                DispatchQueue.main.async {
                    UIApplication.shared.registerForRemoteNotifications()
                }
                return
            }

            let lastSent = defaults.string(forKey: "last_sent_token")

            // ✅ Only send if new or not previously confirmed
            if token != lastSent {
                print("📲 ABC Registering token for user \(userId) / institution_user \(institutionUserId) → \(token.prefix(12))...")

                if let appDelegate = sharedAppDelegate {
                    print("✅ Found global shared AppDelegate instance.")
                    appDelegate.registerDeviceTokenToServer(token) { success in
                        if success {
                            defaults.set(token, forKey: "apns_token")
                            defaults.set(token, forKey: "last_sent_token")
                            defaults.removeObject(forKey: "pending_apns_token")
                            print("✅ Token successfully confirmed and stored.")
                        } else {
                            print("⚠️ Server registration failed, keeping token as pending.")
                        }
                    }
                } else {
                    print("❌ Could not access sharedAppDelegate — skipping server registration.")
                }
            } else {
                print("✅ Token already registered → skipping duplicate server call")
            }
        }
        if let action = obj["action"] as? String, action == "startNotificationPolling" {

            // ✅ Accept both String & Number for user and institution_user
            func parseID(_ any: Any?) -> String? {
                if let str = any as? String, !str.isEmpty { return str }
                if let num = any as? NSNumber { return num.stringValue }
                return nil
            }

            let userId = parseID(obj["user"])
            let institutionUserId = parseID(obj["institution_user"])

            if let userId = userId, let institutionUserId = institutionUserId {
                if let apiToken = parseID(obj["api_token"]) {
                    UserDefaults.standard.set(apiToken, forKey: "api_token")
                }
                print("🔔 JS requested polling for user \(userId) / institution_user \(institutionUserId)")

                // Save both values to UserDefaults
                UserDefaults.standard.set(userId, forKey: "user_id")
                UserDefaults.standard.set(institutionUserId, forKey: "institution_user_id")

                // ✅ Immediate first check
                NotificationFetcher.shared.checkForNotifications {
                    print("✅ Initial notification check done")
                }

                // ✅ Schedule periodic background fetch
                DispatchQueue.main.async {
                    if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
                        appDelegate.scheduleNextFetch()
                        print("⏳ Background fetch task scheduled from JS bridge.")
                    }
                }

                // ✅ Optional foreground polling while app active
                Timer.scheduledTimer(withTimeInterval: 600, repeats: true) { _ in
                    NotificationFetcher.shared.checkForNotifications {
                        print("⏰ Foreground periodic notification check done")
                    }
                }

            } else {
                print("⚠️ Missing IDs in startNotificationPolling payload:", obj)
            }

            return
        }
      // 3) Networking bridge (Promise-style)
      // Supported payload: { id, path, method, headers, body, json, withCredentials }
      let callId = (obj["id"] as? String) ?? UUID().uuidString
      let method = (obj["method"] as? String ?? "GET").uppercased()
      let path = (obj["path"] as? String) ?? "/"
      let headers = (obj["headers"] as? [String: String]) ?? [:]
      let bodyStr = obj["body"] as? String
      let jsonObj = obj["json"]
      let withCredentials = (obj["withCredentials"] as? Bool) ?? true

      // Build URL from APP_CONFIG.API_BASE_URL if path isn't absolute
      guard let url = buildURL(path: path) else {
        dispatchEvent(
          "native:api", detail: ["id": callId, "status": 400, "error": "Bad URL", "body": ""])
        return
      }

      var req = URLRequest(
        url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 60)
      req.httpMethod = method
      headers.forEach { req.setValue($1, forHTTPHeaderField: $0) }
      print("➡️ \(req.url?.absoluteString ?? "nil")")
      if let jsonObj = jsonObj,
        JSONSerialization.isValidJSONObject(jsonObj),
        let data = try? JSONSerialization.data(withJSONObject: jsonObj)
      {
        if req.value(forHTTPHeaderField: "Content-Type") == nil {
          req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        }
        req.httpBody = data
      } else if let str = bodyStr {
        req.httpBody = str.data(using: .utf8)
        if req.value(forHTTPHeaderField: "Content-Type") == nil {
          req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        }
      }

      // Cookies/session support if requested
      let cfg = URLSessionConfiguration.default
      // Avoid PAC/proxy resolution issues from simulator/device environments.
      cfg.connectionProxyDictionary = [:]
      if withCredentials {
        cfg.httpCookieAcceptPolicy = .always
        cfg.httpShouldSetCookies = true
        cfg.httpCookieStorage = HTTPCookieStorage.shared
      }
      let session = URLSession(configuration: cfg)

      session.dataTask(with: req) { [weak self] data, resp, err in
        let status = (resp as? HTTPURLResponse)?.statusCode ?? 0
        var payload: [String: Any] = ["id": callId, "status": status]
        let responseBytes = data?.count ?? 0
        let isReportImagePath = path.contains("/api/get-report-image")
        payload["path"] = path
        payload["bytes"] = responseBytes
        print("🌐 native:api response path=\(path) status=\(status) bytes=\(responseBytes)")

        if let err = err {
          payload["error"] = err.localizedDescription
          if let nsErr = err as NSError? {
            payload["error_code"] = nsErr.code
            payload["error_domain"] = nsErr.domain
          }
          payload["body"] = ""
          self?.dispatchEvent("native:api", detail: payload)
          return
        }

        if let headerFields = (resp as? HTTPURLResponse)?.allHeaderFields {
          let headerMap = headerFields.reduce(into: [String: String]()) { partialResult, item in
            partialResult[String(describing: item.key)] = String(describing: item.value)
          }
          payload["headers"] = headerMap
        } else {
          payload["headers"] = [String: String]()
        }
        if let d = data, !d.isEmpty {
          if isReportImagePath {
            // Large base64 image payloads are safer to forward as text for JS-side parsing.
            payload["body"] = String(data: d, encoding: .utf8) ?? ""
          } else if let json = try? JSONSerialization.jsonObject(with: d) {
            payload["json"] = json
          } else if let txt = String(data: d, encoding: .utf8) {
            payload["body"] = txt
          } else {
            payload["body"] = ""
          }
        } else {
          payload["body"] = ""
        }

        self?.dispatchEvent("native:api", detail: payload)
      }.resume()

    // (Optional) View loader like your older project
    case "view":
      guard let body = message.body as? [String: Any],
        let action = body["action"] as? String, action == "loadView",
        let viewName = body["view"] as? String,
        isSafeViewName(viewName)
      else { return }

      let htmlPath = "www/pages/\(viewName).html"
      let jsPath = "www/pages/\(viewName).js"

      let htmlB64 = loadFileB64(path: htmlPath)
      let jsB64 = loadFileB64(path: jsPath)

      if let htmlB64 = htmlB64 {
        // Convert any data passed from JS into JSON string
        let dataJSON: String
        if let dataObj = body["data"] as? [String: Any],
          let dataData = try? JSONSerialization.data(withJSONObject: dataObj),
          let jsonStr = String(data: dataData, encoding: .utf8)
        {
          dataJSON = sanitizeForJS(jsonStr)
        } else {
          dataJSON = "null"
        }

          let js = """
          (function(){
            function b64ToUtf8(b64){ try { return decodeURIComponent(escape(atob(b64))); } catch(_) { return atob(b64); } }
            function syncLegacyUserGlobal(){
              try {
                var raw = localStorage.getItem('user');
                if (raw) {
                  var parsed = JSON.parse(raw);
                  if (parsed && typeof parsed === 'object') {
                    window.user = parsed;
                  }
                }
              } catch(_) {}
              try {
                if (window.user && typeof window.user === 'object') {
                  globalThis.user = window.user;
                }
              } catch(_) {}
            }

            var html = b64ToUtf8('\(htmlB64)');
            var jsb64 = \(jsB64 != nil ? "'\(jsB64!)'" : "null");
            var main = document.querySelector('#mainContent');

            // 🔹 1. If a previous view was active, destroy it
            if (window.currentView && window.viewControllers && typeof window.viewControllers[window.currentView]?.destroy === 'function') {
                try { window.viewControllers[window.currentView].destroy(); } catch(e){ console.warn('destroy error', e); }
            }

            // 🔹 2. Set new content
            main.innerHTML = html;

            // 🔹 3. Load and init new JS
            if (jsb64) { 
              try { 
                var previousInit = window.init;
                (1,eval)( b64ToUtf8(jsb64) ); 
                syncLegacyUserGlobal();
                var didInit = false;
                if (window.viewControllers && window.viewControllers['\(viewName)'] && typeof window.viewControllers['\(viewName)'].init === 'function') {
                    window.viewControllers['\(viewName)'].init(\(dataJSON));
                    didInit = true;
                } else if (typeof window.init === 'function' && window.init !== previousInit) {
                    // Backward compatibility for legacy view scripts that expose a global init().
                    window.init(\(dataJSON));
                    didInit = true;
                }
                if (!didInit) {
                    console.warn('No init handler for view: \(viewName)');
                }
                window.currentView = '\(viewName)'; // 🔹 remember active view
              } catch(e){
                var errMsg = '';
                try { errMsg = (e && (e.stack || e.message)) ? (e.stack || e.message) : JSON.stringify(e); } catch(_) {}
                console.error('view js eval error', errMsg || e, e);
                window.dispatchEvent(new CustomEvent('native:viewError', { detail: { view: '\(viewName)', error: errMsg || String(e) } }));
              }
            }

            // 🔹 4. Notify listeners that view changed
            window.dispatchEvent(new CustomEvent('native:viewLoaded', { detail: { view: '\(viewName)', data: \(dataJSON) } }));
          })();
          """
        self.webView?.evaluateJavaScript(
          js,
          completionHandler: { _, err in
            if let err = err { print("inject error:", err.localizedDescription) }
          })
      } else {
        self.webView?.evaluateJavaScript(
          "window.dispatchEvent(new CustomEvent('native:viewError', { detail: { view: '\(viewName)' } }));",
          completionHandler: nil
        )
      }

    default:
      break
    }
  }
    func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
            // 3) Called when user taps “Done”
        print("⚠️ safari done:")
        let pollUrl = paymentPollUrl ?? ""
           let invoiceId = paymentInvoiceId ?? 0           // let js = "window.dispatchEvent(new CustomEvent('paymentBrowserClosed', { detail: { } }));"
        let js = "window.dispatchEvent(new CustomEvent('paymentBrowserClosed', { detail: { pollUrl: '\(pollUrl)', invoiceId: \(invoiceId) } }));"
        webView?.evaluateJavaScript(js) { result, error in
          if let err = error {
            print("❌ JS dispatch error: \(err.localizedDescription)")
          } else {
            print("✅ JS dispatched event result: \(String(describing: result))")
          }
        }
        }
   
    // MARK: - Navigation / external links
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        webViewHasFinishedLoad = true
        syncCurrentBundleVersionToPage(webView)
        checkForAppUpdateIfNeeded(force: false)
        flushPendingNotificationOpenIfReady()
    }

    func webView(
      _ webView: WKWebView,
      decidePolicyFor navigationAction: WKNavigationAction,
      decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
    ) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.cancel)
            return
        }

        let scheme = url.scheme?.lowercased() ?? ""

        // ✅ Allow everything safe: internal http/https, local, and sandbox pages
        if ["http", "https", "file", "blob", "data", "about"].contains(scheme) {
            decisionHandler(.allow)
            return
        }

        // ✅ Explicitly allow subframe (iframe) navigations
        if navigationAction.targetFrame == nil || navigationAction.targetFrame?.isMainFrame == false {
            decisionHandler(.allow)
            return
        }

        // ✅ Allow phone, email, sms
        if ["tel", "mailto", "sms"].contains(scheme) {
            UIApplication.shared.open(url)
            decisionHandler(.cancel)
            return
        }

        // 🚫 Block anything unsafe
        print("⚠️ Blocked navigation for unknown scheme:", scheme, url.absoluteString)
        decisionHandler(.cancel)
    }
  // MARK: - JS alert/confirm/prompt (like your prior project)
  func webView(
    _ webView: WKWebView,
    runJavaScriptAlertPanelWithMessage message: String,
    initiatedByFrame frame: WKFrameInfo,
    completionHandler: @escaping () -> Void
  ) {
    let ac = UIAlertController(title: nil, message: message, preferredStyle: .alert)
    ac.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler() })
    if !present(ac, from: webView) {
      completionHandler()
    }
  }

  func webView(
    _ webView: WKWebView,
    runJavaScriptConfirmPanelWithMessage message: String,
    initiatedByFrame frame: WKFrameInfo,
    completionHandler: @escaping (Bool) -> Void
  ) {
    let ac = UIAlertController(title: nil, message: message, preferredStyle: .alert)
    ac.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) })
    ac.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) })
    if !present(ac, from: webView) {
      completionHandler(false)
    }
  }

  func webView(
    _ webView: WKWebView,
    runJavaScriptTextInputPanelWithPrompt prompt: String,
    defaultText: String?,
    initiatedByFrame frame: WKFrameInfo,
    completionHandler: @escaping (String?) -> Void
  ) {
    let ac = UIAlertController(title: nil, message: prompt, preferredStyle: .alert)
    ac.addTextField { $0.text = defaultText }
    ac.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(nil) })
    ac.addAction(
      UIAlertAction(title: "OK", style: .default) { _ in
        completionHandler(ac.textFields?.first?.text)
      })
    if !present(ac, from: webView) {
      completionHandler(defaultText)
    }
  }

  private func present(_ ac: UIAlertController, from webView: WKWebView) -> Bool {
    guard
      let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first,
      let root = scene.keyWindow?.rootViewController
    else {
      if let fallbackRoot = webView.window?.rootViewController {
        var top = fallbackRoot
        while let presented = top.presentedViewController { top = presented }
        guard top.presentedViewController == nil else { return false }
        top.present(ac, animated: true, completion: nil)
        return true
      }
      return false
    }
    var top = root
    while let presented = top.presentedViewController { top = presented }
    guard top.presentedViewController == nil else { return false }
    top.present(ac, animated: true, completion: nil)
    return true
  }
}
extension Coordinator: UIAdaptivePresentationControllerDelegate {
    func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
        let js = "window.dispatchEvent(new CustomEvent('paymentBrowserClosed', { detail: { } }));"
        webView?.evaluateJavaScript(js, completionHandler: nil)
        }
}
