# encoding: utf-8

require 'claide/command/banner'

module CLAide

  # This class is used to build a command-line interface
  #
  # Each command is represented by a subclass of this class, which may be
  # nested to create more granular commands.
  #
  # Following is an overview of the types of commands and what they should do.
  #
  # ### Any command type
  #
  # * Inherit from the command class under which the command should be nested.
  # * Set {Command.summary} to a brief description of the command.
  # * Override {Command.options} to return the options it handles and their
  #   descriptions and prepending them to the results of calling `super`.
  # * Override {Command#initialize} if it handles any parameters.
  # * Override {Command#validate!} to check if the required parameters the
  #   command handles are valid, or call {Command#help!} in case they’re not.
  #
  # ### Abstract command
  #
  # The following is needed for an abstract command:
  #
  # * Set {Command.abstract_command} to `true`.
  # * Subclass the command.
  #
  # When the optional {Command.description} is specified, it will be shown at
  # the top of the command’s help banner.
  #
  # ### Normal command
  #
  # The following is needed for a normal command:
  #
  # * Set {Command.arguments} to the description of the arguments this command
  #   handles.
  # * Override {Command#run} to perform the actual work.
  #
  # When the optional {Command.description} is specified, it will be shown
  # underneath the usage section of the command’s help banner. Otherwise this
  # defaults to {Command.summary}.
  #
  class Command

    #-------------------------------------------------------------------------#

    class << self

      # @return [Boolean] Indicates whether or not this command can actually
      #         perform work of itself, or that it only contains subcommands.
      #
      attr_accessor :abstract_command
      alias_method :abstract_command?, :abstract_command

      # @return [Boolean] Indicates whether or not this command is used during
      #         command parsing and whether or not it should be shown in the
      #         help banner or to show its subcommands instead.
      #
      #         Setting this to `true` implies it’s an abstract command.
      attr_reader :ignore_in_command_lookup
      alias_method :ignore_in_command_lookup?, :ignore_in_command_lookup
      def ignore_in_command_lookup=(flag)
        @ignore_in_command_lookup = self.abstract_command = flag
      end

      # @return [String] The subcommand which an abstract command should invoke
      #         by default.
      #
      attr_accessor :default_subcommand

      # @return [String] A brief description of the command, which is shown
      #         next to the command in the help banner of a parent command.
      #
      attr_accessor :summary

      # @return [String] A longer description of the command, which is shown
      #         underneath the usage section of the command’s help banner. Any
      #         indentation in this value will be ignored.
      #
      attr_accessor :description

      # @return [String] The prefix for loading CLAide plugins for this
      #         command. Plugins are loaded via their
      #         <plugin_prefix>_plugin.rb file.
      #
      attr_accessor :plugin_prefix

      # @return [String] A list of arguments the command handles. This is shown
      #         in the usage section of the command’s help banner.
      #
      attr_accessor :arguments

      # @return [Boolean] The default value for {Command#ansi_output}. This
      #         defaults to `true` if `STDOUT` is connected to a TTY and
      #         `String` has the instance methods `#red`, `#green`, and
      #         `#yellow` (which are defined by, for instance, the
      #         [colored](https://github.com/defunkt/colored) gem).
      #
      def ansi_output
        if @ansi_output.nil?
          @ansi_output = STDOUT.tty? &&
                           String.method_defined?(:red) &&
                             String.method_defined?(:green) &&
                               String.method_defined?(:yellow)
        end
        @ansi_output
      end
      attr_writer :ansi_output
      alias_method :ansi_output?, :ansi_output

      def colorize_output
        warn "[!] The use of `CLAide::Command.colorize_output` has been " \
             "deprecated. Use `CLAide::Command.ansi_output` instead. " \
             "(Called from: #{caller.first})"
        ansi_output
      end
      alias_method :colorize_output?, :colorize_output

      def colorize_output=(flag)
        warn "[!] The use of `CLAide::Command.colorize_output=` has been " \
             "deprecated. Use `CLAide::Command.ansi_output=` instead. " \
             "(Called from: #{caller.first})"
        self.ansi_output = flag
      end

      # @return [String] The name of the command. Defaults to a snake-cased
      #         version of the class’ name.
      #
      def command
        @command ||= name.split('::').last.gsub(/[A-Z]+[a-z]*/) do |part|
          part.downcase << '-'
        end[0..-2]
      end
      attr_writer :command

      # @return [String] The full command up-to this command, as it would be
      #         looked up during parsing.
      #
      # @note (see #ignore_in_command_lookup)
      #
      # @example
      #
      #   BevarageMaker::Tea.full_command # => "beverage-maker tea"
      #
      def full_command
        if superclass == Command
          ignore_in_command_lookup? ? '' : command
        else
          if ignore_in_command_lookup?
            superclass.full_command
          else
            "#{superclass.full_command} #{command}"
          end
        end
      end

      # @return [Array<Class>] A list of all command classes that are nested
      #         under this command.
      #
      def subcommands
        @subcommands ||= []
      end

      # @return [Array<Class>] A list of command classes that are nested under
      #         this command _or_ the subcommands of those command classes in
      #         case the command class should be ignored in command lookup.
      #
      def subcommands_for_command_lookup
        subcommands.map do |subcommand|
          if subcommand.ignore_in_command_lookup?
            subcommand.subcommands_for_command_lookup
          else
            subcommand
          end
        end.flatten
      end

      # Searches the list of subcommands that should not be ignored for command
      # lookup for a subcommand with the given `name`.
      #
      # @param  [String] name
      #         The name of the subcommand to be found.
      #
      # @return [CLAide::Command, nil] The subcommand, if found.
      #
      def find_subcommand(name)
        subcommands_for_command_lookup.find { |sc| sc.command == name }
      end

      # @visibility private
      #
      # Automatically registers a subclass as a subcommand.
      #
      def inherited(subcommand)
        subcommands << subcommand
      end

      # Should be overridden by a subclass if it handles any options.
      #
      # The subclass has to combine the result of calling `super` and its own
      # list of options. The recommended way of doing this is by concatenating
      # concatenating to this classes’ own options.
      #
      # @return [Array<Array>]
      #
      #   A list of option name and description tuples.
      #
      # @example
      #
      #   def self.options
      #     [
      #       ['--verbose', 'Print more info'],
      #       ['--help',    'Print help banner'],
      #     ].concat(super)
      #   end
      #
      def options
        options = [
          ['--verbose', 'Show more debugging information'],
          ['--help',    'Show help banner of specified command'],
        ]
        if Command.ansi_output?
          options.unshift(['--no-ansi', 'Show output without ANSI codes'])
        end
        options
      end

      # @param  [Array, ARGV] argv
      #         A list of (remaining) parameters.
      #
      # @return [Command] An instance of the command class that was matched by
      #         going through the arguments in the parameters and drilling down
      #         command classes.
      #
      def parse(argv)
        argv = ARGV.new(argv) unless argv.is_a?(ARGV)
        cmd = argv.arguments.first
        if cmd && subcommand = find_subcommand(cmd)
          argv.shift_argument
          subcommand.parse(argv)
        elsif abstract_command? && default_subcommand
          subcommand = find_subcommand(default_subcommand)
          unless subcommand
            raise "Unable to find the default subcommand " \
                  "`#{default_subcommand}` for command `#{self}`."
          end
          result = subcommand.parse(argv)
          result.invoked_as_default = true
          result
        else
          new(argv)
        end
      end

      # Instantiates the command class matching the parameters through
      # {Command.parse}, validates it through {Command#validate!}, and runs it
      # through {Command#run}.
      #
      # @note
      #
      #   You should normally call this on
      #
      # @param [Array, ARGV] argv
      #
      #   A list of parameters. For instance, the standard `ARGV` constant,
      #   which contains the parameters passed to the program.
      #
      # @return [void]
      #
      def run(argv)
        load_plugins
        command = parse(argv)
        command.validate!
        command.run
        rescue Exception => exception
          if exception.is_a?(InformativeError)
            puts exception.message
            if command.verbose?
              puts
              puts(*exception.backtrace)
            end
            exit exception.exit_status
          else
            report_error(exception)
          end
      end

      # Allows the application to perform custom error reporting, by overriding
      # this method.
      #
      # @param [Exception] exception
      #
      #   An exception that occurred while running a command through
      #   {Command.run}.
      #
      # @raise
      #
      #   By default re-raises the specified exception.
      #
      # @return [void]
      #
      def report_error(exception)
        raise exception
      end

      # @visibility private
      #
      # @param  [String] error_message
      #         The error message to show to the user.
      #
      # @param  [Boolean] ansi_output
      #         Whether or not to use ANSI codes to prettify output.
      #
      # @param  [Class] help_class
      #         The class to use to raise a ‘help’ error.
      #
      # @raise  [Help]
      #
      #   Signals CLAide that a help banner for this command should be shown,
      #   with an optional error message.
      #
      # @return [void]
      #
      def help!(error_message = nil, ansi_output = false, help_class = Help)
        raise help_class.new(banner(ansi_output), error_message, ansi_output)
      end

      # @visibility private
      #
      # Returns the banner for the command.
      #
      # @param  [Boolean] ansi
      #         Whether the banner should use ANSI codes to prettify output.
      #
      # @param  [Class] banner_class
      #         The class to use to format help banners.
      #
      # @return [String] The banner for the command.
      #
      def banner(ansi_output = false, banner_class = Banner)
        banner_class.new(self, ansi_output).formatted_banner
      end

      # Load additional plugins via rubygems looking for:
      #
      # <command-path>/plugin.rb
      #
      # where <command-path> is the namespace of the Command converted to a
      # path, for example:
      #
      # Pod::Command
      #
      # maps to
      #
      # pod/command
      #
      def load_plugins
        return unless plugin_prefix
        files_to_require = if Gem.respond_to? :find_latest_files
                             Gem.find_latest_files("#{plugin_prefix}_plugin")
                           else
                             Gem.find_files("#{plugin_prefix}_plugin")
                           end
        files_to_require.each { |path| require_plugin_path(path) }
      end

      # Loads the plugin file at the given path, catching any failure.
      #
      # @param [String] path
      #        The path to load.
      #
      def require_plugin_path(path)
        require path
      rescue Exception => exception
        message = "\n---------------------------------------------"
        message << "\nError loading the plugin with path `#{path}`.\n"
        message << "\n#{exception.class} - #{exception.message}"
        message << "\n#{exception.backtrace.join("\n")}"
        message << "\n---------------------------------------------\n"
        puts prettify_plugin_load_error(message)
      end

      # Override to control how to print the warning that’s shown when an
      # exception occurs during plugin loading.
      #
      # By default this will be displayed in yellow if `#ansi_output?` returns
      # `true`.
      #
      # @param [String] message
      #        The plugin load error message.
      #
      def prettify_plugin_load_error(message)
        ansi_output? ? message.yellow : message
      end
    end

    #-------------------------------------------------------------------------#

    # Set to `true` if the user specifies the `--verbose` option.
    #
    # @note
    #
    #   If you want to make use of this value for your own configuration, you
    #   should check the value _after_ calling the `super` {Command#initialize}
    #   implementation.
    #
    # @return [Boolean]
    #
    #   Wether or not backtraces should be included when presenting the user an
    #   exception that includes the {InformativeError} module.
    #
    attr_accessor :verbose
    alias_method :verbose?, :verbose

    # Set to `true` if {Command.ansi_output} returns `true` and the user
    # did **not** specify the `--no-ansi` option.
    #
    # @note (see #verbose)
    #
    # @return [Boolean]
    #
    #   Whether or not to use ANSI codes to prettify output. For instance, by
    #   default {InformativeError} exception messages will be colored red and
    #   subcommands in help banners green.
    #
    attr_accessor :ansi_output
    alias_method :ansi_output?, :ansi_output

    def colorize_output
      warn "[!] The use of `CLAide::Command#colorize_output` has been " \
           "deprecated. Use `CLAide::Command#ansi_output` instead. " \
           "(Called from: #{caller.first})"
      ansi_output
    end
    alias_method :colorize_output?, :colorize_output

    def colorize_output=(flag)
      warn "[!] The use of `CLAide::Command#colorize_output=` has been " \
           "deprecated. Use `CLAide::Command#ansi_output=` instead. " \
           "(Called from: #{caller.first})"
      self.ansi_output = flag
    end

    # @return [Bool] Whether the command was invoked by an abstract command by
    #         default.
    #
    attr_accessor :invoked_as_default
    alias_method :invoked_as_default?, :invoked_as_default

    # Subclasses should override this method to remove the arguments/options
    # they support from `argv` _before_ calling `super`.
    #
    # The `super` implementation sets the {#verbose} attribute based on whether
    # or not the `--verbose` option is specified; and the {#ansi_output}
    # attribute to `false` if {Command.ansi_output} returns `true`, but the
    # user specified the `--no-ansi` option.
    #
    # @param [ARGV, Array] argv
    #
    #   A list of (user-supplied) params that should be handled.
    #
    def initialize(argv)
      argv = ARGV.new(argv) unless argv.is_a?(ARGV)
      @verbose = argv.flag?('verbose')
      @ansi_output = argv.flag?('ansi', Command.ansi_output?)

      color = argv.flag?('color')
      unless color.nil?
        warn "[!] The use of the `--color`/`--no-color` flag has been " \
             "deprecated. Use `--ansi`/`--no-ansi` instead."
        @ansi_output = color
      end

      @argv = argv
    end

    # Raises a Help exception if the `--help` option is specified, if `argv`
    # still contains remaining arguments/options by the time it reaches this
    # implementation, or when called on an ‘abstract command’.
    #
    # Subclasses should call `super` _before_ doing their own validation. This
    # way when the user specifies the `--help` flag a help banner is shown,
    # instead of possible actual validation errors.
    #
    # @raise [Help]
    #
    # @return [void]
    #
    def validate!
      help! if @argv.flag?('help')
      help! "Unknown arguments: #{@argv.remainder.join(' ')}" if !@argv.empty?
      help! if self.class.abstract_command?
    end

    # This method should be overridden by the command class to perform its work.
    #
    # @return [void
    #
    def run
      raise "A subclass should override the `CLAide::Command#run` method to " \
            "actually perform some work."
    end

    protected

    # @raise [Help]
    #
    #   Signals CLAide that a help banner for this command should be shown,
    #   with an optional error message.
    #
    # @return [void]
    #
    def help!(error_message = nil)
      if invoked_as_default?
        command = self.class.superclass
      else
        command = self.class
      end
      command = command.help!(error_message, ansi_output?)
    end

    #-------------------------------------------------------------------------#

  end
end