#
# An abstractions in front of Fog, which is an abstraction in front of
# the AWS api. Oh my!
#
# A Cloud object binds a particular node with particular Fog
# authentication credentials.
#
# NOTE: Possible AWS options for creating instances:
#
# options = {
#   'BlockDeviceMapping'          => block_device_mapping,
#   'NetworkInterfaces'           => network_interfaces,
#   'ClientToken'                 => client_token,
#   'DisableApiTermination'       => disable_api_termination,
#   'EbsOptimized'                => ebs_optimized,
#   'IamInstanceProfile.Arn'      => @iam_instance_profile_arn,
#   'IamInstanceProfile.Name'     => @iam_instance_profile_name,
#   'InstanceInitiatedShutdownBehavior' => instance_initiated_shutdown_behavior,
#   'InstanceType'                => flavor_id,
#   'KernelId'                    => kernel_id,
#   'KeyName'                     => key_name,
#   'Monitoring.Enabled'          => monitoring,
#   'Placement.AvailabilityZone'  => availability_zone,
#   'Placement.GroupName'         => placement_group,
#   'Placement.Tenancy'           => tenancy,
#   'PrivateIpAddress'            => private_ip_address,
#   'RamdiskId'                   => ramdisk_id,
#   'SecurityGroup'               => groups,
#   'SecurityGroupId'             => security_group_ids,
#   'SubnetId'                    => subnet_id,
#   'UserData'                    => user_data,
# }
#

module LeapCli
  class Cloud
    DEFAULT_INSTANCE_OPTIONS = {
      "InstanceType" => "t2.nano"
    }
    LEAP_SG_NAME = 'leap_default'
    LEAP_SG_DESC = 'Default security group for LEAP nodes'

    include LeapCli::LogCommand

    attr_reader :compute  # Fog::Compute object
    attr_reader :node     # Config::Node, if any
    attr_reader :options  # options for the VMs, if any
    attr_reader :image    # which vm image to use, if any
    attr_reader :name     # name of which entry in cloud.json to use

    def initialize(name, conf, node=nil)
      @node = node
      @name = name
      @conf = conf
      @compute = nil
      @options = DEFAULT_INSTANCE_OPTIONS
      @image = nil

      raise ArgumentError, 'name missing' unless @name
      raise ArgumentError, 'config missing' unless @conf
      raise ArgumentError, 'config auth missing' unless @conf["auth"]
      raise ArgumentError, 'config auth missing' unless @conf["vendor"]

      credentials = @conf["auth"].symbolize_keys
      credentials[:provider] = @conf["vendor"]
      @compute = Fog::Compute.new(credentials)

      if @conf['default_options']
        @options = @options.merge(@conf['default_options'])
      end
      @image = @conf['default_image'] || Cloud.aws_image(credentials[:region])
      if @node
        @options = @options.merge(node.vm.options) if node['vm.options']
        @image   = node.vm.image if node['vm.image']
      end

      unless @options['InstanceType']
        raise ArgumentError, 'VM instance type is required. See https://leap.se/virtual-machines for more information.'
      end
      unless @image
        raise ArgumentError, 'VM image is required. See https://leap.se/virtual-machines for more information.'
      end
    end

    #
    # fetches or creates a server for this cloud object.
    #
    def fetch_or_create_server(options)
      fetch_server_for_node || create_new_vm_instance(choose_ssh_key: options[:choose_ssh_key])
    end

    #
    # fetches the server for a particular node.
    #
    # return nil if this cloud object has no node, or there is no corresponding
    # server.
    #
    def fetch_server_for_node(bail_on_failure=false)
      server = nil
      return nil unless @node

      # does an instance exist that matches the node's vm.id?
      if @node.vm_id?
        instance_id = @node.vm.id
        server = @compute.servers.get(instance_id)
      end

      # does an instance exist that is tagged with this node name?
      if server.nil?
        response = @compute.describe_instances({"tag:node_name" => @node.name})
        # puts JSON.pretty_generate(response.body)
        if !response.body["reservationSet"].empty?
          instances = response.body["reservationSet"].first["instancesSet"]
          if instances.size > 1
            bail! "There are multiple VMs with the same node name tag! Manually remove one before continuing."
          elsif instances.size == 1
            instance_id = instances.first["instanceId"]
            server = @compute.servers.get(instance_id)
          end
        end
      end

      if server.nil? && bail_on_failure
        bail! :error, "A virtual machine could not be found for node `#{@node.name}`. Things to try:" do
          log "check the output of `leap vm status`"
          log "check the value of `vm.id` in #{@node.name}.json"
          log "run `leap vm add #{@node.name}` to create a corresponding virtual machine"
        end
      end

      return server
    end

    #
    # associates a node with a vm
    #
    def bind_server_to_node(server)
      unless @node
        raise ArgumentError, 'no node'
      end
      if server.public_ip_address.nil?
        bail! do
          log 'The virtual machine `%s` must have an IP address in order to bind it to the configuration `%s`.' % [
            server.id, Path.relative_path(Path.named_path([:node_config, @node.name]))]
          log 'To fix, run `leap vm start %s`' % server.id
        end
      end

      # assign tag
      @compute.create_tags(server.id, {'node_name' => @node.name})
      log :created, "association between node '%s' and vm '%s'" % [@node.name, server.id]

      # update node json
      @node.update_json({
        "ip_address" => server.public_ip_address,
        "vm"=> {"id"=>server.id}
      })
    end

    #
    # disassociates a node from a vm
    #
    def unbind_server_from_node(server)
      raise ArgumentError, 'no node' unless @node

      # assign tag
      @compute.delete_tags(server.id, {'node_name' => @node.name})
      log :removed, "association between node '%s' and vm '%s'" % [@node.name, server.id]

      # update node json
      @node.update_json({
        "ip_address" => '0.0.0.0',
        "vm"=> {"id" => ""}
      })
    end

    #
    # return an AWS KeyPair object, potentially uploading it to the server
    # if necessary.
    #
    # this is used when initially creating the vm. After the first `node init`, then
    # all sysadmins should have access to the server.
    #
    # NOTE: ssh and aws use different types of fingerprint
    #
    def find_or_create_key_pair(pick_ssh_key_method)
      require 'leap_cli/ssh'
      key_pair, local_key = match_ssh_key(:user_only => true)
      if key_pair
        log :using, "user SSH key #{local_key.filename}" do
          log 'AWS MD5 fingerprint: ' + local_key.fingerprint(:digest => :md5, :type => :der, :encoding => :hex)
          log 'SSH MD5 fingerprint: ' + local_key.fingerprint(:digest => :md5, :type => :ssh, :encoding => :hex)
          log 'SSH SHA256 fingerprint: ' + local_key.fingerprint(:digest => :sha256, :type => :ssh, :encoding => :base64)
        end
      elsif key_pair.nil?
        username, key = pick_ssh_key_method.call(self)
        key_pair = upload_ssh_key(username, key)
      end
      return key_pair
    end

    #
    # checks if there is a match between a local key and a registered key_pair
    #
    # options:
    #   :key_pair -- limit comparisons to this key_pair object.
    #   :user_only -- limit comparisons to the user's ~/.ssh directory only
    #
    # returns:
    #
    #   key_pair -- an AWS KeyPair
    #   local_key -- a LeapCLi::SSH::Key
    #
    def match_ssh_key(options={})
      key_pair = options[:key_pair]
      local_keys_to_check = LeapCli::SSH::Key.my_public_keys
      unless options[:user_only]
        local_keys_to_check += LeapCli::SSH::Key.provider_public_keys
      end
      fingerprints = Hash[local_keys_to_check.map {|k|
        [k.fingerprint(:digest => :md5, :type => :der, :encoding => :hex), k]
      }]
      key_pair ||= @compute.key_pairs.select {|key_pair|
        fingerprints.include?(key_pair.fingerprint)
      }.first
      if key_pair
        local_key = fingerprints[key_pair.fingerprint]
        return key_pair, local_key
      else
        return nil, nil
      end
    end

    def wait_for_ssh_host_key(server)
      require 'leap_cli/ssh'
      return nil if Fog.mock?
      tries = 0
      host_key = nil
      cloud = self
      server.wait_for {
        if tries > 0
          LeapCli.log :waiting, "for SSH host key..."
        elsif tries > 20
          return nil
        end
        tries += 1
        ssh_host_keys = cloud.ssh_host_keys(server)
        if ssh_host_keys.nil?
          false
        else
          host_key = SSH::Key.pick_best_key(ssh_host_keys)
          true
        end
      }
      return host_key
    end

    #
    # checks the console of the server for the ssh host keys
    #
    # returns nil if they cannot be found.
    #
    def ssh_host_keys(server)
      require 'leap_cli/ssh'
      return nil if Fog.mock?
      response = @compute.get_console_output(server.id)
      output = response.body["output"]
      if output.nil?
        return nil
      end
      keys = output.match(
        /-----BEGIN SSH HOST KEY KEYS-----(.*)-----END SSH HOST KEY KEYS-----/m
      )
      if keys.nil?
        return nil
      else
        ssh_key_list = keys[1].strip.split("\r\n").map {|key_str|
          SSH::Key.load(key_str)
        }
        return ssh_key_list.compact
      end
    end

    private

    #
    # Every AWS instance requires a security group, which is just a simple firewall.
    # In the future, we could create a separate security group for each node,
    # and set the rules to match what the rules should be for that node.
    #
    # However, for now, we just use a security group 'leap_default' that opens
    # all the ports.
    #
    # The default behavior for AWS security groups is:
    # all ingress traffic is blocked and all egress traffic is allowed.
    #
    def find_or_create_security_group
      group = @compute.security_groups.get(LEAP_SG_NAME)
      if group.nil?
        group = @compute.security_groups.create(
          :name => LEAP_SG_NAME,
          :description => LEAP_SG_DESC
        )
        group.authorize_port_range(0..65535,
          :ip_protocol => 'tcp',
          :cidr_ip => '0.0.0.0/0',
        )
        group.authorize_port_range(0..65535,
          :ip_protocol => 'udp',
          :cidr_ip => '0.0.0.0/0',
        )
      end
      return group
    end

    #
    # key - a LeapCli::SSH::Key object
    # returns -- AWS KeyPair
    #
    def upload_ssh_key(username, key)
      key_name = 'leap_' + username
      key_pair = @compute.key_pairs.create(
       :name => key_name,
       :public_key => key.public_key.to_s
      )
      log :registered, "public key" do
        log 'cloud provider: ' + @name
        log 'name: ' + key_name
        log 'AWS MD5 fingerprint: ' + key.fingerprint(:digest => :md5, :type => :der, :encoding => :hex)
        log 'SSH MD5 fingerprint: ' + key.fingerprint(:digest => :md5, :type => :ssh, :encoding => :hex)
        log 'SSH SHA256 fingerprint: ' + key.fingerprint(:digest => :sha256, :type => :ssh, :encoding => :base64)
      end
      return key_pair
    end

    def create_new_vm_instance(choose_ssh_key: nil)
      log :creating, "new vm instance..."
      assert! @image, "No image found. Specify `default_image` in cloud.json or `vm.image` in node's config."
      if Fog.mock?
        options = @options
      else
        key_pair       = find_or_create_key_pair(choose_ssh_key)
        security_group = find_or_create_security_group
        options = @options.merge({
          'KeyName' => key_pair.name,
          'SecurityGroup' => security_group.name
        })
      end
      response = @compute.run_instances(
        @image,
        1, # min count
        1, # max count
        options
      )
      instance_id = response.body["instancesSet"].first["instanceId"]
      log :created, "vm with instance id #{instance_id}."
      server = @compute.servers.get(instance_id)
      if server.nil?
        bail! :error, "could not query instance '#{instance_id}'."
      end
      unless Fog.mock?
        tries = 0
        server.wait_for {
          if tries > 0
            LeapCli.log :waiting, "for IP address to be assigned..."
          end
          tries += 1
          !public_ip_address.nil?
        }
        if options[:wait]
          log :waiting, "for vm #{instance_id} to start..."
          server.wait_for { ready? }
          log :started, "#{instance_id} with #{server.public_ip_address}"
        end
      end
      return server
    end

  end
end