#
# Author:: Seth Vargo <sethvargo@gmail.com>
# Cookbook:: hostsfile
# Library:: manipulator
#
# Copyright:: 2012-2013, Seth Vargo
# Copyright:: 2012, CustomInk, LCC
#
# 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 'chef/application'
require 'openssl'

module HostsFile
  class Manipulator
    attr_reader :node
    attr_reader :entries

    # Create a new Manipulator object (aka an /etc/hosts manipulator). If a
    # hostsfile is not found, an exception is raised.
    #
    # @param [Chef::node] node
    #   the current Chef node
    # @return [Manipulator]
    #   a class designed to manipulate the node's /etc/hosts file
    def initialize(node)
      @node = node

      # Fail if no hostsfile is found
      unless ::File.exist?(hostsfile_path)
        raise "No hostsfile exists at `#{hostsfile_path}'!"
      end

      @entries = []
      collect_and_flatten(::File.readlines(hostsfile_path))
    end

    # Return a list of all IP Addresses for this hostsfile.
    #
    # @return [Array<IPAddr>]
    #   the list of IP Addresses
    def ip_addresses
      @entries.collect(&:ip_address).compact || []
    end

    # Add a new record to the hostsfile.
    #
    # @param [Hash] options
    #   a list of options to create the entry with
    # @option options [String] :ip_address
    #   the IP Address for this entry
    # @option options [String] :hostname
    #   the hostname for this entry
    # @option options [String, Array<String>] :aliases
    #   a alias or array of aliases for this entry
    # @option options[String] :comment
    #   an optional comment for this entry
    # @option options [Integer] :priority
    #   the relative priority of this entry (compared to others)
    def add(options = {})
      entry = HostsFile::Entry.new(
        ip_address: options[:ip_address],
        hostname:   options[:hostname],
        aliases:    options[:aliases],
        comment:    options[:comment],
        priority:   options[:priority]
      )

      @entries << entry
      remove_existing_hostnames(entry) if options[:unique]
    end

    # Update an existing entry. This method will do nothing if the entry
    # does not exist.
    #
    # @param (see #add)
    def update(options = {})
      if entry = find_entry_by_ip_address(options[:ip_address])
        entry.hostname  = options[:hostname]
        entry.aliases   = options[:aliases]
        entry.comment   = options[:comment]
        entry.priority  = options[:priority]

        remove_existing_hostnames(entry) if options[:unique]
      end
    end

    # Append content to an existing entry. This method will add a new entry
    # if one does not already exist.
    #
    # @param (see #add)
    def append(options = {})
      if entry = find_entry_by_ip_address(options[:ip_address])
        hosts          = normalize(entry.hostname, entry.aliases, options[:hostname], options[:aliases])
        entry.hostname = hosts.shift
        entry.aliases  = hosts

        unless entry.comment && options[:comment] && entry.comment.include?(options[:comment])
          entry.comment = normalize(entry.comment, options[:comment]).join(', ')
        end

        remove_existing_hostnames(entry) if options[:unique]
      else
        add(options)
      end
    end

    # Remove an entry by it's IP Address
    #
    # @param [String] ip_address
    #   the IP Address of the entry to remove
    def remove(ip_address)
      if entry = find_entry_by_ip_address(ip_address)
        @entries.delete(entry)
      end
    end

    # Save the new hostsfile to the target machine. This method will only write the
    # hostsfile if the current version has changed. In other words, it is convergent.
    def save
      file = Chef::Resource::File.new(hostsfile_path, node.run_context)
      file.content(new_content)
      file.atomic_update false if docker_guest?
      file.run_action(:create)
    end

    # Determine if the content of the hostfile has changed by comparing sha
    # values of existing file and new content
    #
    # @return [Boolean]
    def content_changed?
      new_sha = OpenSSL::Digest::SHA512.hexdigest(new_content)
      new_sha != current_sha
    end

    # Find an entry by the given IP Address.
    #
    # @param [String] ip_address
    #   the IP Address of the entry to find
    # @return [Entry, nil]
    #   the corresponding entry object, or nil if it does not exist
    def find_entry_by_ip_address(ip_address)
      @entries.find do |entry|
        !entry.ip_address.nil? && entry.ip_address == ip_address
      end
    end

    # Determine if the current hostsfile contains the given resource. This
    # is really just a proxy to {find_resource_by_ip_address} /
    #
    # @param [Chef::Resource] resource
    #
    # @return [Boolean]
    def contains?(resource)
      !!find_entry_by_ip_address(resource.ip_address)
    end

    private

    # Determine if we are running inside a Docker container
    #
    # @return [Boolean]
    def docker_guest?
      node['virtualization'] && node['virtualization']['systems'] &&
        node['virtualization']['systems']['docker'] && node['virtualization']['systems']['docker'] == 'guest'
    end

    # The path to the current hostsfile.
    #
    # @return [String]
    #   the full path to the hostsfile, depending on the operating system
    #   can also be overriden in the node attributes
    def hostsfile_path
      return @hostsfile_path if @hostsfile_path
      @hostsfile_path = node['hostsfile']['path'] || case node['platform_family']
                                                     when 'windows'
                                                       "#{node['kernel']['os_info']['system_directory']}\\drivers\\etc\\hosts"
                                                     else
                                                       '/etc/hosts'
                                                     end
    end

    # The header of the new hostsfile
    #
    # @return [Array]
    #   an array of header comments
    def hostsfile_header
      lines = []
      lines << '#'
      lines << '# This file is managed by Chef, using the hostsfile cookbook.'
      lines << '# Editing this file by hand is highly discouraged!'
      lines << '#'
      lines << '# Comments containing an @ sign should not be modified or else'
      lines << '# hostsfile will be unable to guarantee relative priority in'
      lines << '# future Chef runs!'
      lines << '#'
      lines << ''
    end

    # The content that will be written to the hostfile
    #
    # @return [String]
    #   the full contents of the hostfile to be written
    def new_content
      entries = hostsfile_header
      entries += unique_entries.map(&:to_line)
      entries << ''
      entries.join("\n")
    end

    # The current sha of the system hostsfile.
    #
    # @return [String]
    #   the sha of the current hostsfile
    def current_sha
      @current_sha ||= OpenSSL::Digest::SHA512.hexdigest(File.read(hostsfile_path))
    end

    # Normalize the given list of elements into a single array with no nil
    # values and no duplicate values.
    #
    # @param [Object] things
    #
    # @return [Array]
    #   a normalized array of things
    def normalize(*things)
      things.flatten.compact.uniq
    end

    # This is a crazy way of ensuring unique objects in an array using a Hash.
    #
    # @return [Array]
    #   the sorted list of entires that are unique
    def unique_entries
      entries = Hash[*@entries.map { |entry| [entry.ip_address, entry] }.flatten].values
      entries.sort_by { |e| [-e.priority.to_i, e.hostname.to_s] }
    end

    # Takes /etc/hosts file contents and builds a flattened entries
    # array so that each IP address has only one line and multiple hostnames
    # are flattened into a list of aliases.
    #
    # @param [Array] contents
    #   Array of lines from /etc/hosts file
    def collect_and_flatten(contents)
      contents.each do |line|
        entry = HostsFile::Entry.parse(line)
        next if entry.nil?

        append(
          ip_address: entry.ip_address,
          hostname:   entry.hostname,
          aliases:    entry.aliases,
          comment:    entry.comment,
          priority:   !entry.calculated_priority? && entry.priority
        )
      end
    end

    # Removes duplicate hostnames in other files ensuring they are unique
    #
    # @param [Entry] entry
    #   the entry to keep the hostname and aliases from
    #
    # @return [nil]
    def remove_existing_hostnames(entry)
      @entries.delete(entry)
      changed_hostnames = [entry.hostname, entry.aliases].flatten.uniq

      @entries = @entries.collect do |entry|
        entry.hostname = nil if changed_hostnames.include?(entry.hostname)
        entry.aliases  = entry.aliases - changed_hostnames

        if entry.hostname.nil?
          if entry.aliases.empty?
            nil
          else
            entry.hostname = entry.aliases.shift
            entry
          end
        else
          entry
        end
      end.compact

      @entries << entry

      nil
    end
  end
end
