require 'ec2/address'
require 'ec2/availability_zone'
require 'ec2/image'
require 'ec2/instance'
require 'ec2/keypair'
require 'ec2/security_group'
require 'ec2/snapshot'
require 'ec2/region'
require 'ec2/volume'

module Awsum
  # Handles all interaction with Amazon EC2
  #
  # ==Getting Started
  # Create an Awsum::Ec2 object and begin calling methods on it.
  #   require 'rubygems'
  #   require 'awsum'
  #   ec2 = Awsum::Ec2.new('your access id', 'your secret key')
  #   images = ec2.my_images
  #   ...
  #
  # All calls to EC2 can be done directly in this class, or through a more object oriented way through the various returned classes
  #
  # ==Examples
  #   ec2.image('ami-ABCDEF').run
  #
  #   ec2.instance('i-123456789').volumes.each do |vol|
  #     vol.create_snapsot
  #   end
  #
  #   ec2.regions.each do |region|
  #     region.use
  #       images.each do |img|
  #         puts "#{img.id} - #{region.name}"
  #       end
  #     end
  #   end
  #
  # ==Errors
  # All methods will raise an Awsum::Error if an error is returned from Amazon
  #
  # ==Missing Methods
  # * ConfirmProductInstance
  # * ModifyImageAttribute
  # * DescribeImageAttribute
  # * ResetImageAttribute
  # If you need any of this functionality, please consider getting involved and help complete this library.
  class Ec2
    include Awsum::Requestable

    # Create an new ec2 instance
    #
    # The access_key and secret_key are both required to do any meaningful work.
    #
    # If you want to get these keys from environment variables, you can do that in your code as follows:
    #   ec2 = Awsum::Ec2.new(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY'])
    def initialize(access_key = nil, secret_key = nil)
      @access_key = access_key
      @secret_key = secret_key
    end

    # Retrieve a list of available Images
    #
    # ===Options:
    # * <tt>:image_ids</tt> - array of Image id's, default: []
    # * <tt>:owners</tt> - array of owner id's, default: []
    # * <tt>:executable_by</tt> - array of user id's who have executable permission, default: []
    def images(options = {})
      options = {:image_ids => [], :owners => [], :executable_by => []}.merge(options)
      action = 'DescribeImages'
      params = {
          'Action' => action
        }
      #Add options
      params.merge!(array_to_params(options[:image_ids], "ImageId"))
      params.merge!(array_to_params(options[:owners], "Owner"))
      params.merge!(array_to_params(options[:executable_by], "ExecutableBy"))

      response = send_query_request(params)
      parser = Awsum::Ec2::ImageParser.new(self)
      parser.parse(response.body)
    end

    # Retrieve all Image(s) owned by you
    def my_images
      images :owners => 'self'
    end

    # Retrieve a single Image
    def image(image_id)
      images(:image_ids => [image_id])[0]
    end

    # Register an Image
    def register_image(image_location)
      action = 'RegisterImage'
      params = {
          'Action'        => action,
          'ImageLocation' => image_location
        }

      response = send_query_request(params)
      parser = Awsum::Ec2::RegisterImageParser.new(self)
      parser.parse(response.body)
    end

    # Deregister an Image. Once deregistered, you can no longer launch the Image
    def deregister_image(image_id)
      action = 'DeregisterImage'
      params = {
          'Action'  => action,
          'ImageId' => image_id
        }

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

    # Launch an ec2 Instance
    #
    # ===Options:
    # * <tt>:min</tt> - The minimum number of instances to launch. Default: 1
    # * <tt>:max</tt> - The maximum number of instances to launch. Default: 1
    # * <tt>:key_name</tt> - The name of the key pair with which to launch instances
    # * <tt>:security_groups</tt> - The names of security groups to associate launched instances with
    # * <tt>:user_data</tt> - User data made available to instances (Note: Must be 16K or less, will be base64 encoded by Awsum)
    # * <tt>:instance_type</tt> - The size of the instances to launch, can be one of [m1.small, m1.large, m1.xlarge, c1.medium, c1.xlarge], default is m1.small
    # * <tt>:availability_zone</tt> - The name of the availability zone to launch this Instance in
    # * <tt>:kernel_id</tt> - The ID of the kernel with which to launch instances
    # * <tt>:ramdisk_id</tt> - The ID of the RAM disk with which to launch instances
    # * <tt>:block_device_map</tt> - A 'hash' of mappings. E.g. {'instancestore0' => 'sdb'}
    def run_instances(image_id, options = {})
      options = {:min => 1, :max => 1}.merge(options)
      action = 'RunInstances'
      params = {
        'Action'                     => action,
        'ImageId'                    => image_id,
        'MinCount'                   => options[:min],
        'MaxCount'                   => options[:max],
        'KeyName'                    => options[:key_name],
        'UserData'                   => options[:user_data].nil? ? nil : Base64::encode64(options[:user_data]).gsub(/\n/, ''),
        'InstanceType'               => options[:instance_type],
        'Placement.AvailabilityZone' => options[:availability_zone],
        'KernelId'                   => options[:kernel_id],
        'RamdiskId'                  => options[:ramdisk_id]
      }
      if options[:block_device_map].respond_to?(:keys)
        map = options[:block_device_map]
        map.keys.each_with_index do |key, i|
          params["BlockDeviceMapping.#{i+1}.VirtualName"] = key
          params["BlockDeviceMapping.#{i+1}.DeviceName"] = map[key]
        end
      else
        raise ArgumentError.new("options[:block_device_map] - must be a key => value map") unless options[:block_device_map].nil?
      end
      params.merge!(array_to_params(options[:security_groups], "SecurityGroup"))

      response = send_query_request(params)
      parser = Awsum::Ec2::InstanceParser.new(self)
      parser.parse(response.body)
    end
    alias_method :launch_instances, :run_instances

    #Retrieve the information on a number of Instance(s)
    def instances(*instance_ids)
      action = 'DescribeInstances'
      params = {
        'Action' => action
      }
      params.merge!(array_to_params(instance_ids, 'InstanceId'))

      response = send_query_request(params)
      parser = Awsum::Ec2::InstanceParser.new(self)
      parser.parse(response.body)
    end

    #Retrieve the information on a single Instance
    def instance(instance_id)
      instances([instance_id])[0]
    end

    # Retrieves the currently running Instance
    # This should only be run on a running EC2 instance
    def me
      require 'open-uri'
      begin
        instance_id = open('http://169.254.169.254/latest/meta-data/instance-id').read
        instance instance_id
      rescue OpenURI::HTTPError => e
        nil
      end
    end

    # Retreives the user-data supplied when starting the currently running Instance
    # This should only be run on a running EC2 instance
    def user_data
      require 'open-uri'
      begin
        open('http://169.254.169.254/latest/user-data').read
      rescue OpenURI::HTTPError => e
        nil
      end
    end

    # Terminates the Instance(s)
    #
    # Returns true if the terminations succeeds, false otherwise
    def terminate_instances(*instance_ids)
      action = 'TerminateInstances'
      params = {
        'Action' => action
      }
      params.merge!(array_to_params(instance_ids, 'InstanceId'))

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

    #Retrieve the information on a number of Volume(s)
    def volumes(*volume_ids)
      action = 'DescribeVolumes'
      params = {
        'Action' => action
      }
      params.merge!(array_to_params(volume_ids, 'VolumeId'))

      response = send_query_request(params)
      parser = Awsum::Ec2::VolumeParser.new(self)
      parser.parse(response.body)
    end

    # Retreive information on a Volume
    def volume(volume_id)
      volumes(volume_id)[0]
    end

    # Create a new volume
    #
    # ===Options:
    # * <tt>:size</tt> - The size of the volume to be created (in GB) (<b>NOTE:</b> Required if you are not creating from a snapshot)
    # * <tt>:snapshot_id</tt> - The snapshot id from which to create the volume
    #
    def create_volume(availability_zone, options = {})
      raise ArgumentError.new('You must specify a size if not creating a volume from a snapshot') if options[:snapshot_id].blank? && options[:size].blank?

      action = 'CreateVolume'
      params = {
        'Action'           => action,
        'AvailabilityZone' => availability_zone
      }
      params['Size'] = options[:size] unless options[:size].blank?
      params['SnapshotId'] = options[:snapshot_id] unless options[:snapshot_id].blank?

      response = send_query_request(params)
      parser = Awsum::Ec2::VolumeParser.new(self)
      parser.parse(response.body)[0]
    end

    # Attach a volume to an instance
    def attach_volume(volume_id, instance_id, device = '/dev/sdh')
      action = 'AttachVolume'
      params = {
        'Action'     => action,
        'VolumeId'   => volume_id,
        'InstanceId' => instance_id,
        'Device'     => device
      }

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

    # Detach a volume from an instance
    #
    # ===Options
    # * <tt>:instance_id</tt> - The ID of the instance from which the volume will detach
    # * <tt>:device</tt> - The device name
    # * <tt>:force</tt> - Whether to force the detachment. <b>NOTE:</b> If forced you may have data corruption issues.
    def detach_volume(volume_id, options = {})
      action = 'DetachVolume'
      params = {
        'Action'     => action,
        'VolumeId'   => volume_id
      }
      params['InstanceId'] = options[:instance_id] unless options[:instance_id].blank?
      params['Device'] = options[:device] unless options[:device].blank?
      params['Force'] = options[:force] unless options[:force].blank?

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

    # Delete a volume
    def delete_volume(volume_id)
      action = 'DeleteVolume'
      params = {
        'Action'     => action,
        'VolumeId'   => volume_id
      }

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

    # Create a Snapshot of a Volume
    def create_snapshot(volume_id)
      action = 'CreateSnapshot'
      params = {
        'Action'   => action,
        'VolumeId' => volume_id
      }

      response = send_query_request(params)
      parser = Awsum::Ec2::SnapshotParser.new(self)
      parser.parse(response.body)[0]
    end

    # List Snapshot(s)
    def snapshots(*snapshot_ids)
      action = 'DescribeSnapshots'
      params = {
        'Action' => action
      }
      params.merge!(array_to_params(snapshot_ids, 'SnapshotId'))

      response = send_query_request(params)
      parser = Awsum::Ec2::SnapshotParser.new(self)
      parser.parse(response.body)
    end

    # Get the information about a Snapshot
    def snapshot(snapshot_id)
      snapshots(snapshot_id)[0]
    end

    # Delete a Snapshot
    def delete_snapshot(snapshot_id)
      action = 'DeleteSnapshot'
      params = {
        'Action'     => action,
        'SnapshotId' => snapshot_id
      }

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

    # List all AvailabilityZone(s)
    def availability_zones(*zone_names)
      action = 'DescribeAvailabilityZones'
      params = {
        'Action' => action
      }
      params.merge!(array_to_params(zone_names, 'ZoneName'))

      response = send_query_request(params)
      parser = Awsum::Ec2::AvailabilityZoneParser.new(self)
      parser.parse(response.body)
    end

    # List all Region(s)
    def regions(*region_names)
      action = 'DescribeRegions'
      params = {
        'Action' => action
      }
      params.merge!(array_to_params(region_names, 'Region'))

      response = send_query_request(params)
      parser = Awsum::Ec2::RegionParser.new(self)
      parser.parse(response.body)
    end

    # List a Region
    def region(region_name)
      regions(region_name)[0]
    end

    # List Addresses
    def addresses(*public_ips)
      action = 'DescribeAddresses'
      params = {
        'Action' => action
      }
      params.merge!(array_to_params(public_ips, 'PublicIp'))

      response = send_query_request(params)
      parser = Awsum::Ec2::AddressParser.new(self)
      parser.parse(response.body)
    end

    # Get the Address with a specific public ip
    def address(public_ip)
      addresses(public_ip)[0]
    end

    # Allocate Address
    #
    # Will aquire an elastic ip address for use with your account
    def allocate_address
      action = 'AllocateAddress'
      params = {
        'Action' => action
      }

      response = send_query_request(params)
      parser = Awsum::Ec2::AddressParser.new(self)
      parser.parse(response.body)[0]
    end

    # Associate Address
    #
    # Will link an allocated elastic ip address to an Instance
    #
    # <b>NOTE:</b> If the ip address is already associated with another instance, it will be associated with the new instance.
    #
    # You can run this command more than once and it will not return an error.
    def associate_address(instance_id, public_ip)
      action = 'AssociateAddress'
      params = {
        'Action'     => action,
        'InstanceId' => instance_id,
        'PublicIp'   => public_ip
      }

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

    # Disassociate Address
    #
    # Will disassociate an allocated elastic ip address from the Instance it's allocated to
    #
    # <b>NOTE:</b> You can run this command more than once and it will not return an error.
    def disassociate_address(public_ip)
      action = 'DisassociateAddress'
      params = {
        'Action'     => action,
        'PublicIp'   => public_ip
      }

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

    # Releases an associated Address
    #
    # <b>NOTE:</b> This is not a direct call to the Amazon web service. This is a safe operation that will first check to see if the address is allocated to an instance and fail if it is
    def release_address(public_ip)
      address = address(public_ip)

      if address.instance_id.nil?
        action = 'ReleaseAddress'
        params = {
          'Action'   => action,
          'PublicIp' => public_ip
        }

        response = send_query_request(params)
        response.is_a?(Net::HTTPSuccess)
      else
        raise 'Address is currently allocated' #FIXME: Add a proper Awsum error here
      end
    end

    # Releases an associated Address
    #
    # <b>NOTE:</b> This will disassociate an address automatically if it is associated with an instance
    def release_address!(public_ip)
      action = 'ReleaseAddress'
      params = {
        'Action'   => action,
        'PublicIp' => public_ip
      }

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end
    
    # List KeyPair(s)
    def key_pairs(*key_names)
      action = 'DescribeKeyPairs'
      params = {
        'Action' => action
      }
      params.merge!(array_to_params(key_names, 'KeyName'))

      response = send_query_request(params)
      parser = Awsum::Ec2::KeyPairParser.new(self)
      parser.parse(response.body)
    end

    # Get a single KeyPair
    def key_pair(key_name)
      key_pairs(key_name)[0]
    end
    
    # Create a new KeyPair
    def create_key_pair(key_name)
      action = 'CreateKeyPair'
      params = {
        'Action'  => action,
        'KeyName' => key_name
      }

      response = send_query_request(params)
      parser = Awsum::Ec2::KeyPairParser.new(self)
      parser.parse(response.body)[0]
    end
    
    # Delete a KeyPair
    def delete_key_pair(key_name)
      action = 'DeleteKeyPair'
      params = {
        'Action'  => action,
        'KeyName' => key_name
      }

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end
    
    # List SecurityGroup(s)
    def security_groups(*group_names)
      action = 'DescribeSecurityGroups'
      params = {
        'Action' => action
      }
      params.merge!(array_to_params(group_names, 'GroupName'))

      response = send_query_request(params)
      parser = Awsum::Ec2::SecurityGroupParser.new(self)
      parser.parse(response.body)
    end

    # Get a single SecurityGroup
    def security_group(group_name)
      security_groups(group_name)[0]
    end
    
    # Create a new SecurityGroup
    def create_security_group(name, description)
      action = 'CreateSecurityGroup'
      params = {
        'Action'           => action,
        'GroupName'        => name,
        'GroupDescription' => description
      }

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end
    
    # Delete a SecurityGroup
    def delete_security_group(group_name)
      action = 'DeleteSecurityGroup'
      params = {
        'Action'  => action,
        'GroupName' => group_name
      }

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

    # Authorize access on a specific security group
    #
    # ===Options:
    # ====User/Group access
    # * <tt>:source_security_group_name</tt> - Name of the security group to authorize access to when operating on a user/group pair
    # * <tt>:source_security_group_owner_id</tt> - Owner of the security group to authorize access to when operating on a user/group pair
    # ====CIDR IP access
    # * <tt>:ip_protocol</tt> - IP protocol to authorize access to when operating on a CIDR IP (tcp, udp or icmp) (default: tcp)
    # * <tt>:from_port</tt> - Bottom of port range to authorize access to when operating on a CIDR IP. This contains the ICMP type if ICMP is being authorized.
    # * <tt>:to_port</tt> - Top of port range to authorize access to when operating on a CIDR IP. This contains the ICMP type if ICMP is being authorized.
    # * <tt>:cidr_ip</tt> - CIDR IP range to authorize access to when operating on a CIDR IP. (default: 0.0.0.0/0)
    def authorize_security_group_ingress(group_name, options = {})
      got_at_least_one_user_group_option = !options[:source_security_group_name].nil? || !options[:source_security_group_owner_id].nil?
      got_user_group_options = !options[:source_security_group_name].nil? && !options[:source_security_group_owner_id].nil?
      got_at_least_one_cidr_option = !options[:ip_protocol].nil? || !options[:from_port].nil? || !options[:to_port].nil? || !options[:cidr_ip].nil?
      #Add in defaults
      options = {:cidr_ip => '0.0.0.0/0'}.merge(options) if got_at_least_one_cidr_option
      options = {:ip_protocol => 'tcp'}.merge(options) if got_at_least_one_cidr_option
      got_cidr_options = !options[:ip_protocol].nil? && !options[:from_port].nil? && !options[:to_port].nil? && !options[:cidr_ip].nil?
      raise ArgumentError.new('Can only authorize user/group or CIDR IP, not both') if got_at_least_one_user_group_option && got_at_least_one_cidr_option
      raise ArgumentError.new('Need all user/group options when authorizing user/group access') if got_at_least_one_user_group_option && !got_user_group_options
      raise ArgumentError.new('Need all CIDR IP options when authorizing CIDR IP access') if got_at_least_one_cidr_option && !got_cidr_options
      raise ArgumentError.new('ip_protocol can only be one of tcp, udp or icmp') if got_at_least_one_cidr_option && !%w(tcp udp icmp).detect{|p| p == options[:ip_protocol] }

      action = 'AuthorizeSecurityGroupIngress'
      params = {
        'Action'    => action,
        'GroupName' => group_name
      }
      params['SourceSecurityGroupName'] = options[:source_security_group_name] unless options[:source_security_group_name].nil?
      params['SourceSecurityGroupOwnerId'] = options[:source_security_group_owner_id] unless options[:source_security_group_owner_id].nil?
      params['IpProtocol'] = options[:ip_protocol] unless options[:ip_protocol].nil?
      params['FromPort'] = options[:from_port] unless options[:from_port].nil?
      params['ToPort'] = options[:to_port] unless options[:to_port].nil?
      params['CidrIp'] = options[:cidr_ip] unless options[:cidr_ip].nil?

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

    # Revoke access on a specific SecurityGroup
    #
    # ===Options:
    # ====User/Group access
    # * <tt>:source_security_group_name</tt> - Name of the security group to authorize access to when operating on a user/group pair
    # * <tt>:source_security_group_owner_id</tt> - Owner of the security group to authorize access to when operating on a user/group pair
    # ====CIDR IP access
    # * <tt>:ip_protocol</tt> - IP protocol to authorize access to when operating on a CIDR IP (tcp, udp or icmp) (default: tcp)
    # * <tt>:from_port</tt> - Bottom of port range to authorize access to when operating on a CIDR IP. This contains the ICMP type if ICMP is being authorized.
    # * <tt>:to_port</tt> - Top of port range to authorize access to when operating on a CIDR IP. This contains the ICMP type if ICMP is being authorized.
    # * <tt>:cidr_ip</tt> - CIDR IP range to authorize access to when operating on a CIDR IP. (default: 0.0.0.0/0)
    def revoke_security_group_ingress(group_name, options = {})
      got_at_least_one_user_group_option = !options[:source_security_group_name].nil? || !options[:source_security_group_owner_id].nil?
      got_user_group_options = !options[:source_security_group_name].nil? && !options[:source_security_group_owner_id].nil?
      got_at_least_one_cidr_option = !options[:ip_protocol].nil? || !options[:from_port].nil? || !options[:to_port].nil? || !options[:cidr_ip].nil?
      #Add in defaults
      options = {:cidr_ip => '0.0.0.0/0'}.merge(options) if got_at_least_one_cidr_option
      options = {:ip_protocol => 'tcp'}.merge(options) if got_at_least_one_cidr_option
      got_cidr_options = !options[:ip_protocol].nil? && !options[:from_port].nil? && !options[:to_port].nil? && !options[:cidr_ip].nil?
      raise ArgumentError.new('Can only authorize user/group or CIDR IP, not both') if got_at_least_one_user_group_option && got_at_least_one_cidr_option
      raise ArgumentError.new('Need all user/group options when revoking user/group access') if got_at_least_one_user_group_option && !got_user_group_options
      raise ArgumentError.new('Need all CIDR IP options when revoking CIDR IP access') if got_at_least_one_cidr_option && !got_cidr_options
      raise ArgumentError.new('ip_protocol can only be one of tcp, udp or icmp') if got_at_least_one_cidr_option && !%w(tcp udp icmp).detect{|p| p == options[:ip_protocol] }

      action = 'RevokeSecurityGroupIngress'
      params = {
        'Action'    => action,
        'GroupName' => group_name
      }
      params['SourceSecurityGroupName'] = options[:source_security_group_name] unless options[:source_security_group_name].nil?
      params['SourceSecurityGroupOwnerId'] = options[:source_security_group_owner_id] unless options[:source_security_group_owner_id].nil?
      params['IpProtocol'] = options[:ip_protocol] unless options[:ip_protocol].nil?
      params['FromPort'] = options[:from_port] unless options[:from_port].nil?
      params['ToPort'] = options[:to_port] unless options[:to_port].nil?
      params['CidrIp'] = options[:cidr_ip] unless options[:cidr_ip].nil?

      response = send_query_request(params)
      response.is_a?(Net::HTTPSuccess)
    end

#private
    #The host to make all requests against
    def host
      @host ||= 'ec2.amazonaws.com'
    end

    def host=(host)
      @host = host
    end
  end
end