/*
The MIT License (MIT)

Copyright (c) 2014 Shazron Abdullah

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
// jscs:disable maximumLineLength

const path = require('path')
const fs = require('fs')
const util = require('util')

let simctl
let bplist
let plist

function findFirstAvailableDevice (list) {
  /*
        // Example result:
        {
            name : 'iPhone 6',
            id : 'A1193D97-F5EE-468D-9DBA-786F403766E6',
            runtime : 'iOS 8.3'
        }
    */

  // the object to return
  let ret_obj = {
    name: null,
    id: null,
    runtime: null
  }

  let available_runtimes = {}

  list.runtimes.forEach(function (runtime) {
    available_runtimes[ runtime.name ] = (runtime.availability === '(available)')
  })

  Object.keys(list.devices).some(function (deviceGroup) {
    return list.devices[deviceGroup].some(function (device) {
      // deviceGroup has not been normalized, it can either be the namespaced name, or the
      // human readable name. We normalize it
      let normalizedRuntimeName = fixRuntimeName(deviceGroup)
      if (available_runtimes[normalizedRuntimeName]) {
        ret_obj = {
          name: device.name,
          id: device.udid,
          runtime: normalizedRuntimeName
        }
        return true
      }
      return false
    })
  })

  return ret_obj
}

function findRuntimesGroupByDeviceProperty (list, deviceProperty, availableOnly, options = {}) {
  /*
        // Example result:
        {
            "iPhone 6" : [ "iOS 8.2", "iOS 8.3"],
            "iPhone 6 Plus" : [ "iOS 8.2", "iOS 8.3"]
        }
    */

  let runtimes = {}
  let available_runtimes = {}

  list.runtimes.forEach(function (runtime) {
    // key value changed to "isAvailble" from "availability"
    available_runtimes[ runtime.name ] = (runtime.availability ? (runtime.availability === '(available)') : runtime.isAvailable)
  })

  Object.keys(list.devices).forEach(function (deviceGroup) {
    list.devices[deviceGroup].forEach(function (device) {
      // deviceGroup has not been normalized, it can either be the namespaced name, or the
      // human readable name. We normalize it
      let normalizedRuntimeName = fixRuntimeName(deviceGroup)

      let devicePropertyValue = device[deviceProperty]

      if (options.lowerCase) {
        devicePropertyValue = devicePropertyValue.toLowerCase()
      }
      if (!runtimes[devicePropertyValue]) {
        runtimes[devicePropertyValue] = []
      }
      if (availableOnly) {
        if (available_runtimes[normalizedRuntimeName]) {
          runtimes[devicePropertyValue].push(normalizedRuntimeName)
        }
      } else {
        runtimes[devicePropertyValue].push(normalizedRuntimeName)
      }
    })
  })

  return runtimes
}

function findAvailableRuntime (list, device_name) {
  device_name = device_name.toLowerCase()

  let all_druntimes = findRuntimesGroupByDeviceProperty(list, 'name', true, { lowerCase: true })
  let druntime = all_druntimes[ filterDeviceName(device_name) ] || all_druntimes[ device_name ]
  let runtime_found = druntime && druntime.length > 0

  if (!runtime_found) {
    console.error(util.format('No available runtimes could be found for "%s".', device_name))
    process.exit(1)
  }

  // return most modern runtime
  return druntime.sort().pop()
}

function getDeviceFromDeviceTypeId (devicetypeid) {
  /*
        // Example result:
        {
            name : 'iPhone 6',
            id : 'A1193D97-F5EE-468D-9DBA-786F403766E6',
            runtime : 'iOS 8.3'
        }
    */

  // the object to return
  let ret_obj = {
    name: null,
    id: null,
    runtime: null
  }

  let options = { 'silent': true }
  let list = simctl.list(options).json
  list = fixSimCtlList(list)

  let arr = []
  if (devicetypeid) {
    arr = devicetypeid.split(',')
  }

  // get the devicetype from --devicetypeid
  // --devicetypeid is a string in the form "devicetype, runtime_version" (optional: runtime_version)
  let devicetype = null
  if (arr.length < 1) {
    let dv = findFirstAvailableDevice(list)
    console.error(util.format('--devicetypeid was not specified, using first available device: %s.', dv.name))
    return dv
  } else {
    devicetype = arr[0].trim()
    if (arr.length > 1) {
      ret_obj.runtime = arr[1].trim()
    }
  }

  // check whether devicetype has the "com.apple.CoreSimulator.SimDeviceType." prefix, if not, add it
  let prefix = 'com.apple.CoreSimulator.SimDeviceType.'
  if (devicetype.indexOf(prefix) !== 0) {
    devicetype = prefix + devicetype
  }

  // now find the devicename from the devicetype
  let devicename_found = list.devicetypes.some(function (deviceGroup) {
    if (deviceGroup.identifier === devicetype) {
      ret_obj.name = deviceGroup.name
      return true
    }

    return false
  })

  // device name not found, exit
  if (!devicename_found) {
    console.error(util.format('Device type "%s" could not be found.', devicetype))
    process.exit(1)
  }

  // if runtime_version was not specified, we use a default. Use first available that has the device
  if (!ret_obj.runtime) {
    ret_obj.runtime = findAvailableRuntime(list, ret_obj.name)
  }

  // prepend iOS to runtime version, if necessary
  if (ret_obj.runtime.indexOf('OS') === -1) {
    ret_obj.runtime = util.format('iOS %s', ret_obj.runtime)
  }

  // now find the deviceid (by runtime and devicename)
  let deviceid_found = Object.keys(list.devices).some(function (deviceGroup) {
    // deviceGroup has not been normalized, it can either be the namespaced name, or the
    // human readable name. We normalize it
    let normalizedRuntimeName = fixRuntimeName(deviceGroup)
    // found the runtime, now find the actual device matching devicename
    if (normalizedRuntimeName === ret_obj.runtime) {
      return list.devices[deviceGroup].some(function (device) {
        if (filterDeviceName(device.name).toLowerCase() === filterDeviceName(ret_obj.name).toLowerCase()) {
          ret_obj.id = device.udid
          return true
        }
        return false
      })
    }
    return false
  })

  if (!deviceid_found) {
    console.error(
      util.format('Device id for device name "%s" and runtime "%s" could not be found, or is not available.', ret_obj.name, ret_obj.runtime)
    )
    process.exit(1)
  }

  return ret_obj
}

// Parses array of KEY=Value strings into map of strings
// If fixsymctl == true, updates variables for correct usage with simctl
function parseEnvironmentVariables (envVariables, fixsymctl) {
  envVariables = envVariables || []
  fixsymctl = typeof fixsymctl !== 'undefined' ? fixsymctl : true

  let envMap = {}
  envVariables.forEach(function (variable) {
    let envPair = variable.split('=', 2)
    if (envPair.length === 2) {
      let key = envPair[0]
      let value = envPair[1]
      if (fixsymctl) {
        key = 'SIMCTL_CHILD_' + key
      }
      envMap[ key ] = value
    }
  })
  return envMap
}

// Injects specified environt variables to the process and then runs action
// returns environment variables back to original state after action completes
function withInjectedEnvironmentVariablesToProcess (process, envVariables, action) {
  let oldVariables = Object.assign({}, process.env)

  // Inject additional environment variables to process
  for (let key in envVariables) {
    let value = envVariables[key]
    process.env[key] = value
  }

  action()

  // restore old envs
  process.env = oldVariables
}

// replace hyphens in iPad Pro name which differ in 'Device Types' and 'Devices'
function filterDeviceName (deviceName) {
  // replace hyphens in iPad Pro name which differ in 'Device Types' and 'Devices'
  if (/^iPad Pro/i.test(deviceName)) {
    return deviceName.replace(/-/g, ' ').trim()
  }
  // replace ʀ in iPhone Xʀ
  if (deviceName.indexOf('ʀ') > -1) {
    return deviceName.replace('ʀ', 'R')
  }
  return deviceName
}

function fixNameKey (array, mapping) {
  if (!array || !mapping) {
    return array
  }

  return array.map(function (elem) {
    let name = mapping[elem.name]
    if (name) {
      elem.name = name
    }
    return elem
  })
}

function fixSimCtlList (list) {
  // Xcode 9 `xcrun simctl list devicetypes` have obfuscated names for 2017 iPhones and Apple Watches.
  let deviceTypeNameMap = {
    'iPhone2017-A': 'iPhone 8',
    'iPhone2017-B': 'iPhone 8 Plus',
    'iPhone2017-C': 'iPhone X',
    'Watch2017 - 38mm': 'Apple Watch Series 3 - 38mm',
    'Watch2017 - 42mm': 'Apple Watch Series 3 - 42mm'
  }
  list.devicetypes = fixNameKey(list.devicetypes, deviceTypeNameMap)

  // `iPad Pro` in iOS 9.3 has mapped to `iPad Pro (9.7 inch)`
  // `Apple TV 1080p` has mapped to `Apple TV`
  let deviceNameMap = {
    'Apple TV 1080p': 'Apple TV',
    'iPad Pro': 'iPad Pro (9.7-inch)'
  }
  Object.keys(list.devices).forEach(function (key) {
    list.devices[key] = fixNameKey(list.devices[key], deviceNameMap)
  })

  return list
}

function fixRuntimeName (runtimeName) {
  // looking for format 'com.apple.CoreSimulator.SimRuntime.iOS-12-0'
  const pattern = /^com\.apple\.CoreSimulator\.SimRuntime\.(([a-zA-Z0-9]+)-(\S+))$/i
  const match = pattern.exec(runtimeName)

  if (match) {
    const [ , , os, version ] = match
    // all or nothing -- os, version will always have a value for match
    return `${os} ${version.replace('-', '.')}`
  }

  return runtimeName
}

let lib = {

  init: function () {
    if (!simctl) {
      simctl = require('simctl')
    }
    let output = simctl.check_prerequisites()
    if (output.code !== 0) {
      console.error(output.output)
    }

    if (!bplist) {
      bplist = require('bplist-parser')
    }

    return output.code
  },

  // jscs:disable disallowUnusedParams
  showsdks: function (args) {
    let options = { silent: true, runtimes: true }
    let list = simctl.list(options).json

    let output = 'Simulator SDK Roots:\n'
    list.runtimes.forEach(function (runtime) {
      if (runtime.availability === '(available)') {
        output += util.format('"%s" (%s)\n', runtime.name, runtime.buildversion)
        output += util.format('\t(unknown)\n')
      }
    })

    return output
  },
  // jscs:enable disallowUnusedParams

  // jscs:disable disallowUnusedParams
  getdevicetypes: function (args) {
    let options = { silent: true }
    let list = simctl.list(options).json
    list = fixSimCtlList(list)

    let druntimes = findRuntimesGroupByDeviceProperty(list, 'name', true, { lowerCase: true })
    let name_id_map = {}

    list.devicetypes.forEach(function (device) {
      name_id_map[ filterDeviceName(device.name).toLowerCase() ] = device.identifier
    })

    list = []
    let remove = function (devicename, runtime) {
      // remove "iOS" prefix in runtime, remove prefix "com.apple.CoreSimulator.SimDeviceType." in id
      list.push(util.format('%s, %s', name_id_map[ devicename ].replace(/^com.apple.CoreSimulator.SimDeviceType./, ''), runtime.replace(/^iOS /, '')))
    }

    let cur = function (devicename) {
      return function (runtime) {
        remove(devicename, runtime)
      }
    }

    for (let deviceName in druntimes) {
      let runtimes = druntimes[ deviceName ]
      let dname = filterDeviceName(deviceName).toLowerCase()

      if (!(dname in name_id_map)) {
        continue
      }

      runtimes.forEach(cur(dname))
    }
    return list
  },
  // jscs:enable disallowUnusedParams

  // jscs:disable disallowUnusedParams
  showdevicetypes: function (args) {
    let output = ''
    this.getdevicetypes().forEach(function (device) {
      output += util.format('%s\n', device)
    })

    return output
  },
  // jscs:enable disallowUnusedParams

  launch: function (app_path, devicetypeid, log, exit, setenv, argv) {
    let wait_for_debugger = false
    let info_plist_path
    let app_identifier

    info_plist_path = path.join(app_path, 'Info.plist')
    if (!fs.existsSync(info_plist_path)) {
      console.error(info_plist_path + ' file not found.')
      process.exit(1)
    }

    bplist.parseFile(info_plist_path, function (err, obj) {
      if (err) {
        // try to see if a regular plist parser will work
        if (!plist) {
          plist = require('plist')
        }
        obj = plist.parse(fs.readFileSync(info_plist_path, 'utf8'))
        if (obj) {
          app_identifier = obj.CFBundleIdentifier
        } else {
          throw err
        }
      } else {
        app_identifier = obj[0].CFBundleIdentifier
      }

      argv = argv || []
      setenv = setenv || []

      let environmentVariables = parseEnvironmentVariables(setenv)

      withInjectedEnvironmentVariablesToProcess(process, environmentVariables, function () {
        // get the deviceid from --devicetypeid
        // --devicetypeid is a string in the form "devicetype, runtime_version" (optional: runtime_version)
        let device = getDeviceFromDeviceTypeId(devicetypeid)

        // log device information
        console.log(util.format('device.name: %s', device.name))
        console.log(util.format('device.runtime: %s', device.runtime))
        console.log(util.format('device.id: %s', device.id))

        // so now we have the deviceid, we can proceed
        simctl.extensions.start(device.id)
        simctl.install(device.id, app_path)
        simctl.launch(wait_for_debugger, device.id, app_identifier, argv)
        simctl.extensions.log(device.id, log)
        if (log) {
          console.log(util.format('logPath: %s', path.resolve(log)))
        }
        if (exit) {
          process.exit(0)
        }
      })
    })
  },

  install: function (app_path, devicetypeid, log, exit) {
    let info_plist_path

    info_plist_path = path.join(app_path, 'Info.plist')
    if (!fs.existsSync(info_plist_path)) {
      console.error(info_plist_path + ' file not found.')
      process.exit(1)
    }

    bplist.parseFile(info_plist_path, function (err, obj) {
      if (err) {
        throw err
      }

      // get the deviceid from --devicetypeid
      // --devicetypeid is a string in the form "devicetype, runtime_version" (optional: runtime_version)
      let device = getDeviceFromDeviceTypeId(devicetypeid)

      // so now we have the deviceid, we can proceed
      simctl.extensions.start(device.id)
      simctl.install(device.id, app_path)

      simctl.extensions.log(device.id, log)
      if (log) {
        console.log(util.format('logPath: %s', path.resolve(log)))
      }
      if (exit) {
        process.exit(0)
      }
    })
  },

  start: function (devicetypeid) {
    let device = getDeviceFromDeviceTypeId(devicetypeid)
    simctl.extensions.start(device.id)
  },

  _parseEnvironmentVariables: parseEnvironmentVariables

}

module.exports = lib
