require 'roll/version'
require 'roll/metadata'
require 'roll/environment'

module Roll

  # = Library class
  #
  class Library

    # Dynamic link extension.
    #DLEXT = '.' + ::Config::CONFIG['DLEXT']

    #
    SUFFIXES = ['', '.rb', '.rbw', '.so', '.bundle', '.dll', '.sl', '.jar']

    #
    SUFFIX_PATTERN = "{#{SUFFIXES.join(',')}}"

    # Get an instance of a library by name, or name and version.
    # Libraries are singleton, so once loaded the same object is
    # always returned.

    def self.instance(name, constraint=nil)
      name = name.to_s
      #raise "no library -- #{name}" unless ledger.include?(name)
      return nil unless ledger.include?(name)

      library = ledger[name]

      if Library===library
        if constraint # TODO: it's okay if constraint fits current
          raise VersionConflict, "previously selected version -- #{ledger[name].version}"
        else
          library
        end
      else # library is an array of versions
        if constraint
          compare = Version.constraint_lambda(constraint)
          library = library.select(&compare).max
        else
          library = library.max
        end
        unless library
          raise VersionError, "no library version -- #{name} #{constraint}"
        end
        #ledger[name] = library
        #library.activate
        return library
      end
    end

    # A shortcut for #instance.

    def self.[](name, constraint=nil)
      instance(name, constraint)
    end

    # Same as #instance but will raise and error if the library is
    # not found. This can also take a block to yield on the library.

    def self.open(name, constraint=nil) #:yield:
      lib = instance(name, constraint)
      unless lib
        raise LoadError, "no library -- #{name}"
      end
      yield(lib) if block_given?
      lib
    end

    #
    def initialize(location, name=nil, options={})
      @location = location
      @name     = name
      @options  = options
    end

    #
    def location
      @location
    end

    # Access to metadata.
    def metadata
      @metadata ||= Metadata.new(location, name, @options)
    end

    #
    def name
      @name ||= metadata.name
    end

    #
    def version
      @version ||= metadata.version
    end

    #
    def active?
      true #@active ||= metadata.active
    end

    #
    def loadpath
      @loadpath ||= metadata.loadpath
    end

    #
    def requires
      @requires ||= metadata.requires
    end

    #
    def released
      @released ||= metadata.released
    end

    # TODO
    def verify
      requires.each do |(name, constraint)|
        Library.open(name, constraint)
      end
    end

    # Find first matching +file+.

    #def find(file, suffix=true)
    #  case File.extname(file)
    #  when *SUFFIXES
    #    find = File.join(lookup_glob, file)
    #  else
    #    find = File.join(lookup_glob, file + SUFFIX_PATTERN) #'{' + ".rb,#{DLEXT}" + '}')
    #  end
    #  Dir[find].first
    #end

    # Standard loadpath search.
    #
    def find(file, suffix=true)
      lp = loadpath
      if suffix
        SUFFIXES.each do |ext|
          lp.each do |lpath|
            f = File.join(location, lpath, file + ext)
            return f if File.file?(f)
          end
        end
      else
        lp.each do |lpath|
          f = File.join(location, lpath, file)
          return f if File.file?(f)
        end
      end
      nil
    end

    # Does this library have a matching +file+? If so, the full-path
    # of the file is returned.
    #
    # Unlike #find, this also matches within the library directory
    # itself, eg. <tt>lib/foo/*</tt>. It is used by #acquire.
    def include?(file, suffix=true)
      lp = loadpath
      if suffix
        SUFFIXES.each do |ext|
          lp.each do |lpath|
            f = File.join(location, lpath, name, file + ext)
            return f if File.file?(f)
            f = File.join(location, lpath, file + ext)
            return f if File.file?(f)
          end
        end
      else
        lp.each do |lpath|
          f = File.join(location, lpath, name, file)
          return f if File.file?(f)
          f = File.join(location, lpath, file)
          return f if File.file?(f)
        end
      end
      nil
    end

    #def include?(file)
    #  case File.extname(file)
    #  when *SUFFIXES
    #    find = File.join(lookup_glob, "{#{name}/,}" + file)
    #  else
    #    find = File.join(lookup_glob, "{#{name}/,}" + file + SUFFIX_PATTERN) #'{' + ".rb,#{DLEXT}" + '}')
    #   end
    #  Dir[find].first
    #end

    #
    def require(file)
      if path = include?(file)
        require_absolute(path)
      else
        load_error = LoadError.new("no such file to require -- #{name}:#{file}")
        raise clean_backtrace(load_error)
      end
    end

    # NOT SURE ABOUT USING THIS
    def require_absolute(file)
      #Library.load_monitor[file] << caller if $LOAD_MONITOR
      Library.load_stack << self
      begin
        success = roll_original_require(file)
      #rescue LoadError => load_error
      #  raise clean_backtrace(load_error)
      ensure
        Library.load_stack.pop
      end
      success
    end

    #
    def load(file, wrap=nil)
      if path = include?(file, false)
        load_absolute(path, wrap)
      else
        load_error = LoadError.new("no such file to load -- #{name}:#{file}")
        clean_backtrace(load_error)
      end
    end

    #
    def load_absolute(file, wrap=nil)
      #Library.load_monitor[file] << caller if $LOAD_MONITOR
      Library.load_stack << self
      begin
        success = roll_original_load(file, wrap)
      #rescue LoadError => load_error
      #  raise clean_backtrace(load_error)
      ensure
        Library.load_stack.pop
      end
      success
    end

    # Inspection.
    def inspect
      if version
        %[#<Library #{name}/#{@version} @location="#{location}">]
      else
        %[#<Library #{name} @location="#{location}">]
      end
    end

    def to_s
      inspect
    end

    # Compare by version.
    def <=>(other)
      version <=> other.version
    end

#    # List of subdirectories that are searched when loading.
#    #--
#    # This defualts to ['lib/{name}', 'lib']. The first entry is
#    # usually proper location; the latter is added for default
#    # compatability with the traditional require system.
#    #++
#    def libdir
#      loadpath.map{ |path| File.join(location, path) }
#    end
#
#    # Does the library have any lib directories?
#    def libdir?
#      lib.any?{ |d| File.directory?(d) }
#    end

    # Location of executable. This is alwasy bin/. This is a fixed
    # convention, unlike lib/ which needs to be more flexable.
    def bindir  ; File.join(location, 'bin') ; end

    # Is there a <tt>bin/</tt> location?
    def bindir? ; File.exist?(bindir) ; end

    # Location of library system configuration files.
    # This is alwasy the <tt>etc/</tt> directory.
    def confdir ; File.join(location, 'etc') ; end

    # Is there a <tt>etc/</tt> location?
    def confdir? ; File.exist?(confdir) ; end

    # Location of library shared data directory.
    # This is always the <tt>data/</tt> directory.
    def datadir ; File.join(location, 'data') ; end

    # Is there a <tt>data/</tt> location?
    def datadir? ; File.exist?(datadir) ; end

  private

    #
    #def lookup_glob
    #  @lookup_glob ||= File.join(location, '{' + loadpath.join(',') + '}')
    #end

    #
    def clean_backtrace(error)
      if $DEBUG
        error
      else
        bt = error.backtrace
        bt = bt.reject{ |e| /roll/ =~ e } if bt
        error.set_backtrace(bt)
        error
      end
    end

    # Ledger augments the Library metaclass.
    class << self
      # Instance of Ledger class.
      def ledger
        @ledger ||= Ledger.new
      end

      # Current environment
      def environment
        ledger.environment
      end

      # List of library names.
      def list
        ledger.names
      end

      #
      def require(path)
        ledger.require(path)
      end

      #
      def load(path, wrap=nil)
        ledger.load(path, wrap)
      end

      #
      def acquire(path, opts={})
        ledger.acquire(path, opts)
      end

      #
      def load_stack
        ledger.load_stack
      end

      ## NOTE: Not used yet.
      #def load_monitor
      #  ledger.load_monitor
      #end
    end
  end

  # = Ledger class
  #
  class Ledger

    include Enumerable

    #
    def initialize
      @index = Hash.new{|h,k| h[k] = []}

      @environment = Environment.new

      @environment.each do |name, paths|
        paths.each do |path|
          unless File.directory?(path)
            warn "invalid path for #{name} -- #{path}"
            next
          end
          lib = Library.new(path, name)
          @index[name] << lib if lib.active?
        end
      end

      @load_stack = []
      #@load_monitor = Hash.new{ |h,k| h[k]=[] }
    end

    #
    def enironment
      @environment
    end

    #
    def [](name)
      @index[name]
    end

    #
    def []=(name, value)
      @index[name] = value
    end

    #
    def include?(name)
      @index.include?(name)
    end

    #
    def names
      @index.keys
    end

    #
    def each(&block)
      @index.each(&block)
    end

    #
    def size
      @index.size
    end

    #
    def load_stack
      @load_stack
    end

    ## NOTE: Not used yet.
    #def load_monitor
    #  @load_monitor
    #end

    #--
    # The BIG QUESTION: Should Ruby's underlying require
    # be tried first then fallback to Rolls. Or vice-versa?
    #
    #  begin
    #    original_require(path)
    #  rescue LoadError => load_error
    #    lib, file = *match(path)
    #    if lib && file
    #      constrain(lib)
    #      lib.require_absolute(file)
    #    else
    #      raise clean_backtrace(load_error)
    #    end
    #  end
    #++

    #
    def require(path)
      #return if $".include?(path)
      #return if $".include?(path+'.rb')

      lib, file = *match(path)
      if lib && file
        constrain(lib)
        lib.require_absolute(file)
      else
        begin
          roll_original_require(path)
        rescue LoadError => load_error
          raise clean_backtrace(load_error)
        end
      end

    end

    #
    def load(path, wrap=nil)
      lib, file = *match(path, false)
      if lib && file
        constrain(lib)
        lib.load_absolute(file, wrap)
      else
        begin
          roll_original_load(path, wrap)
        rescue LoadError => load_error
          raise clean_backtrace(load_error)
        end
      end
    end

    # Acquire is pure Roll-style loading. First it
    # looks for a specific library via ':'. If ':' is
    # not present it then tries the current library.
    # Failing that it fallsback to Ruby itself.
    #
    #   acquire('facets:string/margin')
    #
    # To "load" the library, rather than "require" it set
    # the +:load+ option to true.
    #
    #   acquire('facets:string/margin', :load=>true)
    #
    def acquire(file, opts={})
      if file.index(':') # a specific library
        name, file = file.split(':')
        lib = Library.open(name)
      else # try the current library
        cur = load_stack.last
        if cur && cur.include?(file)
          lib = cur
        elsif !file.index('/') # is this a library name?
          if cur = Library.instance(file)
            lib  = cur
            file = lib.default # default file to load
          end
        end
      end
      if opts[:load]
        lib ? lib.load(file) : roll_original_load(file)
      else
        lib ? lib.require(file) : roll_original_require(file)
      end
    end

    #
    def constrain(lib)
      cmp = self[lib.name]
      if Array === cmp
        self[lib.name] = lib
      else
        if lib.version != cmp.version
          raise VersionError
        end
      end
    end

  private

    # Find require matches.
    def match(path, suffix=true)
      path = path.to_s

      # Ruby appears to have a special exception for enumerator.
      return nil if path == 'enumerator' 

      # absolute path
      return nil if /^\// =~ path

      if path.index(':') # a specified library
        name, path = path.split(':')
        lib = Library.open(name)
        if lib.active?
          #file = lib.find(File.join(name,path), suffix)
          file = lib.include?(path, suffix)
          return lib, file
        end
      end

      matches = []

      # try the load stack first
      load_stack.reverse_each do |lib|
        if file = lib.find(path, suffix)
          return [lib, file] unless $VERBOSE
          matches << [lib, file]
        end
      end

      # if the head of the path is the library
      name, *_ = path.split(/\/|\\/)
      lib = Library[name]
      if lib && lib.active?
        if file = lib.find(path, suffix)
          return [lib, file] unless $VERBOSE
          matches << [lib, file]
        end
      end

      # standard ruby locations
      return nil if $LOAD_PATH.find do |lp|
        if suffix
          Library::SUFFIXES.find do |s|
            File.exist?(File.join(lp, path + s))
          end
        else
          File.exist?(File.join(lp, path))
        end
      end


      # TODO: Perhaps the selected and unselected should be kept in separate lists?
      unselected, selected = *@index.partition{ |name, libs| Array === libs }

      # broad search pre-selected libraries
      selected.each do |(name, lib)|
        if file = lib.find(path, suffix)
          #matches << [lib, file]
          #return matches.first unless $VERBOSE
          return [lib, file] unless $VERBOSE
          matches << [lib, file]
        end
      end

      # finally try a broad search on unselected libraries
      unselected.each do |(name, libs)|
        pos = []
        libs.each do |lib|
          if file = lib.find(path, suffix)
            pos << [lib, file]
          end
        end
        unless pos.empty?
          latest = pos.sort{ |a,b| b[0].version <=> a[0].version }.first
          return latest unless $VERBOSE
          matches << latest
          #return matches.first unless $VERBOSE
        end
      end

      matches.uniq!

      if matches.size > 1
        warn_multiples(path, matches)
      end

      matches.first
    end

    #
    def warn_multiples(path, matches)
      warn "multiple matches for same request -- #{path}"
      matches.each do |lib, file|
        warn "  #{file}"
      end
    end

    #
    def warn(message)
      $stderr.puts("roll: #{message}") if $DEBUG || $VERBOSE
    end

    #
    def clean_backtrace(error)
      if $DEBUG
        error
      else
        bt = error.backtrace
        bt = bt.reject{ |e| /roll/ =~ e }
        error.set_backtrace(bt)
        error
      end
    end

  end#class Ledger

  # VersionError is raised when a requested version cannot be found.
  class VersionError < ::RangeError  # :nodoc:
  end

  # VersionConflict is raised when selecting another version
  # of a library when a previous version has already been selected.
  class VersionConflict < ::LoadError  # :nodoc:
  end

end