require "juicer/chainable"

module Juicer

  # Defines an abstract implementation of a binary that needs to be "shelled
  # out" to be run. Provides a starting point when wrapping and API around a
  # shell binary.
  #
  # The module requires the including class to define the default_options
  # method. It should return a hash of options where options are keys and
  # default values are the values. Only options defined in this hash will be
  # allowed to set on the binary.
  #
  module Binary
    # Initialize binary with options
    # options = Hash of options, optional
    #
    def initialize(binary, options = {})
      @options = self.respond_to?(:default_options) ? default_options.merge(options) : options
      @opt_set = false
      @command = nil
      @binary = binary
      @path = []
    end

    def path
      @path
    end

    # Run command
    #
    def execute(*params)
      cmd = IO.popen(([self.command] + params).flatten, "r")
      results = cmd.gets(nil)
      cmd.close
      results
    end

    # Return the value of a given option
    # opt = The option to return value for
    #
    def get_opt(opt)
      @options[opt] || nil
    end

    # Return options as a cli arguments string. Optionally accepts a list of
    # options to exclude from the generated string
    #
    def options(*excludes)
      excludes = excludes.flatten.collect { |exc| exc.to_sym }
      @options.inject("") do |str, opt|
        if opt[1].nil? || excludes.include?(opt[0].to_sym)
          str
        else
          val = opt[1] == true ? '' : opt[1]
          option = opt[0].to_s
          option = (option.length == 1 ? "-" : "--") + option.gsub('_', '-')
          "#{str} #{option} #{val}".strip
        end
      end
    end

    # Set an option. Important: you can only set options that are predefined by the
    # implementing class
    # opt = The option to set
    # value = The value of the option
    #
    def set_opt(opt, value)
      opt = opt.to_sym
      if @options.key?(opt)
        @options[opt] = value
        @opt_set = true
      else
        msg = "Illegal option '#{opt}', specify one of: #{@options.keys.join(', ')}"
        raise ArgumentError.new(msg)
      end
    end

    # Performs simple parsing of a string of parameters. All recognized
    # parameters are set, non-existent arguments raise an ArgumentError
    #
    def set_opts(options)
      options = options.split " "
      option = nil
      regex = /^--?([^=]*)(=(.*))?/

      while word = options.shift
        if word =~ regex
          if option
            set_opt option, true
          end

          if $3
            set_opt $1, $3
          else
            option = $1
          end
        else
          set_opt option, word
          option = nil
        end
      end
    end

    # Constructs the command to use
    #
    def command
      return @command if !@opt_set && @command
      @opt_set = false
      @command = [@binary, options]
    end

    # Locate the binary to execute. The binary is searched for in the
    # following places:
    #
    # 1) The paths specified through my_binary.path << "/usr/bin"
    # 2) The path specified by the given environment variable
    # 3) Current working directory
    #
    # The name of the binary may be a glob pattern, resulting in +locate+
    # returning an array of matches. This is useful in cases where the path
    # is expected to store several versions oof a binary in the same directory,
    # like /usr/bin/ruby /usr/bin/ruby1.8 /usr/bin/ruby1.9
    #
    # +locate+ always returns an array, or nil if no binaries where found.
    # The result is always all files matching the given pattern in *one* of
    # the specified paths - ie the first path where the pattern matches
    # something.
    #
    def locate(bin_glob, env = nil)
      path << ENV[env] if env && ENV.key?(env) && File.exist?(ENV[env])

      (path << Dir.pwd).each do |path|
        files = Dir.glob(File.expand_path(File.join(path, bin_glob)))
        return files unless files.empty?
      end

      nil
    end

    # Allows for options to be set and read directly on the object as though they were
    # standard attributes. compressor.verbose translates to
    # compressor.get_opt('verbose') and compressor.verbose = true to
    # compressor.set_opt('verbose', true)
    def method_missing(m, *args)
      if @options.key?(m)
        # Only hit method_missing once per option
        self.class.send(:define_method, m) do       # def verbose
          get_opt(m)                                #   get_opt(:verbose)
        end                                         # end

        return get_opt(m)
      end

      return super unless m.to_s =~ /=$/

      opt = m.to_s.sub(/=$/, "").to_sym

      if @options.key?(opt)
        # Only hit method_missing once per option
        self.class.send(:define_method, m) do      # def verbose=(val)
          set_opt(opt, args[0])                    #   set_opt(:verbose, val)
        end                                        # end

        return set_opt(opt, args[0])
      end

      super
    end
  end
end
