# frozen_string_literal: true
#
# Author:: Chef Partner Engineering (<partnereng@chef.io>)
# Copyright:: Copyright (c) 2016 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 "chef/knife/cloud/exceptions"
require "chef/knife/cloud/service"
require "chef/knife/cloud/helpers"
require_relative "google_service_helpers"
require "google/apis/compute_v1"
require "ipaddr" unless defined?(IPAddr)
require_relative "../../../knife-google/version"

class Chef::Knife::Cloud
  class GoogleService < Service
    include Chef::Knife::Cloud::GoogleServiceHelpers

    attr_reader :project, :zone, :wait_time, :refresh_rate, :max_pages, :max_page_size

    SCOPE_ALIAS_MAP = {
      "bigquery" => "bigquery",
      "cloud-platform" => "cloud-platform",
      "compute-ro" => "compute.readonly",
      "compute-rw" => "compute",
      "datastore" => "datastore",
      "logging-write" => "logging.write",
      "monitoring" => "monitoring",
      "monitoring-write" => "monitoring.write",
      "service-control" => "servicecontrol",
      "service-management" => "service.management",
      "sql" => "sqlservice",
      "sql-admin" => "sqlservice.admin",
      "storage-full" => "devstorage.full_control",
      "storage-ro" => "devstorage.read_only",
      "storage-rw" => "devstorage.read_write",
      "taskqueue" => "taskqueue",
      "useraccounts-ro" => "cloud.useraccounts.readonly",
      "useraccounts-rw" => "cloud.useraccounts",
      "userinfo-email" => "userinfo.email",
    }.freeze

    IMAGE_ALIAS_MAP = {
      "centos-6" => { project: "centos-cloud", prefix: "centos-6" },
      "centos-7" => { project: "centos-cloud", prefix: "centos-7" },
      "centos-8" => { project: "centos-cloud", prefix: "centos-8" },
      "container-vm" => { project: "google-containers", prefix: "container-vm" },
      "coreos" => { project: "coreos-cloud", prefix: "coreos-stable" },
      "debian-9" => { project: "debian-cloud", prefix: "debian-9" },
      "debian-10" => { project: "debian-cloud", prefix: "debian-10" },
      "opensuse-13" => { project: "opensuse-cloud", prefix: "opensuse-13" },
      "rhel-6" => { project: "rhel-cloud", prefix: "rhel-6" },
      "rhel-7" => { project: "rhel-cloud", prefix: "rhel-7" },
      "rhel-8" => { project: "rhel-cloud", prefix: "rhel-8" },
      "sles-12" => { project: "suse-cloud", prefix: "sles-12" },
      "sles-15" => { project: "suse-cloud", prefix: "sles-15" },
      "ubuntu-16-04" => { project: "ubuntu-os-cloud", prefix: "ubuntu-1604-xenial" },
      "ubuntu-1604-lts" => { project: "ubuntu-os-cloud", prefix: "ubuntu-1604-xenial" },
      "ubuntu-1804-lts" => { project: "ubuntu-os-cloud", prefix: "ubuntu-1804-bionic" },
      "ubuntu-2004-lts" => { project: "ubuntu-os-cloud", prefix: "ubuntu-2004-focal" },
      "ubuntu-minimal-1604-lts" => { project: "ubuntu-os-cloud", prefix: "ubuntu-minimal-1604-xenial" },
      "ubuntu-minimal-1804-lts" => { project: "ubuntu-os-cloud", prefix: "ubuntu-minimal-1804-bionic" },
      "windows-2008-r2" => { project: "windows-cloud", prefix: "windows-server-2008-r2" },
      "windows-2012-r2" => { project: "windows-cloud", prefix: "windows-server-2012-r2" },
      "windows-2012-r2-core" => { project: "windows-cloud", prefix: "windows-server-2012-r2-dc-core" },
      "windows-2016-core" => { project: "windows-cloud", prefix: "windows-server-2016-dc-core" },
      "windows-2016" => { project: "windows-cloud", prefix: "windows-server-2016-dc" },
      "windows-2019-core-for-containers" => { project: "windows-cloud", prefix: "windows-server-2019-dc-core-for-containers" },
      "windows-2019-core" => { project: "windows-cloud", prefix: "windows-server-2019-dc-core" },
      "windows-2019-for-containers" => { project: "windows-cloud", prefix: "windows-server-2019-dc-for-containers" },
      "windows-2019" => { project: "windows-cloud", prefix: "windows-server-2019-dc" },
    }.freeze

    PUBLIC_PROJECTS = %w{
      centos-cloud
      coreos-cloud
      debian-cloud
      cos-cloud
      rhel-cloud
      rhel-sap-cloud
      suse-cloud
      suse-sap-cloud
      ubuntu-os-cloud
      windows-cloud
      windows-sql-cloud
    }.freeze

    def initialize(options = {})
      @project       = options[:project]
      @zone          = options[:zone]
      @wait_time     = options[:wait_time]
      @refresh_rate  = options[:refresh_rate]
      @max_pages     = options[:max_pages]
      @max_page_size = options[:max_page_size]
    end

    def connection
      return @connection unless @connection.nil?

      @connection = Google::Apis::ComputeV1::ComputeService.new
      @connection.authorization = authorization
      @connection.client_options = Google::Apis::ClientOptions.new.tap do |opts|
        opts.application_name    = "knife-google"
        opts.application_version = Knife::Google::VERSION
      end

      @connection
    end

    def authorization
      @authorization ||= Google::Auth.get_application_default(
        [
          "https://www.googleapis.com/auth/cloud-platform",
          "https://www.googleapis.com/auth/compute",
        ]
      )
    end

    def create_server(options = {})
      validate_server_create_options!(options)

      ui.msg("Creating instance...")

      instance_object = instance_object_for(options)
      wait_for_operation(connection.insert_instance(project, zone, instance_object))
      wait_for_status("RUNNING") { get_server(options[:name]) }

      ui.msg("Instance created!")

      get_server(options[:name])
    end

    def delete_server(instance_name)
      begin
        instance = get_server(instance_name)
      rescue Google::Apis::ClientError
        ui.warn("Unable to locate instance #{instance_name} in project #{project}, zone #{zone}")
        return
      end

      server_summary(instance)
      ui.confirm("Do you really want to delete this instance")

      ui.msg("Deleting instance #{instance_name}...")

      wait_for_operation(connection.delete_instance(project, zone, instance_name))

      ui.msg("Instance #{instance_name} deleted successfully.")
    end

    def get_server(instance_name)
      connection.get_instance(project, zone, instance_name)
    end

    def create_disk(name, size, type, source_image = nil)
      disk = Google::Apis::ComputeV1::Disk.new
      disk.name    = name
      disk.size_gb = size
      disk.type    = disk_type_url_for(type)

      ui.msg("Creating a #{size} GB disk named #{name}...")

      wait_for_operation(connection.insert_disk(project, zone, disk, source_image: source_image))

      ui.msg("Waiting for disk to be ready...")

      wait_for_status("READY") { connection.get_disk(project, zone, name) }

      ui.msg("Disk created successfully.")
    end

    def delete_disk(name)
      begin
        connection.get_disk(project, zone, name)
      rescue Google::Apis::ClientError
        ui.warn("Unable to locate disk #{name} in project #{project}, zone #{zone}")
        return
      end

      ui.confirm("Do you really want to delete disk #{name}")

      ui.msg("Deleting disk #{name}...")
      wait_for_operation(connection.delete_disk(project, zone, name))
      ui.msg("Disk #{name} deleted successfully.")
    end

    def list_servers
      instances = paginated_results(:list_instances, :items, project, zone)
      return [] if instances.nil?

      instances.each_with_object([]) do |instance, memo|
        memo << OpenStruct.new(
          name:         instance.name,
          status:       instance.status,
          machine_type: machine_type_for(instance),
          network:      network_for(instance),
          private_ip:   private_ip_for(instance),
          public_ip:    public_ip_for(instance)
        )
      end
    end

    def list_zones
      paginated_results(:list_zones, :items, project) || []
    end

    # Retrieves the list of custom images and public images.
    # Custom images are images you create that belong to your project.
    def list_images
      available_projects.map { |project| paginated_results(:list_images, :items, project) || [] }.flatten
    end

    def list_disks
      paginated_results(:list_disks, :items, project, zone) || []
    end

    def list_regions
      paginated_results(:list_regions, :items, project) || []
    end

    def list_project_quotas
      connection.get_project(project).quotas || []
    end

    def validate_server_create_options!(options)
      raise "Invalid machine type: #{options[:machine_type]}" unless valid_machine_type?(options[:machine_type])
      raise "Invalid network: #{options[:network]}" unless valid_network?(options[:network])
      raise "Invalid subnet: #{options[:subnet]}" if options[:subnet] && !valid_subnet?(options[:subnet])
      raise "Invalid Public IP setting: #{options[:public_ip]}" unless valid_public_ip_setting?(options[:public_ip])
      raise "Invalid image: #{options[:image]} - check your image name, or set an image project if needed" if boot_disk_source_image(options[:image], options[:image_project]).nil?
      raise "Maximum number of local SSDs for an instance should be 8, while #{options[:number_of_local_ssd]} is requested." if options[:number_of_local_ssd].to_i > 8
    end

    def check_api_call
      yield
    rescue Google::Apis::ClientError
      false
    else
      true
    end

    def valid_machine_type?(machine_type)
      return false if machine_type.nil?

      check_api_call { connection.get_machine_type(project, zone, machine_type) }
    end

    def valid_network?(network)
      return false if network.nil?

      check_api_call { connection.get_network(project, network) }
    end

    def valid_subnet?(subnet)
      return false if subnet.nil?

      check_api_call { connection.get_subnetwork(project, region, subnet) }
    end

    def image_exist?(image_project, image_name)
      check_api_call { connection.get_image(image_project, image_name) }
    end

    def valid_public_ip_setting?(public_ip)
      case
      when public_ip.nil? || public_ip.match(/(ephemeral|none)/i)
        true
      when valid_ip_address?(public_ip)
        true
      else
        false
      end
    end

    def valid_ip_address?(ip_address)
      IPAddr.new(ip_address)
    rescue IPAddr::InvalidAddressError
      false
    else
      true
    end

    def region
      @region ||= connection.get_zone(project, zone).region.split("/").last
    end

    def instance_object_for(options)
      inst_obj                    = Google::Apis::ComputeV1::Instance.new
      inst_obj.name               = options[:name]
      inst_obj.can_ip_forward     = options[:can_ip_forward]
      inst_obj.disks              = instance_disks_for(options)
      inst_obj.machine_type       = machine_type_url_for(options[:machine_type])
      inst_obj.metadata           = instance_metadata_for(options[:metadata])
      inst_obj.network_interfaces = instance_network_interfaces_for(options)
      inst_obj.scheduling         = instance_scheduling_for(options)
      inst_obj.service_accounts   = instance_service_accounts_for(options) unless instance_service_accounts_for(options).nil?
      inst_obj.tags               = instance_tags_for(options[:tags])

      inst_obj
    end

    def instance_disks_for(options)
      disks = []
      disks << instance_boot_disk_for(options)
      options[:number_of_local_ssd].to_i.times { disks << adding_local_ssd(options) } if options[:local_ssd]
      options[:additional_disks].each do |disk_name|
        begin
          disk = connection.get_disk(project, zone, disk_name)
        rescue Google::Apis::ClientError => e
          ui.error("Unable to attach disk #{disk_name} to the instance: #{e.message}")
          raise
        end

        disks << Google::Apis::ComputeV1::AttachedDisk.new.tap { |x| x.source = disk.self_link }
      end

      disks
    end

    def instance_boot_disk_for(options)
      disk, params = common_operation(options, boot_disk_type_for(options))
      disk.boot           = true
      params.disk_name    = boot_disk_name_for(options)
      params.disk_size_gb = options[:boot_disk_size]
      params.source_image = boot_disk_source_image(options[:image], options[:image_project])

      disk.initialize_params = params
      disk
    end

    # To create a instance with an attached local SSD
    def adding_local_ssd(options)
      disk, params = common_operation(options, "local-ssd")
      disk.type = "SCRATCH"
      disk.interface = options[:interface]

      disk.initialize_params = params
      disk
    end

    def common_operation(options, disk_type)
      disk = Google::Apis::ComputeV1::AttachedDisk.new
      params = Google::Apis::ComputeV1::AttachedDiskInitializeParams.new
      disk.auto_delete = options[:boot_disk_autodelete]
      params.disk_type = disk_type_url_for(disk_type)
      [disk, params]
    end

    def boot_disk_type_for(options)
      options[:boot_disk_ssd] ? "pd-ssd" : "pd-standard"
    end

    def boot_disk_source_image(image, image_project)
      @boot_disk_source ||= image_search_for(image, image_project)
    end

    def image_search_for(image, image_project)
      # if the user provided an image_project, assume they want it, no questions asked
      unless image_project.nil?
        ui.msg("Searching project #{image_project} for image #{image}")
        return image_url_for(image_project, image)
      end

      # no image project has been provided. Check to see if the image is a known alias.
      alias_url = image_alias_url(image)
      unless alias_url.nil?
        ui.msg("image #{image} is a known alias - using image URL: #{alias_url}")
        return alias_url
      end

      # Doesn't match an alias. Let's check the user's project for the image.
      url = image_url_for(project, image)
      unless url.nil?
        ui.msg("Located image #{image} in project #{project} - using image URL: #{url}")
        return url
      end

      # Image not found in user's project. Is there a public project this image might exist in?
      public_project = public_project_for_image(image)
      if public_project
        ui.msg("Searching public image project #{public_project} for image #{image}")
        return image_url_for(public_project, image)
      end

      # No image in user's project or public project, so it doesn't exist.
      ui.error("Image search failed for image #{image} - no suitable image located")
      nil
    end

    def image_url_for(image_project, image_name)
      return "projects/#{image_project}/global/images/#{image_name}" if image_exist?(image_project, image_name)
    end

    def image_alias_url(image_alias)
      return unless IMAGE_ALIAS_MAP.key?(image_alias)

      image_project = IMAGE_ALIAS_MAP[image_alias][:project]
      image_prefix  = IMAGE_ALIAS_MAP[image_alias][:prefix]

      latest_image = connection.list_images(image_project).items
        .select { |image| image.name.start_with?(image_prefix) }
        .max_by(&:name)

      return if latest_image.nil?

      latest_image.self_link
    end

    def boot_disk_name_for(options)
      options[:boot_disk_name].nil? ? options[:name] : options[:boot_disk_name]
    end

    def machine_type_url_for(machine_type)
      "zones/#{zone}/machineTypes/#{machine_type}"
    end

    def instance_metadata_for(metadata)
      return if metadata.nil? || metadata.empty?

      metadata_obj = Google::Apis::ComputeV1::Metadata.new
      metadata_obj.items = metadata.each_with_object([]) do |(k, v), memo|
        metadata_item       = Google::Apis::ComputeV1::Metadata::Item.new
        metadata_item.key   = k
        metadata_item.value = v

        memo << metadata_item
      end

      metadata_obj
    end

    def instance_network_interfaces_for(options)
      interface = Google::Apis::ComputeV1::NetworkInterface.new
      interface.network = network_url_for(options[:network])
      interface.subnetwork = subnet_url_for(options[:subnet]) if options[:subnet]
      interface.access_configs = instance_access_configs_for(options[:public_ip])

      Array(interface)
    end

    def instance_access_configs_for(public_ip)
      return [] if public_ip.nil? || public_ip.match(/none/i)

      access_config = Google::Apis::ComputeV1::AccessConfig.new
      access_config.name = "External NAT"
      access_config.type = "ONE_TO_ONE_NAT"
      access_config.nat_ip = public_ip if valid_ip_address?(public_ip)

      Array(access_config)
    end

    def network_url_for(network)
      "projects/#{project}/global/networks/#{network}"
    end

    def subnet_url_for(subnet)
      "projects/#{project}/regions/#{region}/subnetworks/#{subnet}"
    end

    def instance_scheduling_for(options)
      scheduling = Google::Apis::ComputeV1::Scheduling.new
      scheduling.automatic_restart   = options[:auto_restart].to_s
      scheduling.on_host_maintenance = migrate_setting_for(options[:auto_migrate])
      scheduling.preemptible         = options[:preemptible].to_s

      scheduling
    end

    def migrate_setting_for(auto_migrate)
      auto_migrate ? "MIGRATE" : "TERMINATE"
    end

    def instance_service_accounts_for(options)
      return if options[:service_account_scopes].nil? || options[:service_account_scopes].empty?

      service_account = Google::Apis::ComputeV1::ServiceAccount.new
      service_account.email  = options[:service_account_name]
      service_account.scopes = options[:service_account_scopes].map { |scope| service_account_scope_url(scope) }

      Array(service_account)
    end

    def service_account_scope_url(scope)
      return scope if scope.start_with?("https://www.googleapis.com/auth/")

      "https://www.googleapis.com/auth/#{translate_scope_alias(scope)}"
    end

    def translate_scope_alias(scope_alias)
      SCOPE_ALIAS_MAP.fetch(scope_alias, scope_alias)
    end

    def instance_tags_for(tags)
      return if tags.nil? || tags.empty?

      tag_obj = Google::Apis::ComputeV1::Tags.new
      tag_obj.items = tags

      tag_obj
    end

    def network_for(instance)
      instance.network_interfaces.first.network.split("/").last
    rescue NoMethodError
      "unknown"
    end

    def machine_type_for(instance)
      instance.machine_type.split("/").last
    end

    def server_summary(server, _columns_with_info = nil)
      msg_pair("Instance Name", server.name)
      msg_pair("Status", server.status)
      msg_pair("Machine Type", machine_type_for(server))
      msg_pair("Project", project)
      msg_pair("Zone", zone)
      msg_pair("Network", network_for(server))
      msg_pair("Private IP", private_ip_for(server))
      msg_pair("Public IP", public_ip_for(server))
    end

    def public_project_for_image(image)
      case image
      when /centos/
        "centos-cloud"
      when /container-vm/
        "google-containers"
      when /coreos/
        "coreos-cloud"
      when /debian/
        "debian-cloud"
      when /opensuse-cloud/
        "opensuse-cloud"
      when /rhel/
        "rhel-cloud"
      when /sles/
        "suse-cloud"
      when /ubuntu/
        "ubuntu-os-cloud"
      when /windows/
        "windows-cloud"
      end
    end

    def disk_type_url_for(type)
      "zones/#{zone}/diskTypes/#{type}"
    end

    def paginated_results(api_method, items_method, *args)
      items      = []
      next_token = nil
      loop_num   = 0

      loop do
        loop_num += 1

        response       = connection.send(api_method.to_sym, *args, max_results: max_page_size, page_token: next_token)
        response_items = response.send(items_method.to_sym)

        break if response_items.nil?

        items += response_items

        next_token = response.next_page_token
        break if next_token.nil?

        if loop_num >= max_pages
          ui.warn("Max pages (#{max_pages}) reached, but more results exist - truncating results...")
          break
        end
      end

      items
    end

    def wait_for_status(requested_status)
      last_status = ""

      begin
        Timeout.timeout(wait_time) do
          loop do
            item = yield
            current_status = item.status

            if current_status == requested_status
              print "\n"
              break
            end

            if last_status == current_status
              print "."
            else
              last_status = current_status
              print "\n"
              print "Current status: #{current_status}."
            end

            sleep refresh_rate
          end
        end
      rescue Timeout::Error
        ui.msg("")
        ui.error("Request did not complete in #{wait_time} seconds. Check the Google Cloud Console for more info.")
        exit 1
      end
    end

    def wait_for_operation(operation)
      operation_name = operation.name

      wait_for_status("DONE") { zone_operation(operation_name) }

      errors = operation_errors(operation_name)
      return if errors.empty?

      errors.each do |error|
        ui.error("#{ui.color(error.code, :bold)}: #{error.message}")
      end

      raise "Operation #{operation_name} failed."
    end

    def zone_operation(operation_name)
      connection.get_zone_operation(project, zone, operation_name)
    end

    def operation_errors(operation_name)
      operation = zone_operation(operation_name)
      return [] if operation.error.nil?

      operation.error.errors
    end

    def available_projects
      [project] | PUBLIC_PROJECTS
    end
  end
end
