# Copyright (c) 2007 Tim Coulter
# 
# You are free to modify and use this file under the terms of the GNU LGPL.
# You should have received a copy of the LGPL along with this file.
# 
# Alternatively, you can find the latest version of the LGPL here:
#      
#      http://www.gnu.org/licenses/lgpl.txt
# 
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.


require "svn/repos"
require "md5"
require "fileutils"

# Define three exceptions that will be used to "translate" actual Subversion
# exceptions to give nicer names and maintain separation.
class SvnPathNotFoundError < RuntimeError
end

class SvnNoSuchRevisionError < RuntimeError
end

class SvnNoSuchTransactionError < RuntimeError
end

class SvnRepos

  attr_reader :repos_path
  
  # Translate Subverion specific constants into more programmer friendly terms.
  SVN_CONSTANTS = {:author => Svn::Core::PROP_REVISION_AUTHOR, :date => Svn::Core::PROP_REVISION_DATE}
  SVN_FS_TYPES = {:fsfs => Svn::Fs::TYPE_FSFS, :bdb => Svn::Fs::TYPE_BDB}

  # Constructor. Open the repository if it exists.  
  def initialize(repos_path)
  
    @repos_path = repos_path
  
    if (self.class.repository_exists?(@repos_path))
      @repos = Svn::Repos.open(@repos_path)
    else
      raise "Repository does not exist at path \"" + @repos_path + "\""
    end
    
  end
  
  # Synonym for new.
  def self.open(repos_path)
    SvnRepos.new(repos_path)
  end
  
  # Create a new repository if one doesn't already exist.
  # 
  # The FS type defaults to :fsfs. If you get the "Can't grab FSFS repository mutex" error,
  # you need to upgrade Subversion and your Subversion Ruby Bindings to version 1.4 or higher.
  # Alternatively, you can use :bdb.
  # 
  # Here's more info: http://svn.haxx.se/dev/archive-2006-10/0010.shtml
  def self.create(repos_path, fs_type = :fsfs)
    if (self.repository_exists?(repos_path))
      raise "Repository already exists at path \"" + repos_path + "\"; cannot create."
    end
    
    fs_config = {Svn::Fs::CONFIG_FS_TYPE => SVN_FS_TYPES[fs_type]} 
    Svn::Repos.create(repos_path, {}, fs_config)
    SvnRepos.open(repos_path)
  end
  
  def self.repository_exists?(repos_path)
    File.exist?(File.join(repos_path, "format"))
  end
  
  def self.delete!(repos_path)
    FileUtils.remove_dir(repos_path, false)
  end
 
  # Commit a file. This function either takes in a hash with the :path and :data
  # keys set to their respective values, or it takes in an array of hashes each with the
  # same keys set. Passing an array will let you commit multiple files at once.
  # 
  # The attributes hash is option, and as of now, is only available for setting the
  # author of a revisoin. An example would be {:author => "tcoulter"}.
  # 
  # Note that this function takes care of making sure files and directories are present
  # within the repository. You don't have to create them; this function will do it for you.
  def commit(requests={})
    
    if (block_given?)
      yield requests
    end
    
    throw "Nothing commited. Request size was 0!" if (requests.size == 0)
    
    txn = begin_transaction(requests)
    add_to_transaction(requests, txn)
    commit_transaction(txn)
  end
  
  def begin_transaction(requests={})
    #txn = @repos.transaction_for_commit(requests[:author], requests[:log])
    txn = @repos.fs.transaction    
    
#    requests.delete(:author)
#    requests.delete(:log)
    
    requests.each do |request, value|
      if (request.kind_of? Symbol)
        txn.set_prop(SVN_CONSTANTS[request] || request.intern, value)
      end
    end
    
    txn
  end
  
  def add_to_transaction(requests, txn)
    process_commit_list(requests, txn)
  end
  
  def commit_transaction(txn)
    raise SvnNoSuchTransactionError.new("Transaction doesn't exist in repository.") unless @repos.fs.transactions.include?(txn.name)
    @repos.commit(txn)
  end
  
  # Get the contents of a file at a specific revision.
  # If no revision is passed, the most recent data is returned.
  # 
  # Note: If the files at the given path does not exist at the specified revision,
  # then this function will return nil.
  # TODO: Check the above statement.
  def file_contents(paths, revision = nil)
  
    expects_array = paths.kind_of? Array
    if (!expects_array)
      paths = [paths]  
    end
    
    paths.collect! {|path|
    
      throw "Nil path found!" if (path.nil?) 

      begin
        @repos.fs.root(revision).file_contents(path){|f| f.read}
      rescue Svn::Error::FS_NOT_FOUND => e
        throw_path_not_found_error(e.message)
      rescue Exception => e
        raise SvnNoSuchRevisionError.new("Revision " + revision.to_s + " does not exist.")
      end
    }
    
    if (!expects_array)
      paths.first
    else
      paths
    end
  end
  
  # This function is very similar to @repos.fs.history(); however, it's been altered a little
  # to return only an array of revision numbers. This function, in contrast to the original,
  # takes multiple paths and returns one large history for all paths given.
  def history(paths, starting_revision=nil, ending_revision=nil)

    # We do the to_i's because we want to leave the value nil if it is.
    if (starting_revision.to_i < 0)
      raise SvnNoSuchRevisionError.new("Invalid start revision " + starting_revision.to_i.to_s + ".")
    end

    revision_numbers = []
    
    paths = [paths].flatten
    paths.each do |path|
      hist = []
      history_function = Proc.new do |path, revision|
        yield(path, revision) if block_given?
        hist << revision
      end
      begin
        Svn::Repos.history2(@repos.fs, path, history_function, nil, starting_revision || 0, 
                       ending_revision || @repos.fs.youngest_rev, true)
      rescue Svn::Error::FS_NOT_FOUND => e
        throw_path_not_found_error(e.message)
      rescue Svn::Error::FS_NO_SUCH_REVISION => e
        raise SvnNoSuchRevisionError.new("Ending revision " + ending_revision.to_s + " does not exist.")
      end
                          
      revision_numbers.concat hist
    end
    
    revision_numbers.sort.uniq
  end
  
  # Get the Subversion properties for a given revision id, or an array of revision ids.
  # It returns an array of hashes, where each hash in the returned array corresponds with the
  # revision id in the passed array. This function works cleanly with the return value of history().
  def properties(revisions)
      revisions = [revisions].flatten
      revisions.collect{|revision|
        begin
          proplist = @repos.fs.proplist(revision)
        rescue Svn::Error::FS_NO_SUCH_REVISION => e
          raise SvnNoSuchRevisionError.new("Revision " + revision.to_s + " does not exist.")
        end
        proplist[:id] = revision
        make_hash_friendly(proplist)
        proplist
      }  
  end
  
  def property(prop, rev=nil)
    @repos.prop(SVN_CONSTANTS[prop] || prop.to_s, rev)  
  end
  
  # Diff the file at a given path and revision with the file at the given
  # base_path and base_revision. The value of revision is considered
  # to be newer in time; if a higher number is specified for base_revision than revision,
  # this function will be diffing backwards in time.
  #
  # This function was pulled from the do_diff function in the Binding's info.rb and
  # edited to suit our needs.
  def diff(base_path, base_revision, path, revision)

    # Note: This differ statement was taken from the Subverion Bindings try_diff()
    # function in info.rb; there were four options for situations I didn't understand.
    # This may not work as expected for all situations (however, it seems to for all I've tried).
    begin
      differ = Svn::Fs::FileDiff.new(@repos.fs.root(base_revision), base_path, @repos.fs.root(revision), path)
    
      diff = ""
  
      if differ.binary?
        diff = "(Binary files differ)\n"
      else
        base_label = "#{base_path} (rev #{base_revision})"
        label = "#{path} (rev #{revision})"
        diff = differ.unified(base_label, label)
      end
    
    rescue Svn::Error::FS_NOT_FOUND => e
      throw_path_not_found_error(e.message)
    rescue Svn::Error::FS_NO_SUCH_REVISION => e
      raise SvnNoSuchRevisionError.new("Either revision " + base_revision.to_s + " or revision " + revision.to_s + " does not exist.")
    end
    
    diff
  end

  # Returns the most recent revision of the repository. If a path is specified, 
  # the youngest revision is returned for that path; if a revision is also specified,
  # the function will return the youngest revision that is equal to or older than the one passed.
  # 
  # This will only work for paths that have not been deleted from the repository.
  def youngest_revision(path=nil, revision=nil)
    if (!path.nil?)
      begin
        data = Svn::Repos.get_committed_info(@repos.fs.root(revision || @repos.fs.youngest_rev), path)
        return data[0]
      rescue Svn::Error::FS_NOT_FOUND => e
        throw_path_not_found_error(e.message)
      rescue Svn::Error::FS_NO_SUCH_REVISION => e
        raise SvnNoSuchRevisionError.new("Revision " + revision.to_s + " does not exist.")
      end
    else
      return @repos.fs.youngest_rev
    end
  end
  
  def revision_count
    youngest_revision
  end
  
  def path_exists?(path, revision=nil)
    @repos.fs.root(revision).check_path(path) != 0
  end
  
  def touch(path, attributes={})
    if (!path_exists?(path))
      commit({path => ""}.merge(attributes))
    end
  end
  
  def delete(path, attributes={})
    begin
      txn = begin_transaction(attributes)
      txn.root.delete(path)
      commit_transaction(txn)
    rescue Svn::Error::FS_NOT_FOUND => e
      throw_path_not_found_error(e.message)
    end
  end
  
  # Provides a list of directory entries. path must be a directory.
  def ls(path="/", revision=nil)
    entries = @repos.fs.root(revision).dir_entries(path)
    entries.each do |key, value|
      entries[key] = (value.kind == 1) ? :file : :directory
    end
    entries
  end
  
  # Dump the filesystem. There's a lot of functionality here that is not being
  # utilized, and there may be special cases I don't understand (like, what's with the
  # feedback thing; the second parameter?).
  def dump
    dump_stream = StringIO.new("")
    @repos.dump_fs(dump_stream, StringIO.new(""), 0, @repos.youngest_rev)
    dump_stream.rewind
    dump_stream.read
  end
  
  def load(dump_stream)
    dump_stream = StringIO.new(dump_stream) if (dump_stream.is_a? String)
    dump_stream.rewind
    @repos.load_fs(dump_stream, StringIO.new(""), Svn::Repos::LOAD_UUID_DEFAULT, "/")     
  end
  
private

  def process_commit_list(requests, txn)
    requests.each do |key, value|
      # If the request key is a string, then the key represents a file
      # within the repository.
      if (key.kind_of? String)
        write_file(txn, key, value)
      end
    end 
  end

  # Make a directory if it's not already present.
  def make_directory(txn, path)  
    if (txn.root.check_path(path) == 0)
      txn.root.make_dir(path)
    end
  end

  # Make a file if it's not already present.
  def make_file(txn, path)
    if (txn.root.check_path(path) == 0)
      txn.root.make_file(path)
    end
  end
  
  # Set the author if it's present in the attributes hash.
  def set_author_if_present(txn, requests)
    if (requests.has_key? :author)
      txn.set_prop(SVN_CONSTANTS[:author], requests[:author])
    end
  end
  
  # Helper. This function does the actual writing.
  def write_file(txn, path, data)
    
    if (!path_exists?(path))
      pieces = path.split("/").delete_if {|x| x == ""}
        
      dir_path = ""
      (0..pieces.length - 2).each do |index|
      
        dir_path += "/" + pieces[index]
        make_directory(txn, dir_path)
      end
      
      make_file(txn, path)
    end
    
    #checksum = MD5.new(data).hexdigest
    stream = txn.root.apply_text(path)#, checksum)
    stream.write(data)
    stream.close 
  end

  def make_hash_friendly(hash)
    SVN_CONSTANTS.each do |friendly_key, svn_key|
      if (hash.has_key?(svn_key))
        hash[friendly_key] = hash[svn_key] 
        hash.delete(svn_key)
      end              
    end
  end
  
  def throw_path_not_found_error(svn_message)
    message = svn_message[svn_message.rindex("File not found")..svn_message.length-1]
    raise SvnPathNotFoundError.new(message)
  end
  
  def throw_cant_open_directory_error(svn_message)
    message = svn_message[svn_message.rindex("Can't open directory")..svn_message.length-1] 
    raise SvnPathNotFoundError.new(message)
  end
  
end
