#
# Author:: Joshua Timberman (<joshua@chef.io>)
# Author:: Graeme Mathieson (<mathie@woss.name>)
#
# Copyright:: Copyright (c) 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.
#

require "etc" unless defined?(Etc)
require_relative "../../mixin/homebrew_user"

class Chef
  class Provider
    class Package
      class Homebrew < Chef::Provider::Package
        allow_nils
        use_multipackage_api

        provides :package, os: "darwin"
        provides :homebrew_package

        include Chef::Mixin::HomebrewUser

        def load_current_resource
          @current_resource = Chef::Resource::HomebrewPackage.new(new_resource.name)
          current_resource.package_name(new_resource.package_name)
          current_resource.version(get_current_versions)
          logger.trace("#{new_resource} current package version(s): #{current_resource.version}") if current_resource.version

          current_resource
        end

        def candidate_version
          package_name_array.map do |package_name|
            available_version(package_name)
          end
        end

        def get_current_versions
          package_name_array.map do |package_name|
            installed_version(package_name)
          end
        end

        def install_package(names, versions)
          brew_cmd_output("install", options, names.compact)
        end

        # upgrades are a bit harder in homebrew than other package formats. If you try to
        # brew upgrade a package that isn't installed it will fail so if a user specifies
        # the action of upgrade we need to figure out which packages need to be installed
        # and which packages can be upgrades. We do this by checking if brew_info has an entry
        # via the installed_version helper.
        def upgrade_package(names, versions)
          # @todo when we no longer support Ruby 2.6 this can be simplified to be a .filter_map
          upgrade_pkgs = names.select { |x| x if installed_version(x) }.compact
          install_pkgs = names.select { |x| x unless installed_version(x) }.compact

          brew_cmd_output("upgrade", options, upgrade_pkgs) unless upgrade_pkgs.empty?
          brew_cmd_output("install", options, install_pkgs) unless install_pkgs.empty?
        end

        def remove_package(names, versions)
          brew_cmd_output("uninstall", options, names.compact)
        end

        # Homebrew doesn't really have a notion of purging, do a "force remove"
        def purge_package(names, versions)
          brew_cmd_output("uninstall", "--force", options, names.compact)
        end

        # We implement a querying method that returns the JSON-as-Hash
        # data for a formula per the Homebrew documentation. Previous
        # implementations of this provider in the homebrew cookbook
        # performed a bit of magic with the load path to get this
        # information, but that is not any more robust than using the
        # command-line interface that returns the same thing.
        #
        # https://docs.brew.sh/Querying-Brew
        #
        # @returns [Hash] a hash of package information where the key is the package name
        def brew_info
          @brew_info ||= begin
            command_array = ["info", "--json=v1"].concat package_name_array
            # convert the array of hashes into a hash where the key is the package name

            cmd_output = brew_cmd_output(command_array, allow_failure: true)

            if cmd_output.empty?
              # we had some kind of failure so we need to iterate through each package to find them
              package_name_array.each_with_object({}) do |package_name, hsh|
                cmd_output = brew_cmd_output("info", "--json=v1", package_name, allow_failure: true)
                if cmd_output.empty?
                  hsh[package_name] = {}
                else
                  json = Chef::JSONCompat.from_json(cmd_output).first
                  hsh[json["name"]] = json
                end
              end
            else
              Hash[Chef::JSONCompat.from_json(cmd_output).collect { |pkg| [pkg["name"], pkg] }]
            end
          end
        end

        #
        # Return the package information given a package name or package alias
        #
        # @param [String] name_or_alias The name of the package or its alias
        #
        # @return [Hash] Package information
        #
        def package_info(package_name)
          # return the package hash if it's in the brew info hash
          return brew_info[package_name] if brew_info[package_name]

          # check each item in the hash to see if we were passed an alias
          brew_info.each_value do |p|
            return p if p["full_name"] == package_name || p["aliases"].include?(package_name)
          end

          {}
        end

        # Some packages (formula) are "keg only" and aren't linked,
        # because multiple versions installed can cause conflicts. We
        # handle this by using the last installed version as the
        # "current" (as in latest). Otherwise, we will use the version
        # that brew thinks is linked as the current version.
        #
        # @param [String] package name
        #
        # @returns [String] package version
        def installed_version(i)
          p_data = package_info(i)

          if p_data["keg_only"]
            if p_data["installed"].empty?
              nil
            else
              p_data["installed"].last["version"]
            end
          else
            p_data["linked_keg"]
          end
        end

        # Packages (formula) available to install should have a
        # "stable" version, per the Homebrew project's acceptable
        # formula documentation, so we will rely on that being the
        # case. Older implementations of this provider in the homebrew
        # cookbook would fall back to +brew_info['version']+, but the
        # schema has changed, and homebrew is a constantly rolling
        # forward project.
        #
        # https://github.com/Homebrew/homebrew/wiki/Acceptable-Formulae#stable-versions
        #
        # @param [String] package name
        #
        # @returns [String] package version
        def available_version(i)
          p_data = package_info(i)

          # nothing is available
          return nil if p_data.empty?

          p_data["versions"]["stable"]
        end

        def brew_cmd_output(*command, **options)
          homebrew_uid = find_homebrew_uid(new_resource.respond_to?(:homebrew_user) && new_resource.homebrew_user)
          homebrew_user = Etc.getpwuid(homebrew_uid)

          logger.trace "Executing 'brew #{command.join(" ")}' as user '#{homebrew_user.name}'"

          # allow the calling method to decide if the cmd should raise or not
          # brew_info uses this when querying out available package info since a bad
          # package name will raise and we want to surface a nil available package so that
          # the package provider can magically handle that
          shell_out_cmd = options[:allow_failure] ? :shell_out : :shell_out!

          # FIXME: this 1800 second default timeout should be deprecated
          output = send(shell_out_cmd, "brew", *command, timeout: 1800, user: homebrew_uid, environment: { "HOME" => homebrew_user.dir, "RUBYOPT" => nil, "TMPDIR" => nil })
          output.stdout.chomp
        end

      end
    end
  end
end
