require 'shellwords'
module Boson
  # Scientist redefines the methods of commands that have options and/or take global options. This redefinition
  # allows a command to receive its arguments normally or as a commandline app does. For a command's
  # method to be redefined correctly, its last argument _must_ expect a hash.
  #
  # Take for example this basic method/command with an options definition:
  #   options :level=>:numeric, :verbose=>:boolean
  #   def foo(arg='', options={})
  #     [arg, options]
  #   end
  #
  # When Scientist wraps around foo(), argument defaults are respected:
  #    foo '', :verbose=>true   # normal call
  #    foo '-v'                 # commandline call
  #
  #    Both calls return: ['', {:verbose=>true}]
  #
  # Non-string arguments can be passed in:
  #    foo Object, :level=>1
  #    foo Object, 'l1'
  #
  #    Both calls return: [Object, {:level=>1}]
  #
  # === Global Options
  # Any command with options comes with default global options. For example '-hv' on such a command
  # prints a help summarizing a command's options as well as the global options.
  # When using global options along with command options, global options _must_ precede command options.
  # Take for example using the global --pretend option with the method above:
  #   irb>> foo '-p -l=1'
  #   Arguments: ["", {:level=>1}]
  #   Global options: {:pretend=>true}
  #
  # If a global option conflicts with a command's option, the command's option takes precedence. You can get around
  # this by passing a --global option which takes a string of options without their dashes. For example:
  #   foo '-p --fields=f1,f2 -l=1'
  #   # is the same as
  #   foo ' -g "p fields=f1,f2" -l=1 '
  #
  # === Rendering Views With Global Options
  # Perhaps the most important global option is --render. This option toggles the rendering of your command's output
  # with Hirb[http://github.com/cldwalker/hirb]. Since Hirb can be customized to generate any view, this option allows
  # you toggle a predefined view for a command without embedding view code in your command!
  #
  # Here's a simple example, toggling Hirb's table view:
  #   # Defined in a library file:
  #   #@options {}
  #   def list(options={})
  #     [1,2,3]
  #   end
  #
  #   Using it in irb:
  #   >> list
  #   => [1,2,3]
  #   >> list '-r'
  #   +-------+
  #   | value |
  #   +-------+
  #   | 1     |
  #   | 2     |
  #   | 3     |
  #   +-------+
  #   3 rows in set
  #   => true
  #
  # To default to rendering a view for a command, add a render_options {method attribute}[link:classes/Boson/MethodInspector.html]
  # above list() along with any options you want to pass to your Hirb helper class. In this case, using '-r' gives you the
  # command's returned object instead of a formatted view!
  module Scientist
    extend self
    # Handles all Scientist errors.
    class Error < StandardError; end
    class EscapeGlobalOption < StandardError; end

    attr_reader :global_options, :rendered
    @no_option_commands ||= []
    GLOBAL_OPTIONS = {
      :help=>{:type=>:boolean, :desc=>"Display a command's help"},
      :render=>{:type=>:boolean, :desc=>"Toggle a command's default rendering behavior"},
      :verbose=>{:type=>:boolean, :desc=>"Increase verbosity for help, errors, etc."},
      :global=>{:type=>:string, :desc=>"Pass a string of global options without the dashes"},
      :pretend=>{:type=>:boolean, :desc=>"Display what a command would execute without executing it"}
    } #:nodoc:
    RENDER_OPTIONS = {
      :fields=>{:type=>:array, :desc=>"Displays fields in the order given"},
      :sort=>{:type=>:string, :desc=>"Sort by given field"},
      :class=>{:type=>:string, :desc=>"Hirb helper class which renders"},
      :reverse_sort=>{:type=>:boolean, :desc=>"Reverse a given sort"},
      :max_width=>{:type=>:numeric, :desc=>"Max width of a table"},
      :vertical=>{:type=>:boolean, :desc=>"Display a vertical table"}
    } #:nodoc:

    # Redefines a command's method for the given object.
    def create_option_command(obj, command)
      cmd_block = create_option_command_block(obj, command)
      @no_option_commands << command if command.options.nil?
      [command.name, command.alias].compact.each {|e|
        obj.instance_eval("class<<self;self;end").send(:define_method, e, cmd_block)
      }
    end

    # The actual method which replaces a command's original method
    def create_option_command_block(obj, command)
      lambda {|*args|
        Boson::Scientist.translate_and_render(obj, command, args) {|args| super(*args) }
      }
    end

    #:stopdoc:
    def translate_and_render(obj, command, args)
      @global_options = {}
      args = translate_args(obj, command, args)
      if @global_options[:verbose] || @global_options[:pretend]
        puts "Arguments: #{args.inspect}", "Global options: #{@global_options.inspect}"
      end
      return @rendered = true if @global_options[:pretend]
      render_or_raw yield(args)
    rescue EscapeGlobalOption
      Boson.invoke(:usage, command.name, :verbose=>@global_options[:verbose]) if @global_options[:help]
    rescue OptionParser::Error, Error
      $stderr.puts "Error: " + $!.message
    end

    def translate_args(obj, command, args)
      @obj, @command, @args = obj, command, args
      @command.options ||= {}
      if parsed_options = command_options
        add_default_args(@args)
        return @args if @no_option_commands.include?(@command)
        @args << parsed_options
        if @args.size != command.arg_size && !command.has_splat_args?
          command_size, args_size = @args.size > command.arg_size ? [command.arg_size, @args.size] :
            [command.arg_size - 1, @args.size - 1]
          raise ArgumentError, "wrong number of arguments (#{args_size} for #{command_size})"
        end
      end
      @args
    rescue Error, ArgumentError, EscapeGlobalOption
      raise
    rescue Exception
      message = @global_options[:verbose] ? "#{$!}\n#{$!.backtrace.inspect}" : $!.message
      raise Error, message
    end

    def render_or_raw(result)
      (@rendered = render?) ? View.render(result, global_render_options) : result
    rescue Exception
      message = @global_options[:verbose] ? "#{$!}\n#{$!.backtrace.inspect}" : $!.message
      raise Error, message
    end

    def option_parser
      @command.render_options ? command_option_parser : default_option_parser
    end

    def command_option_parser
      (@option_parsers ||= {})[@command] ||= OptionParser.new render_options.merge(GLOBAL_OPTIONS)
    end

    def render_option_parser(cmd)
      @command = cmd
      option_parser
    end

    def default_option_parser
      @default_option_parser ||= OptionParser.new RENDER_OPTIONS.merge(GLOBAL_OPTIONS)
    end

    def render_options
      @command.render_options ? command_render_options : RENDER_OPTIONS
    end

    def command_render_options
      (@command_render_options ||= {})[@command] ||= begin
        @command.render_options.each {|k,v|
          if !v.is_a?(Hash) && !v.is_a?(Symbol) && RENDER_OPTIONS.keys.include?(k)
            @command.render_options[k] = {:default=>v}
          end
        }
        opts = Util.recursive_hash_merge(@command.render_options, Util.deep_copy(RENDER_OPTIONS))
        opts[:sort][:values] ||= opts[:fields][:values] if opts[:fields][:values]
        opts
      end
    end

    def global_render_options
      @global_options.dup.delete_if {|k,v| !render_options.keys.include?(k) }
    end

    def render?
      (@command.render_options && !@global_options[:render]) || (!@command.render_options && @global_options[:render])
    end

    def command_options
      if @args.size == 1 && @args[0].is_a?(String)
        parsed_options, @args = parse_options Shellwords.shellwords(@args[0])
      # last string argument interpreted as args + options
      elsif @args.size > 1 && @args[-1].is_a?(String)
        args = caller.grep(/bin_runner.rb:/).empty? ? Shellwords.shellwords(@args.pop) : @args
        parsed_options, new_args = parse_options args
        @args += new_args
      # add default options
      elsif (!@command.has_splat_args? && @args.size <= @command.arg_size - 1) ||
        (@command.has_splat_args? && !@args[-1].is_a?(Hash))
          parsed_options = parse_options([])[0]
      # merge default options with given hash of options
      elsif (@command.has_splat_args? || (@args.size == @command.arg_size)) && @args[-1].is_a?(Hash)
        parsed_options = parse_options([])[0]
        parsed_options.merge!(@args.pop)
      end
      parsed_options
    end

    def parse_options(args)
      parsed_options = @command.option_parser.parse(args, :delete_invalid_opts=>true)
      @global_options = option_parser.parse @command.option_parser.leading_non_opts
      new_args = option_parser.non_opts.dup + @command.option_parser.trailing_non_opts
      if @global_options[:global]
        global_opts = Shellwords.shellwords(@global_options[:global]).map {|str|
          ((str[/^(.*?)=/,1] || str).length > 1 ? "--" : "-") + str }
        @global_options.merge! option_parser.parse(global_opts)
      end
      raise EscapeGlobalOption if @global_options[:help]
      [parsed_options, new_args]
    end

    def add_default_args(args)
      if @command.args && args.size < @command.args.size - 1
        # leave off last arg since its an option
        @command.args.slice(0..-2).each_with_index {|arr,i|
          next if args.size >= i + 1 # only fill in once args run out
          break if arr.size != 2 # a default arg value must exist
          begin
            args[i] = @command.file_parsed_args? ? @obj.instance_eval(arr[1]) : arr[1]
          rescue Exception
            raise Error, "Unable to set default argument at position #{i+1}.\nReason: #{$!.message}"
          end
        }
      end
    end
    #:startdoc:
  end
end