'use strict'

const App = require('./platform')
const common = require('./common')
const debug = require('debug')('electron-packager')
const fs = require('fs-extra')
const path = require('path')
const plist = require('plist')
const { notarize } = require('@electron/notarize')
const { signApp } = require('@electron/osx-sign')

class MacApp extends App {
  constructor (opts, templatePath) {
    super(opts, templatePath)

    this.appName = opts.name
  }

  get appCategoryType () {
    return this.opts.appCategoryType
  }

  get appCopyright () {
    return this.opts.appCopyright
  }

  get appVersion () {
    return this.opts.appVersion
  }

  get buildVersion () {
    return this.opts.buildVersion
  }

  get enableDarkMode () {
    return this.opts.darwinDarkModeSupport
  }

  get usageDescription () {
    return this.opts.usageDescription
  }

  get protocols () {
    return this.opts.protocols.map((protocol) => {
      return {
        CFBundleURLName: protocol.name,
        CFBundleURLSchemes: [].concat(protocol.schemes)
      }
    })
  }

  get dotAppName () {
    return `${common.sanitizeAppName(this.appName)}.app`
  }

  get defaultBundleName () {
    return `com.electron.${common.sanitizeAppName(this.appName).toLowerCase()}`
  }

  get bundleName () {
    return filterCFBundleIdentifier(this.opts.appBundleId || this.defaultBundleName)
  }

  get originalResourcesDir () {
    return path.join(this.contentsPath, 'Resources')
  }

  get resourcesDir () {
    return path.join(this.dotAppName, 'Contents', 'Resources')
  }

  get electronBinaryDir () {
    return path.join(this.contentsPath, 'MacOS')
  }

  get originalElectronName () {
    return 'Electron'
  }

  get newElectronName () {
    return this.appPlist.CFBundleExecutable
  }

  get renamedAppPath () {
    return path.join(this.stagingPath, this.dotAppName)
  }

  get electronAppPath () {
    return path.join(this.stagingPath, `${this.originalElectronName}.app`)
  }

  get contentsPath () {
    return path.join(this.electronAppPath, 'Contents')
  }

  get frameworksPath () {
    return path.join(this.contentsPath, 'Frameworks')
  }

  get loginItemsPath () {
    return path.join(this.contentsPath, 'Library', 'LoginItems')
  }

  get loginHelperPath () {
    return path.join(this.loginItemsPath, 'Electron Login Helper.app')
  }

  updatePlist (basePlist, displayName, identifier, name) {
    return Object.assign(basePlist, {
      CFBundleDisplayName: displayName,
      CFBundleExecutable: common.sanitizeAppName(displayName),
      CFBundleIdentifier: identifier,
      CFBundleName: common.sanitizeAppName(name)
    })
  }

  updateHelperPlist (basePlist, suffix, identifierIgnoresSuffix) {
    let helperSuffix, identifier, name
    if (suffix) {
      helperSuffix = `Helper ${suffix}`
      if (identifierIgnoresSuffix) {
        identifier = this.helperBundleIdentifier
      } else {
        identifier = `${this.helperBundleIdentifier}.${suffix}`
      }
      name = `${this.appName} ${helperSuffix}`
    } else {
      helperSuffix = 'Helper'
      identifier = this.helperBundleIdentifier
      name = this.appName
    }
    return this.updatePlist(basePlist, `${this.appName} ${helperSuffix}`, identifier, name)
  }

  async extendPlist (basePlist, propsOrFilename) {
    if (!propsOrFilename) {
      return Promise.resolve()
    }

    if (typeof propsOrFilename === 'string') {
      const plist = await this.loadPlist(propsOrFilename)
      return Object.assign(basePlist, plist)
    } else {
      return Object.assign(basePlist, propsOrFilename)
    }
  }

  async loadPlist (filename, propName) {
    const loadedPlist = plist.parse((await fs.readFile(filename)).toString())
    if (propName) {
      this[propName] = loadedPlist
    }
    return loadedPlist
  }

  ehPlistFilename (helper) {
    return this.helperPlistFilename(path.join(this.frameworksPath, helper))
  }

  helperPlistFilename (helperApp) {
    return path.join(helperApp, 'Contents', 'Info.plist')
  }

  async determinePlistFilesToUpdate () {
    const appPlistFilename = path.join(this.contentsPath, 'Info.plist')

    const plists = [
      [appPlistFilename, 'appPlist'],
      [this.ehPlistFilename('Electron Helper.app'), 'helperPlist']
    ]

    const possiblePlists = [
      [this.ehPlistFilename('Electron Helper (Renderer).app'), 'helperRendererPlist'],
      [this.ehPlistFilename('Electron Helper (Plugin).app'), 'helperPluginPlist'],
      [this.ehPlistFilename('Electron Helper (GPU).app'), 'helperGPUPlist'],
      [this.ehPlistFilename('Electron Helper EH.app'), 'helperEHPlist'],
      [this.ehPlistFilename('Electron Helper NP.app'), 'helperNPPlist'],
      [this.helperPlistFilename(this.loginHelperPath), 'loginHelperPlist']
    ]

    const optional = await Promise.all(possiblePlists.map(async item =>
      (await fs.pathExists(item[0])) ? item : null))
    return plists.concat(optional.filter(item => item))
  }

  appRelativePath (p) {
    return path.relative(this.contentsPath, p)
  }

  async updatePlistFiles () {
    const appBundleIdentifier = this.bundleName
    this.helperBundleIdentifier = filterCFBundleIdentifier(this.opts.helperBundleId || `${appBundleIdentifier}.helper`)

    const plists = await this.determinePlistFilesToUpdate()
    await Promise.all(plists.map(plistArgs => this.loadPlist(...plistArgs)))
    await this.extendPlist(this.appPlist, this.opts.extendInfo)
    if (this.asarIntegrity) {
      await this.extendPlist(this.appPlist, {
        ElectronAsarIntegrity: this.asarIntegrity
      })
    } else {
      delete this.appPlist.ElectronAsarIntegrity
    }
    this.appPlist = this.updatePlist(this.appPlist, this.executableName, appBundleIdentifier, this.appName)

    const updateIfExists = [
      ['helperRendererPlist', '(Renderer)', true],
      ['helperPluginPlist', '(Plugin)', true],
      ['helperGPUPlist', '(GPU)', true],
      ['helperEHPlist', 'EH'],
      ['helperNPPlist', 'NP']
    ]

    for (const [plistKey] of [...updateIfExists, ['helperPlist']]) {
      if (!this[plistKey]) continue
      await this.extendPlist(this[plistKey], this.opts.extendHelperInfo)
    }

    this.helperPlist = this.updateHelperPlist(this.helperPlist)
    for (const [plistKey, ...suffixArgs] of updateIfExists) {
      if (!this[plistKey]) continue
      this[plistKey] = this.updateHelperPlist(this[plistKey], ...suffixArgs)
    }

    // Some properties need to go on all helpers as well, version, usage info, etc.
    const plistsToUpdate = updateIfExists
      .filter(([key]) => !!this[key])
      .map(([key]) => key)
      .concat(['appPlist', 'helperPlist'])

    if (this.loginHelperPlist) {
      const loginHelperName = common.sanitizeAppName(`${this.appName} Login Helper`)
      this.loginHelperPlist.CFBundleExecutable = loginHelperName
      this.loginHelperPlist.CFBundleIdentifier = `${appBundleIdentifier}.loginhelper`
      this.loginHelperPlist.CFBundleName = loginHelperName
    }

    if (this.appVersion) {
      const appVersionString = '' + this.appVersion
      for (const plistKey of plistsToUpdate) {
        this[plistKey].CFBundleShortVersionString = this[plistKey].CFBundleVersion = appVersionString
      }
    }

    if (this.buildVersion) {
      const buildVersionString = '' + this.buildVersion
      for (const plistKey of plistsToUpdate) {
        this[plistKey].CFBundleVersion = buildVersionString
      }
    }

    if (this.opts.protocols && this.opts.protocols.length) {
      this.appPlist.CFBundleURLTypes = this.protocols
    }

    if (this.appCategoryType) {
      this.appPlist.LSApplicationCategoryType = this.appCategoryType
    }

    if (this.appCopyright) {
      this.appPlist.NSHumanReadableCopyright = this.appCopyright
    }

    if (this.enableDarkMode) {
      this.appPlist.NSRequiresAquaSystemAppearance = false
    }

    if (this.usageDescription) {
      for (const [type, description] of Object.entries(this.usageDescription)) {
        const usageTypeKey = `NS${type}UsageDescription`
        for (const plistKey of plistsToUpdate) {
          this[plistKey][usageTypeKey] = description
        }
        this.appPlist[usageTypeKey] = description
      }
    }

    await Promise.all(plists.map(([filename, varName]) =>
      fs.writeFile(filename, plist.build(this[varName]))))
  }

  async moveHelpers () {
    const helpers = [' Helper', ' Helper EH', ' Helper NP', ' Helper (Renderer)', ' Helper (Plugin)', ' Helper (GPU)']
    await Promise.all(helpers.map(suffix => this.moveHelper(this.frameworksPath, suffix)))
    if (await fs.pathExists(this.loginItemsPath)) {
      await this.moveHelper(this.loginItemsPath, ' Login Helper')
    }
  }

  async moveHelper (helperDirectory, suffix) {
    const originalBasename = `Electron${suffix}`

    if (await fs.pathExists(path.join(helperDirectory, `${originalBasename}.app`))) {
      return this.renameHelperAndExecutable(
        helperDirectory,
        originalBasename,
        `${common.sanitizeAppName(this.appName)}${suffix}`
      )
    } else {
      return Promise.resolve()
    }
  }

  async renameHelperAndExecutable (helperDirectory, originalBasename, newBasename) {
    const originalAppname = `${originalBasename}.app`
    const executableBasePath = path.join(helperDirectory, originalAppname, 'Contents', 'MacOS')
    await this.relativeRename(executableBasePath, originalBasename, newBasename)
    await this.relativeRename(helperDirectory, originalAppname, `${newBasename}.app`)
  }

  async copyIcon () {
    if (!this.opts.icon) {
      return Promise.resolve()
    }

    let icon

    try {
      icon = await this.normalizeIconExtension('.icns')
    } catch {
      // Ignore error if icon doesn't exist, in case it's only available for other OSes
      /* istanbul ignore next */
      return Promise.resolve()
    }
    if (icon) {
      debug(`Copying icon "${icon}" to app's Resources as "${this.appPlist.CFBundleIconFile}"`)
      await fs.copy(icon, path.join(this.originalResourcesDir, this.appPlist.CFBundleIconFile))
    }
  }

  async renameAppAndHelpers () {
    await this.moveHelpers()
    await fs.rename(this.electronAppPath, this.renamedAppPath)
  }

  async signAppIfSpecified () {
    const osxSignOpt = this.opts.osxSign
    const platform = this.opts.platform
    const version = this.opts.electronVersion

    if ((platform === 'all' || platform === 'mas') &&
        osxSignOpt === undefined) {
      common.warning('signing is required for mas builds. Provide the osx-sign option, ' +
                     'or manually sign the app later.', this.opts.quiet)
    }

    if (osxSignOpt) {
      const signOpts = createSignOpts(osxSignOpt, platform, this.renamedAppPath, version, this.opts.quiet)
      debug(`Running @electron/osx-sign with the options ${JSON.stringify(signOpts)}`)
      try {
        await signApp(signOpts)
      } catch (err) {
        // Although not signed successfully, the application is packed.
        common.warning(`Code sign failed; please retry manually. ${err}`, this.opts.quiet)
      }
    }
  }

  async notarizeAppIfSpecified () {
    const osxNotarizeOpt = this.opts.osxNotarize

    /* istanbul ignore if */
    if (osxNotarizeOpt) {
      const notarizeOpts = createNotarizeOpts(
        osxNotarizeOpt,
        this.bundleName,
        this.renamedAppPath,
        this.opts.quiet
      )
      if (notarizeOpts) {
        return notarize(notarizeOpts)
      }
    }
  }

  async create () {
    await this.initialize()
    await this.updatePlistFiles()
    await this.copyIcon()
    await this.renameElectron()
    await this.renameAppAndHelpers()
    await this.copyExtraResources()
    await this.signAppIfSpecified()
    await this.notarizeAppIfSpecified()
    return this.move()
  }
}

/**
 * Remove special characters and allow only alphanumeric (A-Z,a-z,0-9), hyphen (-), and period (.)
 * Apple documentation:
 * https://developer.apple.com/library/mac/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html#//apple_ref/doc/uid/20001431-102070
 */
function filterCFBundleIdentifier (identifier) {
  return identifier.replace(/ /g, '-').replace(/[^a-zA-Z0-9.-]/g, '')
}

function createSignOpts (properties, platform, app, version, quiet) {
  // use default sign opts if osx-sign is true, otherwise clone osx-sign object
  const signOpts = properties === true ? { identity: null } : { ...properties }

  // osx-sign options are handed off to sign module, but
  // with a few additions from the main options
  // user may think they can pass platform, app, or version, but they will be ignored
  common.subOptionWarning(signOpts, 'osx-sign', 'platform', platform, quiet)
  common.subOptionWarning(signOpts, 'osx-sign', 'app', app, quiet)
  common.subOptionWarning(signOpts, 'osx-sign', 'version', version, quiet)

  if (signOpts.binaries) {
    common.warning('osx-sign.binaries is not an allowed sub-option. Not passing to @electron/osx-sign.', quiet)
    delete signOpts.binaries
  }

  // Take argument osx-sign as signing identity:
  // if opts.osxSign is true (bool), fallback to identity=null for
  // autodiscovery. Otherwise, provide signing certificate info.
  if (signOpts.identity === true) {
    signOpts.identity = null
  }

  return signOpts
}

function createNotarizeOpts (properties, appBundleId, appPath, quiet) {
  // osxNotarize options are handed off to the @electron/notarize module, but with a few
  // additions from the main options. The user may think they can pass bundle ID or appPath,
  // but they will be ignored.
  if (properties.tool !== 'notarytool') {
    common.subOptionWarning(properties, 'osxNotarize', 'appBundleId', appBundleId, quiet)
  }
  common.subOptionWarning(properties, 'osxNotarize', 'appPath', appPath, quiet)
  return properties
}

module.exports = {
  App: MacApp,
  createNotarizeOpts: createNotarizeOpts,
  createSignOpts: createSignOpts,
  filterCFBundleIdentifier: filterCFBundleIdentifier
}
