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

      return @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
      @oparse.parse!(@argv) rescue(bail_args($!))
      @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(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
      return @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

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

        @opts[fkey] = $1.to_i
        @opts[lkey] = $2.to_i if $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

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

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

        if $2
          @opts[lkey] = 
            if($2[0,1] == '-')
              $2[1..-1].hex_to_num * -1
            else
              $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

