#
# Copyright:: Copyright 2017-2020, Microsoft Corporation
# Copyright:: Copyright (c) Chef Software Inc.
# License:: Apache License, Version 2.0
#
# 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_relative "../resource"
autoload :Plist, "plist"

class Chef
  class Resource

    class PlistResource < Chef::Resource # we name this PlistResource to avoid confusion with Plist from the plist gem

      provides :plist

      description "Use the **plist** resource to set config values in plist files on macOS systems."
      introduced "16.0"
      examples <<~DOC
        **Show hidden files in finder**:

        ```ruby
        plist 'show hidden files' do
          path '/Users/vagrant/Library/Preferences/com.apple.finder.plist'
          entry 'AppleShowAllFiles'
          value true
        end
        ```
      DOC

      property :path, String, name_property: true,
        description: "The path on disk to the plist file."

      property :entry, String
      property :value, [TrueClass, FalseClass, String, Integer, Float, Hash]
      property :encoding, String, default: "binary"

      property :owner, String, default: "root",
        description: "The owner of the plist file."

      property :group, String, default: "wheel",
        description: "The group of the plist file."

      property :mode, [String, Integer],
        description: "The file mode of the plist file. Ex: '644'"

      PLISTBUDDY_EXECUTABLE = "/usr/libexec/PlistBuddy".freeze
      DEFAULTS_EXECUTABLE = "/usr/bin/defaults".freeze
      PLUTIL_EXECUTABLE = "/usr/bin/plutil".freeze
      PLUTIL_FORMAT_MAP = { "us-ascii" => "xml1",
                             "text/xml" => "xml1",
                             "utf-8" => "xml1",
                             "binary" => "binary1" }.freeze

      load_current_value do |new_resource|
        current_value_does_not_exist! unless ::File.exist? new_resource.path
        entry new_resource.entry if entry_in_plist? new_resource.entry, new_resource.path

        setting = setting_from_plist new_resource.entry, new_resource.path
        value convert_to_data_type_from_string(setting[:key_type], setting[:key_value])

        file_type_cmd = shell_out "/usr/bin/file", "--brief", "--mime-encoding", "--preserve-date", new_resource.path
        encoding file_type_cmd.stdout.chomp

        file_owner_cmd = shell_out("/usr/bin/stat", "-f", "%Su", new_resource.path)
        owner file_owner_cmd.stdout.chomp

        file_group_cmd = shell_out("/usr/bin/stat", "-f", "%Sg", new_resource.path)
        group file_group_cmd.stdout.chomp
      end

      action :set, description: "Set a value in a plist file." do
        converge_if_changed :path do
          converge_by "create new plist: '#{new_resource.path}'" do
            file new_resource.path do
              content({}.to_plist)
              owner new_resource.owner
              group new_resource.group
              mode new_resource.mode if property_is_set?(:mode)
            end
          end
        end

        plist_file_name = ::File.basename(new_resource.path)

        converge_if_changed :entry do
          converge_by "add entry \"#{new_resource.entry}\" to #{plist_file_name}" do
            shell_out!(plistbuddy_command(:add, new_resource.entry, new_resource.path, new_resource.value))
          end
        end

        converge_if_changed :value do
          converge_by "#{plist_file_name}: set #{new_resource.entry} to #{new_resource.value}" do
            shell_out!(plistbuddy_command(:set, new_resource.entry, new_resource.path, new_resource.value))
          end
        end

        converge_if_changed :encoding do
          converge_by "change format" do
            unless PLUTIL_FORMAT_MAP.key?(new_resource.encoding)
              Chef::Application.fatal!(
                "Option encoding must be equal to one of: #{PLUTIL_FORMAT_MAP.keys}!  You passed \"#{new_resource.encoding}\"."
              )
            end
            shell_out!(PLUTIL_EXECUTABLE, "-convert", PLUTIL_FORMAT_MAP[new_resource.encoding], new_resource.path)
          end
        end

        converge_if_changed :owner do
          converge_by "update owner to #{new_resource.owner}" do
            file new_resource.path do
              owner new_resource.owner
            end
          end
        end

        converge_if_changed :group do
          converge_by "update group to #{new_resource.group}" do
            file new_resource.path do
              group new_resource.group
            end
          end
        end
      end

      ### Question: Should I refactor these methods into an action_class?
      ### Answer: NO
      ### Why: We need them in both the action and in load_current_value. If you put them in the
      ###      action class then they're only in the Provider class and are not available to load_current_value

      def convert_to_data_type_from_string(type, value)
        case type
        when "boolean"
          # Since we've determined this is a boolean data type, we can assume that:
          # If the value as an int is 1, return true
          # If the value as an int is 0 (not 1), return false
          value.to_i == 1
        when "integer"
          value.to_i
        when "float"
          value.to_f
        when "string", "dictionary"
          value
        when nil
          ""
        else
          raise "Unknown or unsupported data type: #{type.class}"
        end
      end

      def type_to_commandline_string(value)
        case value
        when Array
          "array"
        when Integer
          "integer"
        when FalseClass, TrueClass
          "bool"
        when Hash
          "dict"
        when String
          "string"
        when Float
          "float"
        else
          raise "Unknown or unsupported data type: #{value} of #{value.class}"
        end
      end

      def entry_in_plist?(entry, path)
        print_entry = plistbuddy_command :print, entry, path
        cmd = shell_out print_entry
        cmd.exitstatus == 0
      end

      def plistbuddy_command(subcommand, entry, path, value = nil)
        sep = " "
        arg = case subcommand.to_s
              when "add"
                if value.is_a?(Hash)
                  sep = ":"
                  value.map { |k, v| "#{k} #{type_to_commandline_string(v)}" }
                else
                  type_to_commandline_string(value)
                end
              when "set"
                if value.is_a?(Hash)
                  sep = ":"
                  value.map { |k, v| "#{k} #{v}" }
                else
                  value
                end
              else
                ""
              end
        entry_with_arg = ["\"#{entry}\"", arg].join(sep).strip
        subcommand = "#{subcommand.capitalize} :#{entry_with_arg}"
        [PLISTBUDDY_EXECUTABLE, "-c", "\'#{subcommand}\'", "\"#{path}\""].join(" ")
      end

      def setting_from_plist(entry, path)
        defaults_read_type_output = shell_out(DEFAULTS_EXECUTABLE, "read-type", path, entry).stdout
        data_type = defaults_read_type_output.split.last

        if value.class == Hash
          plutil_output = shell_out(PLUTIL_EXECUTABLE, "-extract", entry, "xml1", "-o", "-", path).stdout.chomp
          { key_type: data_type, key_value: ::Plist.parse_xml(plutil_output) }
        else
          defaults_read_output = shell_out(DEFAULTS_EXECUTABLE, "read", path, entry).stdout
          { key_type: data_type, key_value: defaults_read_output.strip }
        end
      end
    end
  end
end
