// swiftlint:disable identifier_name
// swiftlint:disable type_body_length
import Foundation
import Capacitor
import CoreBluetooth
import ESPProvision

let CONNECTION_TIMEOUT: Double = 22
let DEFAULT_TIMEOUT: Double = 5

@objc(BluetoothLe)
public class BluetoothLe: CAPPlugin {
    typealias BleDevice = [String: Any]
    typealias BleService = [String: Any]
    typealias BleCharacteristic = [String: Any]
    typealias BleDescriptor = [String: Any]
    private var deviceMap = [String: Device]()
    private var timeoutMap = [String: DispatchWorkItem]()
    private var displayStrings = [String: String]()
    private var connectedDevice: ESPDevice?
    private var scannedDevices: [ESPDevice]?
    private var scannedNetworks: [ESPWifiNetwork]?
    private var scanTimeout: DispatchTime?

    override public func load() {
        self.displayStrings = self.getDisplayStrings()
    }

    @objc func initialize(_ call: CAPPluginCall) {
        call.resolve()
    }

    @objc func isEnabled(_ call: CAPPluginCall) {
        call.resolve(["value": true])
    }

    @objc func enable(_ call: CAPPluginCall) {
        call.unavailable("enable is not available on iOS.")
    }

    @objc func disable(_ call: CAPPluginCall) {
        call.unavailable("disable is not available on iOS.")
    }

    @objc func startEnabledNotifications(_ call: CAPPluginCall) {
      call.unavailable("disable is not available on iOS.")
    }

    @objc func stopEnabledNotifications(_ call: CAPPluginCall) {
      call.unavailable("disable is not available on iOS.")
    }

    @objc func isLocationEnabled(_ call: CAPPluginCall) {
        call.unavailable("isLocationEnabled is not available on iOS.")
    }

    @objc func openLocationSettings(_ call: CAPPluginCall) {
        call.unavailable("openLocationSettings is not available on iOS.")
    }

    @objc func openBluetoothSettings(_ call: CAPPluginCall) {
        call.unavailable("openBluetoothSettings is not available on iOS.")
    }

    @objc func openAppSettings(_ call: CAPPluginCall) {
        guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else {
            call.reject("Cannot open app settings.")
            return
        }

        DispatchQueue.main.async {
            if UIApplication.shared.canOpenURL(settingsUrl) {
                UIApplication.shared.open(settingsUrl, completionHandler: { (success) in
                    call.resolve([
                        "value": success
                    ])
                })
            } else {
                call.reject("Cannot open app settings.")
            }
        }
    }

    @objc func scanNetworks(_ call: CAPPluginCall) {
      self.connectedDevice?.scanWifiList(completionHandler: { networks, error in
        if ( networks != nil ) {
          call.resolve(["networks": networks?.map({ network in
            return ["ssid": network.ssid, "channel": network.channel, "rssi": network.rssi] as [String : Any]
          }) ?? []])
        }
        else if ( error != nil ) {
          call.reject(error?.description ?? "Error during scanning wifi list")
        }
      })
    }

    @objc func provision(_ call: CAPPluginCall) {
      guard let ssid = call.getString("ssid") else {
        call.reject("ssid cannot be nill")
        return
      }
      guard let password = call.getString("password") else {
        call.reject("password cannot be nill")
        return
      }
      if (connectedDevice != nil) {
        let key = "provisioning|\(connectedDevice!.name)"
        connectedDevice!.provision(ssid: ssid, passPhrase: password) { status in
          self.timeoutMap[key]?.cancel()
          self.timeoutMap[key] = nil
            switch status {
            case .success:
              call.resolve()
            case let .failure(error):
                switch error {
                case .configurationError:
                  call.reject("Failed to apply network configuration to device")
                case .sessionError:
                  call.reject("Session is not established")
                case .wifiStatusDisconnected:
                  call.reject(error.description)
                default:
                  call.reject(error.description)
                }
            case .configApplied:
              log("WifiConfigApplied")
            }
        }
        self.setConnectionTimeout(key, "Connection timeout.", connectedDevice!, CONNECTION_TIMEOUT, call)
      }
    }

    @objc func setDisplayStrings(_ call: CAPPluginCall) {
        for key in ["noDeviceFound", "availableDevices", "scanning", "cancel"] {
            if call.getString(key) != nil {
                self.displayStrings[key] = call.getString(key)
            }
        }
        call.resolve()
    }

    @objc func requestDevice(_ call: CAPPluginCall) {
          call.unavailable("openBluetoothSettings is not available on iOS.")
    }

    @objc func requestLEScan(_ call: CAPPluginCall) {
      let namePrefix = call.getString("namePrefix")
        
        // Reset the scanned device
        self.scannedDevices = nil
      
      self.scanTimeout = DispatchTime.now() + 28;
      ESPProvisionManager.shared.searchESPDevices(devicePrefix: namePrefix ?? "", transport: .ble, completionHandler: handleDevicesFound)
      call.resolve()
    }
  
  private func handleDevicesFound(devices : [ESPDevice]?, error: ESPDeviceCSSError?) {
    if ( devices != nil ) {
      devices?.forEach({ device in
        if self.scannedDevices?.first(where: { espDevice in
          espDevice.name == device.name
        }) == nil {
          self.notifyListeners("onScanResult", data: self.mapESPDevice(device))
        }
      })
      self.scannedDevices = devices
    }
    if ( error != nil ) {
      log(error!)
    }
    if (self.scanTimeout != nil && self.scanTimeout! > DispatchTime.now()) {
      ESPProvisionManager.shared.refreshDeviceList(completionHandler: handleDevicesFound)
    }
  }

    @objc func stopLEScan(_ call: CAPPluginCall) {
        self.scanTimeout = DispatchTime.now()
        ESPProvisionManager.shared.stopESPDevicesSearch()
        call.resolve()
    }

    @objc func getDevices(_ call: CAPPluginCall) {
        let devices = self.scannedDevices?.map({ espDevice in
          return self.mapESPDevice(espDevice)
        }) ?? []
        call.resolve(["devices": devices])
    }

    @objc func getConnectedDevices(_ call: CAPPluginCall) {
        var devices : [[String:Any]] = []
      if (connectedDevice != nil) { devices.append(self.mapESPDevice(connectedDevice!)) }
        call.resolve(["devices": devices])
    }

    @objc func connect(_ call: CAPPluginCall) {
      guard let device = scannedDevices?.first(where: { espDevice in
        espDevice.name == call.getString("deviceId")
      }) else {
        call.reject("Device not found")
        return
      }
      let key = "connect|\(device.name)"
      
      device.connect(delegate: self) { sessionStatus in
        self.timeoutMap[key]?.cancel();
        self.timeoutMap[key] = nil;
        switch sessionStatus {
          case .connected:
            self.connectedDevice = device
            call.resolve()
          case let .failedToConnect(error):
            call.reject(error.description)
          default:
            call.reject("Device disconnected")
        }
      }
      self.setConnectionTimeout(key, "Connection timeout.", device, CONNECTION_TIMEOUT, call)
    }

    @objc func createBond(_ call: CAPPluginCall) {
        call.unavailable("createBond is not available on iOS.")
    }

    @objc func isBonded(_ call: CAPPluginCall) {
        call.unavailable("isBonded is not available on iOS.")
    }

    @objc func disconnect(_ call: CAPPluginCall) {
      connectedDevice?.disconnect()
      call.resolve()
    }

    @objc func getServices(_ call: CAPPluginCall) {
      call.unavailable("isBonded is not available on iOS.")
    }
  
  private func mapESPDevice(_ espDevice: ESPDevice) -> [String:Any] {
    return ["device": ["deviceId": espDevice.name, "name": espDevice.advertisementData?[CBAdvertisementDataLocalNameKey]], "localName": espDevice.name, "serviceUUIDs": (espDevice.advertisementData![CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []).map({(uuid) -> String in
      return cbuuidToString(uuid)
    }), "isConnectable": espDevice.advertisementData?[CBAdvertisementDataIsConnectable] ?? -1]
  }

    private func getProperties(_ characteristic: CBCharacteristic) -> [String: Bool] {
        return [
            "broadcast": characteristic.properties.contains(CBCharacteristicProperties.broadcast),
            "read": characteristic.properties.contains(CBCharacteristicProperties.read),
            "writeWithoutResponse": characteristic.properties.contains(CBCharacteristicProperties.writeWithoutResponse),
            "write": characteristic.properties.contains(CBCharacteristicProperties.write),
            "notify": characteristic.properties.contains(CBCharacteristicProperties.notify),
            "indicate": characteristic.properties.contains(CBCharacteristicProperties.indicate),
            "authenticatedSignedWrites": characteristic.properties.contains(CBCharacteristicProperties.authenticatedSignedWrites),
            "extendedProperties": characteristic.properties.contains(CBCharacteristicProperties.extendedProperties),
            "notifyEncryptionRequired": characteristic.properties.contains(CBCharacteristicProperties.notifyEncryptionRequired),
            "indicateEncryptionRequired": characteristic.properties.contains(CBCharacteristicProperties.indicateEncryptionRequired)
        ]
    }
    
    @objc func discoverServices(_ call: CAPPluginCall) {
      call.unavailable("isBonded is not available on iOS.")
    }

    @objc func readRssi(_ call: CAPPluginCall) {
      call.unavailable("isBonded is not available on iOS.")
    }

    @objc func read(_ call: CAPPluginCall) {
      call.unavailable("isBonded is not available on iOS.")
    }

    @objc func write(_ call: CAPPluginCall) {
      call.unavailable("isBonded is not available on iOS.")
    }

    @objc func writeWithoutResponse(_ call: CAPPluginCall) {
      call.unavailable("isBonded is not available on iOS.")
    }

    @objc func readDescriptor(_ call: CAPPluginCall) {
      call.unavailable("isBonded is not available on iOS.")
    }

    @objc func writeDescriptor(_ call: CAPPluginCall) {
      call.unavailable("isBonded is not available on iOS.")
    }

    @objc func startNotifications(_ call: CAPPluginCall) {
      call.unavailable("isBonded is not available on iOS.")
    }

    @objc func stopNotifications(_ call: CAPPluginCall) {
      call.unavailable("isBonded is not available on iOS.")
    }

    private func getDisplayStrings() -> [String: String] {
        let configDisplayStrings = getConfigValue("displayStrings") as? [String: String] ?? [String: String]()
        var displayStrings = [String: String]()
        displayStrings["noDeviceFound"] = configDisplayStrings["noDeviceFound"] ?? "No device found"
        displayStrings["availableDevices"] = configDisplayStrings["availableDevices"] ?? "Available devices"
        displayStrings["scanning"] = configDisplayStrings["scanning"] ?? "Scanning..."
        displayStrings["cancel"] = configDisplayStrings["cancel"] ?? "Cancel"
        return displayStrings
    }

    private func getServiceUUIDs(_ call: CAPPluginCall) -> [CBUUID] {
        let services = call.getArray("services", String.self) ?? []
        let serviceUUIDs = services.map({(service) -> CBUUID in
            return CBUUID(string: service)
        })
        return serviceUUIDs
    }

    private func getDevice(_ call: CAPPluginCall, checkConnection: Bool = true) -> Device? {
        guard let deviceId = call.getString("deviceId") else {
            call.reject("deviceId required.")
            return nil
        }
        guard let device = self.deviceMap[deviceId] else {
            call.reject("Device not found. Call 'requestDevice', 'requestLEScan' or 'getDevices' first.")
            return nil
        }
        if checkConnection {
            guard device.isConnected() else {
                call.reject("Not connected to device.")
                return nil
            }
        }
        return device
    }

    private func getTimeout(_ call: CAPPluginCall, defaultTimeout: Double = DEFAULT_TIMEOUT) -> Double {
        guard let timeout = call.getDouble("timeout") else {
            return defaultTimeout
        }
        return timeout / 1000
    }

    private func getCharacteristic(_ call: CAPPluginCall) -> (CBUUID, CBUUID)? {
        guard let service = call.getString("service") else {
            call.reject("Service UUID required.")
            return nil
        }
        let serviceUUID = CBUUID(string: service)

        guard let characteristic = call.getString("characteristic") else {
            call.reject("Characteristic UUID required.")
            return nil
        }
        let characteristicUUID = CBUUID(string: characteristic)
        return (serviceUUID, characteristicUUID)
    }

    private func getDescriptor(_ call: CAPPluginCall) -> (CBUUID, CBUUID, CBUUID)? {
        guard let characteristic = getCharacteristic(call) else {
            return nil
        }
        guard let descriptor = call.getString("descriptor") else {
            call.reject("Descriptor UUID required.")
            return nil
        }
        let descriptorUUID = CBUUID(string: descriptor)

        return (characteristic.0, characteristic.1, descriptorUUID)
    }

    private func getBleDevice(_ device: Device) -> BleDevice {
        var bleDevice = [
            "deviceId": device.getId()
        ]
        if device.getName() != nil {
            bleDevice["name"] = device.getName()
        }
        return bleDevice
    }

    private func getScanResult(_ device: Device, _ advertisementData: [String: Any], _ rssi: NSNumber) -> [String: Any] {
        var data = [
            "device": self.getBleDevice(device),
            "rssi": rssi,
            "txPower": advertisementData[CBAdvertisementDataTxPowerLevelKey] ?? 127,
            "uuids": (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? []).map({(uuid) -> String in
                return cbuuidToString(uuid)
            })
        ]

        let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
        if localName != nil {
            data["localName"] = localName
        }

        let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
        if manufacturerData != nil {
            data["manufacturerData"] = self.getManufacturerData(data: manufacturerData!)
        }

        let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data]
        if serviceData != nil {
            data["serviceData"] = self.getServiceData(data: serviceData!)
        }
        return data
    }

    private func getManufacturerData(data: Data) -> [String: String] {
        var company = 0
        var rest = ""
        for (index, byte) in data.enumerated() {
            if index == 0 {
                company += Int(byte)
            } else if index == 1 {
                company += Int(byte) * 256
            } else {
                rest += String(format: "%02hhx ", byte)
            }
        }
        return [String(company): rest]
    }

    private func getServiceData(data: [CBUUID: Data]) -> [String: String] {
        var result: [String: String] = [:]
        for (key, value) in data {
            result[cbuuidToString(key)] = dataToString(value)
        }
        return result
    }
  
    private func setConnectionTimeout(
        _ key: String,
        _ message: String,
        _ device: ESPDevice,
        _ connectionTimeout: Double,
        _ call: CAPPluginCall
    ) {
        let workItem = DispatchWorkItem {
            // do not call onDisconnnected, which is triggered by cancelPeripheralConnection
            device.disconnect()
            call.reject(message)
        }
        self.timeoutMap[key] = workItem
        DispatchQueue.main.asyncAfter(deadline: .now() + connectionTimeout, execute: workItem)
    }
}

extension BluetoothLe: ESPDeviceConnectionDelegate, CBCentralManagerDelegate {
  public func centralManagerDidUpdateState(_ central: CBCentralManager) {
  }
  
  public func getProofOfPossesion(forDevice: ESPDevice, completionHandler: @escaping (String) -> Void)  {
        completionHandler("abcd1234")
    }
    
  public func getUsername(forDevice: ESPDevice, completionHandler: @escaping (String?) -> Void) {
        completionHandler("")
    }
}
