module LeapCli; module Commands

  desc "Manage remote virtual machines (VMs)."
  long_desc "This command provides a convenient way to manage virtual machines. " +
            "FILTER may be a node filter or the ID of a virtual machine."

  command [:vm] do |vm|
    vm.switch :mock, :desc => "Run as simulation, without actually connecting to a cloud provider. If set, --auth is ignored."
    vm.switch :wait, :desc => "Wait for servers to start/stop before continuing."
    vm.flag :auth,  :arg_name => 'AUTH',
      :desc => "Choose which authentication credentials to use from the file cloud.json. "+
               "If omitted, will default to the node's `vm.auth` property, or the first credentials in cloud.json"

    vm.desc "Allocates a new VM and/or associates it with node NAME."
    vm.long_desc "If node configuration file does not yet exist, "+
            "it is created with the optional SEED values. "+
            "You can run this command when the virtual machine already exists "+
            "in order to update the node's `vm.id` property."
    vm.arg_name 'NODE_NAME [SEED]'
    vm.command :add do |cmd|
      cmd.action do |global, options, args|
        do_vm_add(global, options, args)
      end
    end

    vm.desc 'Starts one or more VMs'
    vm.arg_name 'FILTER', :optional => true
    vm.command :start do |start|
      start.action do |global, options, args|
        do_vm_start(global, options, args)
      end
    end

    vm.desc 'Shuts down one or more VMs'
    vm.long_desc 'This keeps the storage allocated. To save resources, run `leap vm rm` instead.'
    vm.arg_name 'FILTER', :optional => true
    vm.command :stop do |stop|
      stop.action do |global, options, args|
        do_vm_stop(global, options, args)
      end
    end

    vm.desc 'Destroys one or more VMs'
    vm.arg_name 'FILTER', :optional => true
    vm.command :rm do |rm|
      rm.action do |global, options, args|
        do_vm_rm(global, options, args)
      end
    end

    vm.desc 'Print the status of all VMs'
    vm.arg_name 'FILTER', :optional => true
    vm.command [:status, :ls] do |status|
      status.action do |global, options, args|
        do_vm_status(global, options, args)
      end
    end

    vm.desc "Binds a running VM instance to a node configuration."
    vm.long_desc "Afterwards, the VM will be assigned a label matching the node name, "+
            "and the node config will be updated with the instance ID."
    vm.arg_name 'NODE_NAME INSTANCE_ID'
    vm.command 'bind' do |cmd|
      cmd.action do |global, options, args|
        do_vm_bind(global, options, args)
      end
    end

    vm.desc "Registers a SSH public key for use when creating new VMs."
    vm.long_desc "Note that only people who are creating new VM instances need to "+
            "have their key registered."
    vm.command 'key-register' do |cmd|
      cmd.action do |global, options, args|
        do_vm_key_register(global, options, args)
      end
    end

    vm.desc "Lists the registered SSH public keys for a particular VM provider."
    vm.command 'key-list' do |cmd|
      cmd.action do |global, options, args|
        do_vm_key_list(global, options, args)
      end
    end

    #vm.desc 'Saves the current state of the virtual machine as a new snapshot.'
    #vm.arg_name 'FILTER', :optional => true
    #vm.command :save do |save|
    #  save.action do |global, options, args|
    #    do_vm_save(global, options, args)
    #  end
    #end

    #vm.desc 'Resets virtual machine(s) to the last saved snapshot'
    #vm.arg_name 'FILTER', :optional => true
    #vm.command :reset do |reset|
    #  reset.action do |global, options, args|
    #    do_vm_reset(global, options, args)
    #  end
    #end

    #vm.desc 'Lists the available images.'
    #vm.command 'image-list' do |cmd|
    #  cmd.action do |global, options, args|
    #    do_vm_image_list(global, options, args)
    #  end
    #end
  end

  ##
  ## SHARED UTILITY METHODS
  ##

  protected

  #
  # a callback used if we need to upload a new ssh key
  #
  def choose_ssh_key_for_upload(cloud)
    puts
    bail! unless agree("The cloud provider `#{cloud.name}` does not have "+
          "your public key. Do you want to upload one? ")
    key = pick_ssh_key
    username = ask("username? ", :default => `whoami`.strip)
    assert!(username && !username.empty? && username =~ /[0-9a-z_-]+/, "Username must consist of one or more letters or numbers")
    puts
    return username, key
  end

  def bind_server_to_node(vm_id, node, options={})
    cloud  = new_cloud_handle(node, options)
    server = cloud.compute.servers.get(vm_id)
    assert! server, "Could not find a VM instance with ID '#{vm_id}'"
    cloud.bind_server_to_node(server)
  end

  ##
  ## COMMANDS
  ##

  protected

  #
  # entirely removes the vm, not just stopping it.
  #
  # This might be additionally called by the 'leap node rm' command.
  #
  def do_vm_rm(global, options, args)
    servers_from_args(global, options, args) do |cloud, server|
      cloud.unbind_server_from_node(server) if cloud.node
      destroy_server(server, options[:wait])
    end
  end

  private

  def do_vm_status(global, options, args)
    cloud = new_cloud_handle(nil, options)
    servers = cloud.compute.servers

    #
    # PRETTY TABLE
    #
    t = LeapCli::Util::ConsoleTable.new
    t.table do
      t.row(color: :cyan) do
        t.column "ID"
        t.column "NODE"
        t.column "STATE"
        t.column "FLAVOR"
        t.column "IP"
        t.column "ZONE"
      end
      servers.each do |server|
        t.row do
          t.column server.id
          t.column server.tags["node_name"]
          t.column server.state, :color => state_color(server.state)
          t.column server.flavor_id
          t.column server.public_ip_address
          t.column server.availability_zone
        end
      end
    end
    puts
    t.draw_table

    #
    # SANITY CHECKS
    #
    servers.each do |server|
      name = server.tags["node_name"]
      if name
        node = manager.nodes[name]
        if node.nil?
          log :warning, 'A virtual machine has the name `%s`, but there is no corresponding node definition in `%s`.' % [
            name, relative_path(path([:node_config, name]))]
          next
        end
        if node['vm'].nil?
          log :warning, 'Node `%s` is not configured as a virtual machine' % name do
            log 'You should fix this with `leap vm bind %s %s`' % [name, server.id]
          end
          next
        end
        if node['vm.id'] != server.id
          message = 'Node `%s` is configured with virtual machine id `%s`' % [name, node['vm.id']]
          log :warning, message do
            log 'But the virtual machine with that name really has id `%s`' % server.id
            log 'You should fix this with `leap vm bind %s %s`' % [name, server.id]
          end
        end
        if server.state == 'running'
          if node.ip_address != server.public_ip_address
            message = 'The configuration file for node `%s` has IP address `%s`' % [name, node.ip_address]
            log(:warning, message) do
              log 'But the virtual machine actually has IP address `%s`' % server.public_ip_address
              log 'You should fix this with `leap vm add %s`' % name
            end
          end
        end
      end
    end
    manager.filter(['vm']).each_node do |node|
      if node['vm.id'].nil?
        log :warning, 'The node `%s` is missing a server id' % node.name
        next
      end
      if !servers.detect {|s| s.id == node.vm.id }
        message = "The configuration file for node `%s` has virtual machine id of `%s`" % [node.name, node.vm.id]
        log :warning, message do
          log "But that does not match any actual virtual machines!"
        end
      end
      if !servers.detect {|s| s.tags["node_name"] == node.name }
        log :warning, "The node `%s` has no virtual machines with a matching name." % node.name do
          server = servers.detect {|s| s.id == node.vm.id }
          if server
            log 'Run `leap bind %s %s` to fix this' % [node.name, server.id]
          end
        end
      end
    end
  end

  def do_vm_add(global, options, args)
    name = args.first
    if manager.nodes[name].nil?
      do_node_add(global, {:ip_address => '0.0.0.0'}.merge(options), args)
    end
    node   = manager.nodes[name]
    cloud  = new_cloud_handle(node, options)
    server = cloud.fetch_or_create_server(:choose_ssh_key => method(:choose_ssh_key_for_upload))

    if server
      cloud.bind_server_to_node(server)
      ssh_host_key = cloud.wait_for_ssh_host_key(server)
      if ssh_host_key.nil?
        log :warning, "We could not get a SSH host key." do
          log "Try running `leap vm add #{node.name}` again later."
        end
      else
        log :saving, "SSH host key for #{node.name}"
        write_file! [:node_ssh_pub_key, node.name], ssh_host_key.to_s
      end
      log "done", :color => :green, :style => :bold
    end
  end

  def do_vm_start(global, options, args)
    servers_from_args(global, options, args) do |cloud, server|
      start_server(server, options[:wait])
    end
  end

  def do_vm_stop(global, options, args)
    servers_from_args(global, options, args) do |cloud, server|
      stop_server(server, options[:wait])
    end
  end

  def do_vm_key_register(global, options, args)
    cloud = new_cloud_handle(nil, options)
    cloud.find_or_create_key_pair(method(:choose_ssh_key_for_upload))
  end

  def do_vm_key_list(global, options, args)
    require 'leap_cli/ssh'
    cloud = new_cloud_handle(nil, options)
    cloud.compute.key_pairs.each do |key_pair|
      log key_pair.name, :color => :cyan do
        log "AWS fingerprint: " + key_pair.fingerprint
        key_pair, local_key = cloud.match_ssh_key(:key_pair => key_pair)
        if local_key
          log "matches local key: " + local_key.filename
          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
      end
    end
  end

  #
  # update association between node and virtual machine.
  #
  # This might additionally be called by the 'leap node mv' command.
  #
  def do_vm_bind(global, options, args)
    node_name = args.first
    vm_id = args.last
    assert! node_name, "NODE_NAME is missing"
    assert! vm_id, "INSTANCE_ID is missing"
    node = manager.nodes[node_name]
    assert! node, "No node with name '#{node_name}'"
    bind_server_to_node(vm_id, node, options)
  end

  #def do_vm_image_list(global, options, args)
  #  compute = fog_setup(nil, options)
  #  p compute.images.all
  #end

  ##
  ## PRIVATE UTILITY METHODS
  ##

  def stop_server(server, wait=false)
    if server.state == 'stopped'
      log :skipping, "virtual machine `#{server.id}` (already stopped)."
    elsif ['shutting-down', 'terminated'].include?(server.state)
      log :skipping, "virtual machine `#{server.id}` (being destroyed)."
    else
      log :stopping, "virtual machine `#{server.id}` (#{server.flavor_id}, #{server.availability_zone})"
      server.stop
      if wait
        log 'please wait...', :indent => 1
        server.wait_for { state == 'stopped' }
        log 'done', :color => :green, :indent => 1
      end
    end
  end

  def start_server(server, wait=false)
    if server.state == 'running'
      log :skipping, "virtual machine `#{server.id}` (already running)."
    elsif ['shutting-down', 'terminated'].include?(server.state)
      log :skipping, "virtual machine `#{server.id}` (being destroyed)."
    else
      log :starting, "virtual machine `#{server.id}` (#{server.flavor_id}, #{server.availability_zone})"
      server.start
      if wait
        log 'please wait...', :indent => 1
        server.wait_for { ready? }
        log 'done', :color => :green, :indent => 1
      end
    end
  end

  def destroy_server(server, wait=false)
    if ['shutting-down', 'terminated'].include?(server.state)
      log :skipping, "virtual machine `#{server.id}` (already being removed)."
    else
      log :terminated, "virtual machine `#{server.id}` (#{server.flavor_id}, #{server.availability_zone})"
      server.destroy
      if wait
        log 'please wait...', :indent => 1
        server.wait_for { state == 'terminated' }
        log 'done', :color => :green, :indent => 1
      end
    end
  end

  #
  # for each server it finds, yields cloud, server
  #
  def servers_from_args(global, options, args)
    nodes = filter_vm_nodes(args)
    if nodes.any?
      nodes.each_node do |node|
        cloud  = new_cloud_handle(node, options)
        server = cloud.fetch_server_for_node(true)
        yield cloud, server
      end
    else
      instance_id = args.first
      cloud  = new_cloud_handle(nil, options)
      server = cloud.compute.servers.get(instance_id)
      if server.nil?
        bail! :error, "There is no virtual machine with ID `#{instance_id}`."
      end
      yield cloud, server
    end
  end

  #
  # returns either:
  #
  # * the set of nodes specified by the filter, for this environment
  #   even if the result includes nodes that are not previously tagged with 'vm'
  #
  # * the list of all vm nodes for this environment, if filter is empty
  #
  def filter_vm_nodes(filter)
    if filter.nil? || filter.empty?
      return manager.filter(['vm'], :warning => false)
    elsif filter.is_a? Array
      return manager.filter(filter, :warning => false)
    else
      raise ArgumentError, 'could not understand filter'
    end
  end

  def new_cloud_handle(node, options)
    require 'leap_cli/cloud'

    config = manager.env.cloud
    name = nil
    if options[:mock]
      name = 'mock_aws'
      config['mock_aws'] = {
        "api" => "aws",
        "vendor" => "aws",
        "auth" => {
          "aws_access_key_id" => "dummy",
          "aws_secret_access_key" => "dummy",
          "region" => "us-west-2"
        },
        "instance_options" => {
          "image" => "dummy"
        }
      }
    elsif options[:auth]
      name = options[:auth]
      assert! config[name], "The value for --auth does not correspond to any value in cloud.json."
    elsif node && node['vm.auth']
      name = node.vm.auth
      assert! config[name], "The node '#{node.name}' has a value for property 'vm.auth' that does not correspond to any value in cloud.json."
    elsif config.keys.length == 1
      name = config.keys.first
      log :using, "cloud vendor credentials `#{name}`."
    else
      bail! "You must specify --mock, --auth, or a node filter."
    end

    entry = config[name] # entry in cloud.json
    assert! entry, "cloud.json: could not find cloud resource `#{name}`."
    assert! entry['vendor'], "cloud.json: property `vendor` is missing from `#{name}` entry."
    assert! entry['api'], "cloud.json: property `api` is missing from `#{name}` entry. It must be one of #{config.possible_apis.join(', ')}."
    assert! entry['auth'], "cloud.json: property `auth` is missing from `#{name}` entry."
    assert! entry['auth']['region'], "cloud.json: property `auth.region` is missing from `#{name}` entry."
    assert! entry['api'] == 'aws', "cloud.json: currently, only 'aws' is supported for `api`."
    assert! entry['vendor'] == 'aws', "cloud.json: currently, only 'aws' is supported for `vendor`."

    LeapCli::Cloud::check_dependencies!(entry)
    if options[:mock]
      Fog.mock!
    end
    return LeapCli::Cloud.new(name, entry, node)
  end

  def state_color(state)
    case state
      when 'running'; :green
      when 'terminated'; :red
      when 'stopped'; :magenta
      when 'shutting-down'; :yellow
      else; :white
    end
  end

end; end
