#
# Copyright 2014-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module Omnibus
  class Packager::PKG < Packager::Base
    # @return [Hash]
    SCRIPT_MAP = {
      # Default Omnibus naming
      preinst: "preinstall",
      postinst: "postinstall",
      # Default PKG naming
      preinstall: "preinstall",
      postinstall: "postinstall",
    }.freeze

    id :pkg

    setup do
      # Create the resources directory
      create_directory(resources_dir)

      # Create the scripts directory
      create_directory(scripts_dir)

      # Render the license
      render_template(resource_path("license.html.erb"),
        destination: "#{resources_dir}/license.html",
        variables: {
          name: project.name,
          friendly_name: project.friendly_name,
          maintainer: project.maintainer,
          build_version: project.build_version,
          package_name: project.package_name,
        })

      # Render the welcome template
      render_template(resource_path("welcome.html.erb"),
        destination: "#{resources_dir}/welcome.html",
        variables: {
          name: project.name,
          friendly_name: project.friendly_name,
          maintainer: project.maintainer,
          build_version: project.build_version,
          package_name: project.package_name,
        })

      # "Render" the assets
      copy_file(resource_path("background.png"), "#{resources_dir}/background.png")
    end

    build do
      write_scripts

      sign_software_libs_and_bins

      build_component_pkg

      write_distribution_file

      build_product_pkg
    end

    #
    # @!group DSL methods
    # --------------------------------------------------

    #
    # The identifer for the PKG package.
    #
    # @example
    #   identifier 'com.getchef.chefdk'
    #
    # @param [String] val
    #   the package identifier
    #
    # @return [String]
    #
    def identifier(val = NULL)
      if null?(val)
        @identifier
      else
        @identifier = val
      end
    end
    expose :identifier

    #
    # Set or return the signing identity. If this value is provided, Omnibus
    # will attempt to sign the PKG.
    #
    # @example
    #   signing_identity "foo"
    #
    # @param [String] val
    #   the identity to use when signing the PKG
    #
    # @return [String]
    #   the PKG-signing identity
    #
    def signing_identity(val = NULL)
      if null?(val)
        @signing_identity
      else
        @signing_identity = val
      end
    end
    expose :signing_identity

    #
    # @!endgroup
    # --------------------------------------------------

    # @see Base#package_name
    def package_name
      "#{safe_base_package_name}-#{safe_version}-#{safe_build_iteration}.#{safe_architecture}.pkg"
    end

    #
    # The full path where the product package was/will be written.
    #
    # @return [String]
    #
    def final_pkg
      File.expand_path("#{Config.package_dir}/#{package_name}")
    end

    #
    # The path where the product package resources will live. We cannot store
    # resources in the top-level staging dir, because +productbuild+'s
    # +--resources+ flag expects a directory that does not contain the parent
    # package.
    #
    # @return [String]
    #
    def resources_dir
      File.expand_path("#{staging_dir}/Resources")
    end

    #
    # The path where the package scripts will live. We cannot store
    # scripts in the top-level staging dir, because +pkgbuild+'s
    # +--scripts+ flag expects a directory that does not contain the parent
    # package.
    #
    # @return [String]
    #
    def scripts_dir
      File.expand_path("#{staging_dir}/Scripts")
    end

    #
    # Copy all scripts in {Project#package_scripts_path} to the package
    # directory.
    #
    # @return [void]
    #
    def write_scripts
      SCRIPT_MAP.each do |source, destination|
        source_path = File.join(project.package_scripts_path, source.to_s)

        if File.file?(source_path)
          destination_path = File.join(scripts_dir, destination)
          log.debug(log_key) { "Adding script `#{source}' to `#{destination_path}'" }
          copy_file(source_path, destination_path)
        end
      end
    end

    def sign_software_libs_and_bins
      if signing_identity
        log.info(log_key) { "Finding libraries and binaries that require signing." }

        bin_dirs = Set[]
        lib_dirs = Set[]
        binaries = Set[]
        libraries = Set[]

        # Capture lib_dirs and bin_dirs from each software
        project.softwares.each do |software|
          lib_dirs.merge(software.lib_dirs)
          bin_dirs.merge(software.bin_dirs)
        end

        # Find all binaries in each bind_dir
        bin_dirs.each do |dir|
          binaries.merge Dir["#{dir}/*"]
        end
        # Filter out symlinks, non-files, and non-executables
        log.debug(log_key) { "  Filtering non-binary files:" }
        binaries.select! { |bin| is_binary?(bin) }

        # Use otool to find all libries that are used by our binaries
        binaries.each do |bin|
          libraries.merge find_linked_libs bin
        end

        # Find all libraries in each lib_dir and add any we missed with otool
        lib_dirs.each do |dir|
          libraries.merge Dir["#{dir}/*"]
        end

        # Filter Mach-O libraries and bundles
        log.debug(log_key) { "  Filtering non-library files:" }
        libraries.select! { |lib| is_macho?(lib) }

        # Use otool to find all libries that are used by our libraries
        otool_libs = Set[]
        libraries.each do |lib|
          otool_libs.merge find_linked_libs lib
        end

        # Filter Mach-O libraries and bundles
        otool_libs.select! { |lib| is_macho?(lib) }
        libraries.merge otool_libs

        log.info(log_key) { "  Signing libraries:" } unless libraries.empty?
        libraries.each do |library|
          log.debug(log_key) { "    Signing: #{library}" }
          sign_library(library)
        end

        log.info(log_key) { "  Signing binaries:" } unless binaries.empty?
        binaries.each do |binary|
          log.debug(log_key) { "    Signing: #{binary}" }
          sign_binary(binary, true)
        end
      end
    end

    #
    # Construct the intermediate build product. It can be installed with the
    # Installer.app, but doesn't contain the data needed to customize the
    # installer UI.
    #
    # @return [void]
    #
    def build_component_pkg
      command = <<~EOH
        pkgbuild \\
          --identifier "#{safe_identifier}" \\
          --version "#{safe_version}" \\
          --scripts "#{scripts_dir}" \\
          --root "#{project.install_dir}" \\
          --install-location "#{project.install_dir}" \\
          --preserve-xattr \\
      EOH

      command << %Q{  --sign "#{signing_identity}" \\\n} if signing_identity
      command << %Q{  "#{component_pkg}"}
      command << %Q{\n}

      Dir.chdir(staging_dir) do
        shellout!(command)
      end
    end

    #
    # Write the Distribution file to the staging area. This method generates the
    # content of the Distribution file, which is used by +productbuild+ to
    # select the component packages to include in the product package.
    #
    # It also includes information used to customize the UI of the Mac OS X
    # installer.
    #
    # @return [void]
    #
    def write_distribution_file
      render_template(resource_path("distribution.xml.erb"),
        destination: "#{staging_dir}/Distribution",
        mode: 0600,
        variables: {
          friendly_name: project.friendly_name,
          identifier: safe_identifier,
          version: safe_version,
          component_pkg: component_pkg,
          host_architecture: safe_architecture,
        })
    end

    #
    # Construct the product package. The generated package is the final build
    # product that is shipped to end users.
    #
    # @return [void]
    #
    def build_product_pkg
      command = <<~EOH
        productbuild \\
          --distribution "#{staging_dir}/Distribution" \\
          --resources "#{resources_dir}" \\
      EOH

      command << %Q{  --sign "#{signing_identity}" \\\n} if signing_identity
      command << %Q{  "#{final_pkg}"}
      command << %Q{\n}

      Dir.chdir(staging_dir) do
        shellout!(command)
      end
    end

    #
    # The name of the (only) component package.
    #
    # @return [String] the filename of the component .pkg file to create.
    #
    def component_pkg
      "#{safe_base_package_name}-core.pkg"
    end

    #
    # Return the architecture
    #
    # @return [String]
    #
    def safe_architecture
      @safe_architecture ||= Ohai["kernel"]["machine"]
    end

    #
    # Return the PKG-ready base package name, removing any invalid characters.
    #
    # @return [String]
    #
    def safe_base_package_name
      if project.package_name =~ /\A[[:alnum:]-]+\z/
        project.package_name.dup
      else
        converted = project.package_name.downcase.gsub(/[^[:alnum:]+]/, "")

        log.warn(log_key) do
          "The `name' component of Mac package names can only include " \
          "alphabetical characters (a-z, A-Z), numbers (0-9), and -. Converting " \
          "`#{project.package_name}' to `#{converted}'."
        end

        converted
      end
    end

    #
    # The identifier for this mac package (the com.whatever.thing.whatever).
    # This is a configurable project value, but a default value is calculated if
    # one is not given.
    #
    # @return [String]
    #
    def safe_identifier
      return identifier if identifier

      maintainer = project.maintainer.gsub(/[^[:alnum:]+]/, "").downcase
      "test.#{maintainer}.pkg.#{safe_base_package_name}"
    end

    #
    # This is actually just the regular build_iternation, but it felt lonely
    # among all the other +safe_*+ methods.
    #
    # @return [String]
    #
    def safe_build_iteration
      project.build_iteration
    end

    #
    # Return the PKG-ready version, converting any invalid characters to
    # dashes (+-+).
    #
    # @return [String]
    #
    def safe_version
      if project.build_version =~ /\A[a-zA-Z0-9\.\+\-]+\z/
        project.build_version.dup
      else
        converted = project.build_version.gsub(/[^a-zA-Z0-9\.\+\-]+/, "-")

        log.warn(log_key) do
          "The `version' component of Mac package names can only include " \
          "alphabetical characters (a-z, A-Z), numbers (0-9), dots (.), " \
          "plus signs (+), and dashes (-). Converting " \
          "`#{project.build_version}' to `#{converted}'."
        end

        converted
      end
    end

    #
    # Given a file path return any linked libraries.
    #
    # @param [String] file_path
    #    The path to a file
    # @return [Array<String>]
    #    The linked libs
    #
    def find_linked_libs(file_path)
      # Find all libaries for each bin
      command = "otool -L #{file_path}"

      stdout = shellout!(command).stdout
      stdout.slice!(file_path)
      stdout.scan(/#{install_dir}\S*/)
    end

    def sign_library(lib)
      sign_binary(lib)
    end

    def sign_binary(bin, hardened_runtime = false)
      command = "codesign -s '#{signing_identity}' '#{bin}'"
      command << %q{ --options=runtime} if hardened_runtime
      command << %Q{ --entitlements #{resource_path("entitlements.plist")}} if File.exist?(resource_path("entitlements.plist")) && hardened_runtime
      ## Force re-signing to deal with binaries that have the same sha.
      command << %q{ --force}
      command << %Q{\n}

      shellout!(command)
    end

    def is_binary?(bin)
      return false unless File.file?(bin) && File.executable?(bin) && !File.symlink?(bin)

      log.debug(log_key) { "    skipping non-binary file from signing: #{bin}" }
      true
    end

    def is_macho?(lib)
      return false unless File.file?(lib) && File.executable?(lib) && !File.symlink?(lib)

      if shellout!("file #{lib}").stdout.match?(/Mach-O.*(library|bundle)/) # https://rubular.com/r/nRgaQlAbkM9wHL
        log.debug(log_key) { "    skipping non-Mach-O library file from signing: #{lib}" }
        return true
      end

      false
    end
  end
end
