require 'rbkb'
require 'rbkb/extends'
require 'optparse'

# Copyright 2009 emonti at matasano.com
# See README.rdoc for license information
#
module Rbkb::Cli
  # Rbkb::Cli::Executable is an abstract class for creating command line
  # executables using the Ruby Black Bag framework.
  class Executable
    def self.run(param = {})
      new(param).go
    end

    attr_accessor :stdout, :stderr, :stdin, :argv, :opts, :oparse
    attr_reader   :exit_status

    # Instantiates a new Executable object.
    #
    # The 'param' argument is a named value hash. The following keys are
    # significant:
    #
    #  :argv   - An array of cli arguments (default ARGV)
    #  :opts    - executable/function options for use when running 'go'
    #  :stdout, - IO redirection (mostly for unit tests)
    #  :stderr,
    #  :stdin
    #
    #
    # The above keys are deleted from the 'param' hash and stored as instance
    # variables with attr_accessors.  All other parameters are ignored.
    def initialize(param = {})
      @argv   ||= param.delete(:argv) || ARGV
      @stdout ||= param.delete(:stdout) || STDOUT
      @stderr ||= param.delete(:stderr) || STDERR
      @stdin  ||= param.delete(:stdin) || STDIN
      @opts   ||= param.delete(:opts) || {}
      @parser_got_range = nil
      yield self if block_given?
      make_parser
    end

    # Wrapper for Kernel.exit() so we can unit test cli tools
    def exit(ret)
      @exit_status = ret
      if defined? Rbkb::Cli::TESTING
        throw((ret == 0 ? :exit_zero : :exit_err), ret)
      else
        Kernel.exit(ret)
      end
    end

    # This method exits with a message on stderr
    def bail(msg)
      @stderr.puts msg if msg
      self.exit(1)
    end

    # This method wraps a 'bail' with a basic argument error mesage and hint
    # for the '-h or --help' flag
    # The 'arg_err'  parameter is a string with the erroneous arguments
    def bail_args(arg_err)
      bail "Error: bad arguments - #{arg_err}\n  Hint: Use -h or --help"
    end

    # Prepares an OptionsParser object with blackbag standard options
    # This is called from within initialize() and should be overridden in
    # inherited classes to add additional OptionParser-based parsers.
    #
    # See parse for actual parsing.
    def make_parser
      @oparse ||= OptionParser.new
      @oparse.banner = "Usage: #{File.basename $0} [options]"

      @oparse.on('-h', '--help', 'Show this message') do
        bail(@oparse)
      end

      @oparse.on('-v', '--version', 'Show version and exit') do
        @stdout.puts("Ruby BlackBag version #{Rbkb::VERSION}")
        self.exit(0)
      end

      @oparse
    end

    # Abstract argument parser. Override this method with super() from
    # inherited executables. The base method just calls OptionParser.parse!
    # on the internal @oparse object.
    def parse
      # parse flag arguments
      begin
        @oparse.parse!(@argv)
      rescue StandardError
        (bail_args($!))
      end
      @parsed = true

      # the overriding class may implement additional arguments from here
    end

    # Abstract 'runner'. Override this method with super() from inherited
    # executables. The base method just slurps in an optional argv and
    # runs 'parse' if it hasn't already
    def go(argv = nil)
      @exit_status = nil
      @argv = argv if argv

      parse

      # the overriding class implements actual functionality beyond here
    end

    private

    # Wraps a file read with a standard bail error message
    def do_file_read(f)
      File.read(f)
    rescue StandardError
      (bail "File Read Error: #{$!}")
    end

    # Implements a basic input file argument. File reading is handled
    # by do_file_read().
    #
    # Takes one argument, which is the @opts hash keyname to store
    # the file data into.
    # (Used commonly throughout several executables)
    def add_std_file_opt(inkey)
      @oparse.on('-f', '--file FILENAME', 'Input from FILENAME') do |f|
        @opts[inkey] = do_file_read(f)
      end
      @oparse
    end

    # Implements numeric and hex range options via '-r' and '-x'
    #
    # Takes two arguments which are the @opts hash key names for
    # first and last parameters.
    #
    # (Used commonly throughout several executables)
    def add_range_opts(fkey, lkey)
      @oparse.on('-r', '--range=START[:END]',
                 'Start and optional end range') do |r|
        raise '-x and -r are mutually exclusive' if @parser_got_range

        @parser_got_range = true

        raise "invalid range #{r.inspect}" unless /^(-?[0-9]+)(?::(-?[0-9]+))?$/.match(r)

        @opts[fkey] = ::Regexp.last_match(1).to_i
        @opts[lkey] = ::Regexp.last_match(2).to_i if ::Regexp.last_match(2)
      end

      @oparse.on('-x', '--hexrange=START[:END]',
                 'Start and optional end range in hex') do |r|
        raise '-x and -r are mutually exclusive' if @parser_got_range

        @parser_got_range = true

        raise "invalid range #{r.inspect}" unless /^(-?[0-9a-f]+)(?::(-?[0-9a-f]+))?$/i.match(r)

        @opts[fkey] =
          if ::Regexp.last_match(1)[0, 1] == '-'
            ::Regexp.last_match(1)[1..-1].hex_to_num * -1
          else
            ::Regexp.last_match(1).hex_to_num
          end

        if ::Regexp.last_match(2)
          @opts[lkey] =
            if ::Regexp.last_match(2)[0, 1] == '-'
              ::Regexp.last_match(2)[1..-1].hex_to_num * -1
            else
              ::Regexp.last_match(2).hex_to_num
            end
        end
      end
    end

    # Conditionally parses a string argument. Uses 'key' to first check for
    # then store it in @opts hash if it is not yet there.
    # (Used commonly throughout several executables)
    def parse_string_argument(key)
      if @opts[key].nil? and s = @argv.shift
        @opts[key] = s.dup
      end
    end

    # Conditionally parses a file argument. Uses 'key' to first check for
    # then store it in @opts hash if it is not yet there.
    # (Used commonly throughout several executables)
    def parse_file_argument(key)
      if @opts[key].nil? and f = @argv.shift
        @opts[key] = do_file_read(f)
      end
    end

    # For use at the end of a parser - calls bail_args with remaining
    # arguments if there are extra arguments.
    # (Used commonly throughout several executables)
    def parse_catchall
      bail_args(@argv.join(' ')) if @argv.length != 0
    end
  end
end
