# -*- 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/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"] || ENV["KITCHEN_YML"],
        local_config: ENV["KITCHEN_LOCAL_YAML"] || ENV["KITCHEN_LOCAL_YML"],
        global_config: ENV["KITCHEN_GLOBAL_YAML"] || ENV["KITCHEN_GLOBAL_YML"]
      )
      @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
      method_option :color,
                    type: :boolean,
                    lazy_default: $stdout.tty?,
                    desc: "Toggle color output for STDOUT logger"
    end

    # Sets the test_base_path method_options
    # @api private
    def self.test_base_path
      method_option :test_base_path,
                    aliases: "-t",
                    desc: "Set the base path of the tests"
    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 :json,
                  aliases: "-j",
                  type: :boolean,
                  desc: "Print data as JSON"
    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
    map status: :list

    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
    test_base_path
    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+/, "").tr("\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+/, "").tr("\n", " ")
          [Future DEPRECATION, use --concurrency]
          Run a #{action} against all matching instances concurrently.
        DESC
      if action == :converge || action == :verify
        method_option :debug,
                      aliases: "-D",
                      type: :boolean,
                      default: false,
                      desc: "Run the #{action} with debugging enabled."
      end
      test_base_path
      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+/, "").tr("\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+/, "").tr("\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"
    method_option :debug,
                  aliases: "-D",
                  type: :boolean,
                  default: false,
                  desc: "Run the converge and verify with debugging enabled."
    test_base_path
    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 "package INSTANCE|REGEXP", "package an instance"
    log_options
    def package(*args)
      update_config!
      perform("package", "package", args)
    end

    desc "doctor INSTANCE|REGEXP", "Check for common system problems"
    log_options
    method_option :all,
                  aliases: "-a",
                  desc: "Check all instances"
    def doctor(*args)
      update_config!
      perform("doctor", "doctor", 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

    class << self
      private

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

    private

    # @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!
      @config.log_level = log_level if log_level

      unless options[:log_overwrite].nil?
        @config.log_overwrite = options[:log_overwrite]
      end
      @config.colorize = options[:color] unless options[:color].nil?

      if options[:test_base_path]
        # ensure we have an absolute path
        @config.test_base_path = File.absolute_path(options[:test_base_path])
      end

      @config.debug = options[:debug]

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

      update_parallel!
    end

    # Validate the log level from the config / CLI options, defaulting
    # to :info if the supplied level is empty or invalid
    #
    # @api private
    def log_level
      return unless options[:log_level]
      return @log_level if @log_level

      level = options[:log_level].downcase.to_sym
      unless valid_log_level?(level)
        level = :info
        banner "WARNING - invalid log level specified: " \
          "\"#{options[:log_level]}\" - reverting to :info log level."
      end

      @log_level = level
    end

    # Check to whether a provided log level is valid
    #
    # @api private
    def valid_log_level?(level)
      !Util.to_logger_level(level).nil?
    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"] || ENV["KITCHEN_YML"] || ".kitchen.yml"

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