#
# Copyright:: 2016-2018, Chef Software, Inc.
# Copyright:: 2015-2018, Fletcher Nichol
#
# 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 Kitchen
  module Driver
    class Aws
      #
      # Lets you grab StandardPlatform objects that help search for official
      # AMIs in your region and tell you useful tidbits like usernames.
      #
      # To use these, set your platform name to a supported platform name like:
      #
      # centos
      # rhel
      # fedora
      # freebsd
      # ubuntu
      # windows
      #
      # The implementation will select the latest matching version and AMI.
      #
      # You can specify a version and optional architecture as well:
      #
      # windows-2012r2-i386
      # centos-7
      #
      # Useful reference for platform AMIs:
      # https://alestic.com/2014/01/ec2-ssh-username/
      class StandardPlatform
        #
        # Create a new StandardPlatform object.
        #
        # @param driver [Kitchen::Driver::Ec2] The driver.
        # @param name [String] The name of the platform (rhel, centos, etc.)
        # @param version [String] The version of the platform (7.1, 2008sp1, etc.)
        # @param architecture [String] The architecture (i386, x86_64, arm64)
        #
        def initialize(driver, name, version, architecture)
          @driver = driver
          @name = name
          @version = version
          @architecture = architecture
        end

        #
        # The driver.
        #
        # @return [Kitchen::Driver::Ec2]
        #
        attr_reader :driver

        #
        # The name of the platform (e.g. rhel, centos, etc.)
        #
        # @return [String]
        #
        attr_reader :name

        #
        # The version of the platform (e.g. 7.1, 2008sp1, etc.)
        #
        # @return [String]
        #
        attr_reader :version

        #
        # The architecture of the platform, e.g. i386, x86_64
        #
        # @return [String]
        #
        # @see SUPPORTED_ARCHITECTURESS
        #
        attr_reader :architecture

        #
        # The list of supported architectures
        #
        SUPPORTED_ARCHITECTURES = %w{x86_64 i386 arm64}.freeze

        #
        # Find the best matching image for the given image search.
        #
        # @return [String] The image ID (e.g. ami-213984723)
        def find_image(image_search)
          driver.debug("Searching for images matching #{image_search} ...")
          # Convert to ec2 search format (pairs of name+values)
          filters = image_search.map do |key, value|
            { name: key.to_s, values: Array(value).map(&:to_s) }
          end

          # We prefer most recent first
          images = driver.ec2.resource.images(filters: filters)
          images = sort_images(images)
          show_returned_images(images)

          # Grab the best match
          images.first && images.first.id
        end

        #
        # The list of StandardPlatform objects. StandardPlatforms register
        # themselves with this.
        #
        # @return Array[Kitchen::Driver::Aws::StandardPlatform]
        #
        def self.platforms
          @platforms ||= {}
        end

        def to_s
          "#{name}#{version ? " #{version}" : ""}#{architecture ? " #{architecture}" : ""}"
        end

        #
        # Instantiate a platform from a platform name.
        #
        # @param driver [Kitchen::Driver::Ec2] The driver.
        # @param platform_string [String] The platform string, e.g. "windows",
        #        "ubuntu-7.1", "centos-7-i386"
        #
        # @return [Kitchen::Driver::Aws::StandardPlatform]
        #
        def self.from_platform_string(driver, platform_string)
          platform, version, architecture = parse_platform_string(platform_string)
          if platform && platforms[platform]
            platforms[platform].new(driver, platform, version, architecture)
          end
        end

        #
        # Detect platform from an image.
        #
        # @param driver [Kitchen::Driver::Ec2] The driver.
        # @param image [Aws::Ec2::Image] The EC2 Image object.
        #
        # @return [Kitchen::Driver::Aws::StandardPlatform]
        #
        def self.from_image(driver, image)
          platforms.each_value do |platform|
            result = platform.from_image(driver, image)
            return result if result
          end
          nil
        end

        protected

        #
        # Sort a list of images by their versions, from greatest to least.
        #
        # This MUST perform a stable sort. (Note that `sort` and `sort_by` are
        # not, by default, stable sorts in Ruby.)
        #
        # Used by the default find_image. The default version calls platform_from_image()
        # on each image, and interprets the versions as floats (7 < 7.1 < 8).
        #
        # @param images [Array[Aws::Ec2::Image]] The list of images to sort
        #
        # @return [Array[Aws::Ec2::Image]] A sorted list.
        #
        def sort_by_version(images)
          # 7.1 -> [ img1, img2, img3 ]
          # 6 -> [ img4, img5 ]
          # ...
          images.group_by do |image|
            platform = self.class.from_image(driver, image)
            platform ? platform.version : nil
          end.sort_by { |k, _v| k ? k.to_f : nil }.reverse.flat_map { |_k, v| v }
        end

        # Not supported yet: aix mac_os_x nexus solaris

        def prefer(images, &block)
          # Put the matching ones *before* the non-matching ones.
          matching, non_matching = images.partition(&block)
          matching + non_matching
        end

        private

        def self.parse_platform_string(platform_string)
          platform, version = platform_string.split("-", 2)

          # If the right side is a valid architecture, use it as such
          # i.e. debian-i386 or windows-server-2012r2-i386
          if version && SUPPORTED_ARCHITECTURES.include?(version.split("-")[-1])
            # server-2012r2-i386 -> server-2012r2, -, i386
            version, _dash, architecture = version.rpartition("-")
            version = nil if version == ""
          end

          [platform, version, architecture]
        end

        def sort_images(images)
          # P6: We prefer more recent images over older ones
          images = images.sort_by(&:creation_date).reverse
          # P5: We prefer x86_64 over i386 (if available)
          images = prefer(images) { |image| image.architecture == "x86_64" }
          # P4: We prefer (SSD) (if available)
          images = prefer(images) do |image|
            image.block_device_mappings.any? do |b|
              b.device_name == image.root_device_name && b.ebs && %w{gp3 gp2}.any? { |t| b.ebs.volume_type == t }
            end
          end
          # P3: We prefer ebs over instance_store (if available)
          images = prefer(images) { |image| image.root_device_type == "ebs" }
          # P2: We prefer hvm (the modern standard)
          images = prefer(images) { |image| image.virtualization_type == "hvm" }
          # P1: We prefer the latest version over anything else
          sort_by_version(images)
        end

        def show_returned_images(images)
          if images.empty?
            driver.error("Search returned 0 images.")
          else
            driver.debug("Search returned #{images.size} images:")
            images.each do |image|
              platform = self.class.from_image(driver, image)
              if platform
                driver.debug("- #{image.name}: Detected #{platform}. #{driver.image_info(image)}")
              else
                driver.debug("- #{image.name}: No platform detected. #{driver.image_info(image)}")
              end
            end
          end
        end
      end
    end
  end
end
