# -*- encoding: utf-8 -*-
#
# Author:: Fletcher Nichol (<fnichol@nichol.ca>)
#
# Copyright (C) 2012, 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.

require "thor"

require "kitchen"
require "kitchen/generator/driver_create"
require "kitchen/generator/init"

module Kitchen

  # The command line runner for Kitchen.
  #
  # @author Fletcher Nichol <fnichol@nichol.ca>
  class CLI < Thor

    # Common module to load and invoke a CLI-implementation agnostic command.
    module PerformCommand

      # Perform a CLI subcommand.
      #
      # @param task [String] action to take, usually corresponding to the
      #   subcommand name
      # @param command [String] command class to create and invoke]
      # @param args [Array] remainder arguments from processed ARGV
      #   (default: `nil`)
      # @param additional_options [Hash] additional configuration needed to
      #   set up the command class (default: `{}`)
      def perform(task, command, args = nil, additional_options = {})
        require "kitchen/command/#{command}"

        command_options = {
          :action => task,
          :help => -> { help(task) },
          :config => @config,
          :shell => shell
        }.merge(additional_options)

        str_const = Thor::Util.camel_case(command)
        klass = ::Kitchen::Command.const_get(str_const)
        klass.new(args, options, command_options).call
      end
    end

    include Logging
    include PerformCommand

    # The maximum number of concurrent instances that can run--which is a bit
    # high
    MAX_CONCURRENCY = 9999

    attr_reader :config

    # Constructs a new instance.
    def initialize(*args)
      super
      $stdout.sync = true
      @loader = Kitchen::Loader::YAML.new(
        :project_config => ENV["KITCHEN_YAML"],
        :local_config => ENV["KITCHEN_LOCAL_YAML"],
        :global_config => ENV["KITCHEN_GLOBAL_YAML"]
      )
      @config = Kitchen::Config.new(
        :loader     => @loader
      )
      @config.log_level = Kitchen.env_log unless Kitchen.env_log.nil?
      @config.log_overwrite = Kitchen.env_log_overwrite unless Kitchen.env_log_overwrite.nil?
    end

    # Sets the logging method_options
    # @api private
    def self.log_options
      method_option :log_level,
        :aliases => "-l",
        :desc => "Set the log level (debug, info, warn, error, fatal)"
      method_option :log_overwrite,
        :desc => "Set to false to prevent log overwriting each time Test Kitchen runs",
        :type => :boolean
    end

    desc "list [INSTANCE|REGEXP|all]", "Lists one or more instances"
    method_option :bare,
      :aliases => "-b",
      :type => :boolean,
      :desc => "List the name of each instance only, one per line"
    method_option :debug,
      :aliases => "-d",
      :type => :boolean,
      :desc => "[Deprecated] Please use `kitchen diagnose'"
    log_options
    def list(*args)
      update_config!
      perform("list", "list", args)
    end

    desc "diagnose [INSTANCE|REGEXP|all]", "Show computed diagnostic configuration"
    method_option :loader,
      :type => :boolean,
      :desc => "Include data loader diagnostics"
    method_option :plugins,
      :type => :boolean,
      :desc => "Include plugin diagnostics"
    method_option :instances,
      :type => :boolean,
      :default => true,
      :desc => "Include instances diagnostics"
    method_option :all,
      :type => :boolean,
      :desc => "Include all diagnostics"
    log_options
    def diagnose(*args)
      update_config!
      perform("diagnose", "diagnose", args, :loader => @loader)
    end

    {
      :create   => "Change instance state to create. " \
                   "Start one or more instances",
      :converge => "Change instance state to converge. " \
                   "Use a provisioner to configure one or more instances",
      :setup    => "Change instance state to setup. " \
                   "Prepare to run automated tests. " \
                   "Install busser and related gems on one or more instances",
      :verify   => "Change instance state to verify. " \
                   "Run automated tests on one or more instances",
      :destroy  => "Change instance state to destroy. " \
                   "Delete all information for one or more instances"
    }.each do |action, short_desc|
      desc(
        "#{action} [INSTANCE|REGEXP|all]",
        short_desc
      )
      long_desc <<-DESC
        The instance states are in order: destroy, create, converge, setup, verify, destroy.
        Change one or more instances from the current state to the #{action} state. Actions for all
        intermediate states will be executed. See http://kitchen.ci for further explanation.
      DESC
      method_option :concurrency,
        :aliases => "-c",
        :type => :numeric,
        :lazy_default => MAX_CONCURRENCY,
        :desc => <<-DESC.gsub(/^\s+/, "").gsub(/\n/, " ")
          Run a #{action} against all matching instances concurrently. Only N
          instances will run at the same time if a number is given.
        DESC
      method_option :parallel,
        :aliases => "-p",
        :type => :boolean,
        :desc => <<-DESC.gsub(/^\s+/, "").gsub(/\n/, " ")
          [Future DEPRECATION, use --concurrency]
          Run a #{action} against all matching instances concurrently.
        DESC
      log_options
      define_method(action) do |*args|
        update_config!
        perform(action, "action", args)
      end
    end

    desc "test [INSTANCE|REGEXP|all]",
      "Test (destroy, create, converge, setup, verify and destroy) one or more instances"
    long_desc <<-DESC
      The instance states are in order: destroy, create, converge, setup, verify, destroy.
      Test changes the state of one or more instances to destroyed, then executes
      the actions for each state up to destroy. At any sign of failure, executing the
      actions stops and the instance is left in the last successful execution state.

      There are 3 post-verify modes for instance cleanup, triggered with
      the `--destroy' flag:

      * passing: instances passing verify will be destroyed afterwards.\n
      * always: instances will always be destroyed afterwards.\n
      * never: instances will never be destroyed afterwards.
    DESC
    method_option :concurrency,
      :aliases => "-c",
      :type => :numeric,
      :lazy_default => MAX_CONCURRENCY,
      :desc => <<-DESC.gsub(/^\s+/, "").gsub(/\n/, " ")
        Run a test against all matching instances concurrently. Only N
        instances will run at the same time if a number is given.
      DESC
    method_option :parallel,
      :aliases => "-p",
      :type => :boolean,
      :desc => <<-DESC.gsub(/^\s+/, "").gsub(/\n/, " ")
        [Future DEPRECATION, use --concurrency]
        Run a test against all matching instances concurrently.
      DESC
    method_option :destroy,
      :aliases => "-d",
      :default => "passing",
      :desc => "Destroy strategy to use after testing (passing, always, never)."
    method_option :auto_init,
      :type => :boolean,
      :default => false,
      :desc => "Invoke init command if .kitchen.yml is missing"
    log_options
    def test(*args)
      update_config!
      ensure_initialized
      perform("test", "test", args)
    end

    desc "login INSTANCE|REGEXP", "Log in to one instance"
    log_options
    def login(*args)
      update_config!
      perform("login", "login", args)
    end

    desc "exec INSTANCE|REGEXP -c REMOTE_COMMAND",
      "Execute command on one or more instance"
    method_option :command,
      :aliases => "-c",
      :desc => "execute via ssh"
    log_options
    def exec(*args)
      update_config!
      perform("exec", "exec", args)
    end

    desc "version", "Print Kitchen's version information"
    def version
      puts "Test Kitchen version #{Kitchen::VERSION}"
    end
    map %w[-v --version] => :version

    desc "sink", "Show the Kitchen sink!", :hide => true
    def sink
      perform("sink", "sink")
    end

    desc "console", "Kitchen Console!"
    def console
      perform("console", "console")
    end

    register Kitchen::Generator::Init, "init",
      "init", "Adds some configuration to your cookbook so Kitchen can rock"
    long_desc <<-D, :for => "init"
      Init will add Test Kitchen support to an existing project for
      convergence integration testing. A default .kitchen.yml file (which is
      intended to be customized) is created in the project's root directory
      and one or more gems will be added to the project's Gemfile.
    D
    tasks["init"].options = Kitchen::Generator::Init.class_options

    # Thor class for kitchen driver commands.
    #
    # @author Fletcher Nichol <fnichol@nichol.ca>
    class Driver < Thor

      include PerformCommand

      register Kitchen::Generator::DriverCreate, "create",
        "create [NAME]", "Create a new Kitchen Driver gem project"
      long_desc <<-D, :for => "create"
        Create will generate a project scaffold for a brand new Test Kitchen
        Driver RubyGem. For example:

        > kitchen driver create foobar

        will create a project scaffold for a RubyGem called `kitchen-foobar'.
      D
      tasks["create"].options = Kitchen::Generator::DriverCreate.class_options

      desc "discover", "Discover Test Kitchen drivers published on RubyGems"
      long_desc <<-D
        Discover will perform a search aginst the RubyGems service for any
        published gems of the form: "kitchen-*". Note that it it cannot be
        guarenteed that every result is a driver, but chances are good most
        relevant drivers will be returned.
      D
      def discover
        perform("discover", "driver_discover", args)
      end

      # @return [String] basename
      def self.basename
        super + " driver"
      end
    end

    register Kitchen::CLI::Driver, "driver",
      "driver", "Driver subcommands"

    no_tasks do
      def invoke_task(command, *args)
        if command.name == "help" && args.first.first == "driver"
          Kitchen::CLI::Driver.task_help(shell, args.first.last)
        else
          super
        end
      end
      alias_method :invoke_command, :invoke_task
    end

    private

    # Ensure the any failing commands exit non-zero.
    #
    # @return [true] you die always on failure
    # @api private
    def self.exit_on_failure?
      true
    end

    # @return [Logger] the common logger
    # @api private
    def logger
      Kitchen.logger
    end

    # Update and finalize options for logging, concurrency, and other concerns.
    #
    # @api private
    def update_config!
      if options[:log_level]
        level = options[:log_level].downcase.to_sym
        @config.log_level = level
      end
      unless options[:log_overwrite].nil?
        @config.log_overwrite = options[:log_overwrite]
      end

      # Now that we have required configs, lets create our file logger
      Kitchen.logger = Kitchen.default_file_logger(
        level,
        options[:log_overwrite]
      )

      update_parallel!
    end

    # Set parallel concurrency options for Thor
    #
    # @api private
    def update_parallel!
      if options[:parallel]
        # warn here in a future release when option is used
        @options = Thor::CoreExt::HashWithIndifferentAccess.new(options.to_hash)
        if options[:parallel] && !options[:concurrency]
          options[:concurrency] = MAX_CONCURRENCY
        end
        options.delete(:parallel)
        options.freeze
      end
    end

    # If auto_init option is active, invoke the init generator.
    #
    # @api private
    def ensure_initialized
      yaml = ENV["KITCHEN_YAML"] || ".kitchen.yml"

      if options[:auto_init] && !File.exist?(yaml)
        banner "Invoking init as '#{yaml}' file is missing"
        invoke "init"
      end
    end
  end
end
