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

module Kitchen
  # An instance of a suite running on a platform. A created instance may be a
  # local virtual machine, cloud instance, container, or even a bare metal
  # server, which is determined by the platform's driver.
  #
  # @author Fletcher Nichol <fnichol@nichol.ca>
  class Instance
    include Logging

    class << self
      # @return [Hash] a hash of mutxes, arranged by Driver class names
      # @api private
      attr_accessor :mutexes

      # Generates a name for an instance given a suite and platform.
      #
      # @param suite [Suite,#name] a Suite
      # @param platform [Platform,#name] a Platform
      # @return [String] a normalized, consistent name for an instance
      def name_for(suite, platform)
        "#{suite.name}-#{platform.name}".gsub(%r{[_,/]}, "-").delete(".")
      end
    end

    # @return [Suite] the test suite configuration
    attr_reader :suite

    # @return [Platform] the target platform configuration
    attr_reader :platform

    # @return [String] name of this instance
    attr_reader :name

    # @return [Driver::Base] driver object which will manage this instance's
    #   lifecycle actions
    attr_accessor :driver

    # @return [LifecycleHooks] lifecycle hooks manager object
    attr_accessor :lifecycle_hooks

    # @return [Provisioner::Base] provisioner object which will the setup
    #   and invocation instructions for configuration management and other
    #   automation tools
    attr_accessor :provisioner

    # @return [Transport::Base] transport object which will communicate with
    #   an instance.
    attr_accessor :transport

    # @return [Verifier] verifier object for instance to manage the verifier
    #   installation on this instance
    attr_accessor :verifier

    # @return [Logger] the logger for this instance
    attr_reader :logger

    # Creates a new instance, given a suite and a platform.
    #
    # @param [Hash] options configuration for a new suite
    # @option options [Suite] :suite the suite (**Required**)
    # @option options [Platform] :platform the platform (**Required**)
    # @option options [Driver::Base] :driver the driver (**Required**)
    # @option options [Provisioner::Base] :provisioner the provisioner
    # @option options [Transport::Base] :transport the transport
    #   (**Required**)
    # @option options [Verifier] :verifier the verifier logger (**Required**)
    # @option options [Logger] :logger the instance logger
    #   (default: Kitchen.logger)
    # @option options [StateFile] :state_file the state file object to use
    #   when tracking instance  state (**Required**)
    # @raise [ClientError] if one or more required options are omitted
    def initialize(options = {})
      validate_options(options)

      @suite           = options.fetch(:suite)
      @platform        = options.fetch(:platform)
      @name            = self.class.name_for(@suite, @platform)
      @driver          = options.fetch(:driver)
      @lifecycle_hooks = options.fetch(:lifecycle_hooks)
      @provisioner     = options.fetch(:provisioner)
      @transport       = options.fetch(:transport)
      @verifier        = options.fetch(:verifier)
      @logger          = options.fetch(:logger) { Kitchen.logger }
      @state_file      = options.fetch(:state_file)

      setup_driver
      setup_provisioner
      setup_transport
      setup_verifier
      setup_lifecycle_hooks
    end

    # Returns a displayable representation of the instance.
    #
    # @return [String] an instance display string
    def to_str
      "<#{name}>"
    end

    # Creates this instance.
    #
    # @see Driver::Base#create
    # @return [self] this instance, used to chain actions
    #
    # @todo rescue Driver::ActionFailed and return some kind of null object
    #   to gracfully stop action chaining
    def create
      transition_to(:create)
    end

    # Converges this running instance.
    #
    # @see Provisioner::Base#call
    # @return [self] this instance, used to chain actions
    #
    # @todo rescue Driver::ActionFailed and return some kind of null object
    #   to gracfully stop action chaining
    def converge
      transition_to(:converge)
    end

    # Sets up this converged instance for suite tests.
    #
    # @see Driver::Base#setup
    # @return [self] this instance, used to chain actions
    #
    # @todo rescue Driver::ActionFailed and return some kind of null object
    #   to gracfully stop action chaining
    def setup
      transition_to(:setup)
    end

    # Verifies this set up instance by executing suite tests.
    #
    # @see Driver::Base#verify
    # @return [self] this instance, used to chain actions
    #
    # @todo rescue Driver::ActionFailed and return some kind of null object
    #   to gracfully stop action chaining
    def verify
      transition_to(:verify)
    end

    # Destroys this instance.
    #
    # @see Driver::Base#destroy
    # @return [self] this instance, used to chain actions
    #
    # @todo rescue Driver::ActionFailed and return some kind of null object
    #   to gracfully stop action chaining
    def destroy
      transition_to(:destroy)
    end

    # Tests this instance by creating, converging and verifying. If this
    # instance is running, it will be pre-emptively destroyed to ensure a
    # clean slate. The instance will be left post-verify in a running state.
    #
    # @param destroy_mode [Symbol] strategy used to cleanup after instance
    #   has finished verifying (default: `:passing`)
    # @return [self] this instance, used to chain actions
    #
    # @todo rescue Driver::ActionFailed and return some kind of null object
    #   to gracfully stop action chaining
    def test(destroy_mode = :passing)
      elapsed = Benchmark.measure do
        banner "Cleaning up any prior instances of #{to_str}"
        destroy
        banner "Testing #{to_str}"
        verify
        destroy if destroy_mode == :passing
      end
      info "Finished testing #{to_str} #{Util.duration(elapsed.real)}."
      self
    ensure
      destroy if destroy_mode == :always
    end

    # Logs in to this instance by invoking a system command, provided by the
    # instance's transport. This could be an SSH command, telnet, or serial
    # console session.
    #
    # **Note** This method calls exec and will not return.
    #
    # @see Kitchen::LoginCommand
    # @see Transport::Base::Connection#login_command
    def login
      state = state_file.read
      if state[:last_action].nil?
        raise UserError, "Instance #{to_str} has not yet been created"
      end

      lc = if legacy_ssh_base_driver?
             legacy_ssh_base_login(state)
           else
             transport.connection(state).login_command
           end

      debug(%{Login command: #{lc.command} #{lc.arguments.join(' ')} } \
        "(Options: #{lc.options})")
      Kernel.exec(*lc.exec_args)
    end

    # Executes an arbitrary command on this instance.
    #
    # @param command [String] a command string to execute
    def remote_exec(command)
      transport.connection(state_file.read) do |conn|
        conn.execute(command)
      end
    end

    # Perform package.
    #
    def package_action
      banner "Packaging remote instance"
      driver.package(state_file.read)
    end

    # Check system and configuration for common errors.
    #
    def doctor_action
      banner "The doctor is in"
      [driver, provisioner, transport, verifier].any? do |obj|
        obj.doctor(state_file.read)
      end
    end

    # Returns a Hash of configuration and other useful diagnostic information.
    #
    # @return [Hash] a diagnostic hash
    def diagnose
      result = {}
      [
        :platform, :state_file, :driver, :provisioner, :transport, :verifier
      ].each do |sym|
        obj = send(sym)
        result[sym] = obj.respond_to?(:diagnose) ? obj.diagnose : :unknown
      end
      result
    end

    # Returns a Hash of configuration and other useful diagnostic information
    # associated with plugins (such as loaded version, class name, etc.).
    #
    # @return [Hash] a diagnostic hash
    def diagnose_plugins
      result = {}
      [:driver, :provisioner, :verifier, :transport].each do |sym|
        obj = send(sym)
        result[sym] = if obj.respond_to?(:diagnose_plugin)
                        obj.diagnose_plugin
                      else
                        :unknown
                      end
      end
      result
    end

    # Returns the last successfully completed action state of the instance.
    #
    # @return [String] a named action which was last successfully completed
    def last_action
      state_file.read[:last_action]
    end

    # Returns the error encountered on the last action on the instance
    #
    # @return [String] the message of the last error
    def last_error
      state_file.read[:last_error]
    end

    # Clean up any per-instance resources before exiting.
    #
    # @return [void]
    def cleanup!
      @transport.cleanup! if @transport
    end

    private

    # @return [StateFile] a state file object that can be read from or written
    #   to
    # @api private
    attr_reader :state_file

    # Validate the initial internal state of this object and raising an
    # exception if any preconditions are not met.
    #
    # @param options[Hash] options hash passed into the constructor
    # @raise [ClientError] if any validations fail
    # @api private
    def validate_options(options)
      [
        :suite, :platform, :driver, :provisioner,
        :transport, :verifier, :state_file
      ].each do |k|
        next if options.key?(k)

        raise ClientError, "Instance#new requires option :#{k}"
      end
    end

    # Perform any final configuration or preparation needed for the driver
    # object carry out its duties.
    #
    # @api private
    def setup_driver
      @driver.finalize_config!(self)

      if driver.class.serial_actions
        Kitchen.mutex.synchronize do
          self.class.mutexes ||= {}
          self.class.mutexes[driver.class] = Mutex.new
        end
      end
    end

    # Perform any final configuration or preparation needed for the lifecycle hooks
    # object carry out its duties.
    #
    # @api private
    def setup_lifecycle_hooks
      lifecycle_hooks.finalize_config!(self)
    end

    # Perform any final configuration or preparation needed for the provisioner
    # object carry out its duties.
    #
    # @api private
    def setup_provisioner
      @provisioner.finalize_config!(self)
    end

    # Perform any final configuration or preparation needed for the transport
    # object carry out its duties.
    #
    # @api private
    def setup_transport
      transport.finalize_config!(self)
    end

    # Perform any final configuration or preparation needed for the verifier
    # object carry out its duties.
    #
    # @api private
    def setup_verifier
      verifier.finalize_config!(self)
    end

    # Perform all actions in order from last state to desired state.
    #
    # @param desired [Symbol] a symbol representing the desired action state
    # @return [self] this instance, used to chain actions
    # @api private
    def transition_to(desired)
      result = nil
      FSM.actions(last_action, desired).each do |transition|
        @lifecycle_hooks.run_with_hooks(transition, state_file) do
          result = send("#{transition}_action")
        end
      end
      result
    end

    # Perform the create action.
    #
    # @see Driver::Base#create
    # @return [self] this instance, used to chain actions
    # @api private
    def create_action
      perform_action(:create, "Creating")
    end

    # Perform the converge action.
    #
    # @see Provisioner::Base#call
    # @return [self] this instance, used to chain actions
    # @api private
    def converge_action
      banner "Converging #{to_str}..."
      elapsed = action(:converge) do |state|
        if legacy_ssh_base_driver?
          legacy_ssh_base_converge(state)
        else
          provisioner.call(state)
        end
      end
      info("Finished converging #{to_str} #{Util.duration(elapsed.real)}.")
      self
    end

    # Perform the setup action.
    #
    # @see Driver::Base#setup
    # @return [self] this instance, used to chain actions
    # @api private
    def setup_action
      banner "Setting up #{to_str}..."
      elapsed = action(:setup) do |state|
        legacy_ssh_base_setup(state) if legacy_ssh_base_driver?
      end
      info("Finished setting up #{to_str} #{Util.duration(elapsed.real)}.")
      self
    end

    # returns true, if the verifier is busser
    def verifier_busser?(verifier)
      !defined?(Kitchen::Verifier::Busser).nil? && verifier.is_a?(Kitchen::Verifier::Busser)
    end

    # returns true, if the verifier is dummy
    def verifier_dummy?(verifier)
      !defined?(Kitchen::Verifier::Dummy).nil? && verifier.is_a?(Kitchen::Verifier::Dummy)
    end

    def use_legacy_ssh_verifier?(verifier)
      verifier_busser?(verifier) || verifier_dummy?(verifier)
    end

    # Perform the verify action.
    #
    # @see Driver::Base#verify
    # @return [self] this instance, used to chain actions
    # @api private
    def verify_action
      banner "Verifying #{to_str}..."
      elapsed = action(:verify) do |state|
        # use special handling for legacy driver
        if legacy_ssh_base_driver? && use_legacy_ssh_verifier?(verifier)
          legacy_ssh_base_verify(state)
        elsif legacy_ssh_base_driver?
          # read ssh options from legacy driver
          verifier.call(driver.legacy_state(state))
        else
          verifier.call(state)
        end
      end
      info("Finished verifying #{to_str} #{Util.duration(elapsed.real)}.")
      self
    end

    # Perform the destroy action.
    #
    # @see Driver::Base#destroy
    # @return [self] this instance, used to chain actions
    # @api private
    def destroy_action
      perform_action(:destroy, "Destroying") { state_file.destroy }
    end

    # Perform an arbitrary action and provide useful logging.
    #
    # @param verb [Symbol] the action to be performed
    # @param output_verb [String] a verb representing the action, suitable for
    #   use in output logging
    # @yield perform optional work just after action has complted
    # @return [self] this instance, used to chain actions
    # @api private
    def perform_action(verb, output_verb)
      banner "#{output_verb} #{to_str}..."
      elapsed = action(verb) { |state| driver.public_send(verb, state) }
      info("Finished #{output_verb.downcase} #{to_str}" \
        " #{Util.duration(elapsed.real)}.")
      yield if block_given?
      self
    end

    # Times a call to an action block and handles any raised exceptions. This
    # method ensures that the last successfully completed action is persisted
    # to the state file. The last action state will either be the desired
    # action that is passed in or the previous action that was persisted to the
    # state file.
    #
    # @param what [Symbol] the action to be performed
    # @param block [Proc] a block to be called
    # @return [Benchmark::Tms] timing information for the given action
    # @raise [InstanceFailed] if a driver action fails to complete, signaled
    #   by a driver raising an ActionFailed exception. Typical reasons for this
    #   would be a driver create action failing, a chef convergence crashing
    #   in normal course of development, failing acceptance tests in the
    #   verify action, etc.
    # @raise [ActionFailed] if an unforseen or unplanned exception is raised.
    #   This would usually indicate that a race condition was triggered, a
    #   bug exists in a driver, provisioner, or core, a transient IO error
    #   occured, etc.
    # @api private
    def action(what, &block)
      state = state_file.read
      elapsed = Benchmark.measure do
        synchronize_or_call(what, state, &block)
      end
      state[:last_action] = what.to_s
      state[:last_error] = nil
      elapsed
    rescue ActionFailed => e
      log_failure(what, e)
      state[:last_error] = e.class.name
      raise(InstanceFailure, failure_message(what) +
        "  Please see .kitchen/logs/#{name}.log for more details",
            e.backtrace)
    rescue Exception => e # rubocop:disable Lint/RescueException
      log_failure(what, e)
      state[:last_error] = e.class.name
      raise ActionFailed,
            "Failed to complete ##{what} action: [#{e.message}]", e.backtrace
    ensure
      state_file.write(state)
    end

    # Runs a given action block through a common driver mutex if required or
    # runs it directly otherwise. If a driver class' `.serial_actions` array
    # includes the desired action, then the action must be run with a muxtex
    # lock. Otherwise, it is assumed that the action can happen concurrently,
    # or fully in parallel.
    #
    # @param what [Symbol] the action to be performed
    # @param state [Hash] a mutable state hash for this instance
    # @param block [Proc] a block to be called
    # @api private
    def synchronize_or_call(what, state)
      if Array(driver.class.serial_actions).include?(what)
        debug("#{to_str} is synchronizing on #{driver.class}##{what}")
        self.class.mutexes[driver.class].synchronize do
          debug("#{to_str} is messaging #{driver.class}##{what}")
          yield(state)
        end
      else
        yield(state)
      end
    end

    # Writes a high level message for logging and/or output.
    #
    # In this case, all instance banner messages will be written to the common
    # Kitchen logger so that the high level flow of a run can be followed in
    # the kitchen.log file.
    #
    # @api private
    def banner(*args)
      Kitchen.logger.logdev && Kitchen.logger.logdev.banner(*args)
      super
    end

    # Logs a failure (message and backtrace) to the instance's file logger
    # to help with debugging and diagnosing issues without overwhelming the
    # console output in the default case (i.e. running kitchen with :info
    # level debugging).
    #
    # @param what [String] an action
    # @param e [Exception] an exception
    # @api private
    def log_failure(what, e)
      return if logger.logdev.nil?

      logger.logdev.error(failure_message(what))
      Error.formatted_trace(e).each { |line| logger.logdev.error(line) }
    end

    # Returns a string explaining what action failed, at a high level. Used
    # for displaying to end user.
    #
    # @param what [String] an action
    # @return [String] a failure message
    # @api private
    def failure_message(what)
      "#{what.capitalize} failed on instance #{to_str}."
    end

    # Invokes `Driver#converge` on a legacy Driver, which inherits from
    # `Kitchen::Driver::SSHBase`.
    #
    # @param state [Hash] mutable instance state
    # @deprecated When legacy Driver::SSHBase support is removed, the
    #   `#converge` method will no longer be called on the Driver.
    # @api private
    def legacy_ssh_base_converge(state)
      warn("Running legacy converge for '#{driver.name}' Driver")
      # TODO: Document upgrade path and provide link
      # warn("Driver authors: please read http://example.com for more details.")
      driver.converge(state)
    end

    # @return [TrueClass,FalseClass] whether or not the Driver inherits from
    #   `Kitchen::Driver::SSHBase`
    # @deprecated When legacy Driver::SSHBase support is removed, the
    #   `#converge` method will no longer be called on the Driver.
    # @api private
    def legacy_ssh_base_driver?
      driver.class < Kitchen::Driver::SSHBase
    end

    # Invokes `Driver#login_command` on a legacy Driver, which inherits from
    # `Kitchen::Driver::SSHBase`.
    #
    # @param state [Hash] mutable instance state
    # @deprecated When legacy Driver::SSHBase support is removed, the
    #   `#login_command` method will no longer be called on the Driver.
    # @api private
    def legacy_ssh_base_login(state)
      warn("Running legacy login for '#{driver.name}' Driver")
      # TODO: Document upgrade path and provide link
      # warn("Driver authors: please read http://example.com for more details.")
      driver.login_command(state)
    end

    # Invokes `Driver#setup` on a legacy Driver, which inherits from
    # `Kitchen::Driver::SSHBase`.
    #
    # @param state [Hash] mutable instance state
    # @deprecated When legacy Driver::SSHBase support is removed, the
    #   `#setup` method will no longer be called on the Driver.
    # @api private
    def legacy_ssh_base_setup(state)
      warn("Running legacy setup for '#{driver.name}' Driver")
      # TODO: Document upgrade path and provide link
      # warn("Driver authors: please read http://example.com for more details.")
      driver.setup(state)
    end

    # Invokes `Driver#verify` on a legacy Driver, which inherits from
    # `Kitchen::Driver::SSHBase`.
    #
    # @param state [Hash] mutable instance state
    # @deprecated When legacy Driver::SSHBase support is removed, the
    #   `#verify` method will no longer be called on the Driver.
    # @api private
    def legacy_ssh_base_verify(state)
      warn("Running legacy verify for '#{driver.name}' Driver")
      # TODO: Document upgrade path and provide link
      # warn("Driver authors: please read http://example.com for more details.")
      driver.verify(state)
    end

    # The simplest finite state machine pseudo-implementation needed to manage
    # an Instance.
    #
    # @api private
    # @author Fletcher Nichol <fnichol@nichol.ca>
    class FSM
      # Returns an Array of all transitions to bring an Instance from its last
      # reported transistioned state into the desired transitioned state.
      #
      # @param last [String,Symbol,nil] the last known transitioned state of
      #   the Instance, defaulting to `nil` (for unknown or no history)
      # @param desired [String,Symbol] the desired transitioned state for the
      #   Instance
      # @return [Array<Symbol>] an Array of transition actions to perform
      # @api private
      def self.actions(last = nil, desired)
        last_index = index(last)
        desired_index = index(desired)

        if last_index == desired_index || last_index > desired_index
          Array(TRANSITIONS[desired_index])
        else
          TRANSITIONS.slice(last_index + 1, desired_index - last_index)
        end
      end

      TRANSITIONS = [:destroy, :create, :converge, :setup, :verify].freeze

      # Determines the index of a state in the state lifecycle vector. Woah.
      #
      # @param transition [Symbol,#to_sym] a state
      # @param [Integer] the index position
      # @api private
      def self.index(transition)
        if transition.nil?
          0
        else
          TRANSITIONS.find_index { |t| t == transition.to_sym }
        end
      end
    end
  end
end
