#######################################################################
#                  Licensing Information                              #
#                                                                     #
#  The following code is a derivative work of the code from the       #
#  Mercurial project, which is licensed GPLv2. This code therefore    #
#  is also licensed under the terms of the GNU Public License,        #
#  verison 2.                                                         #
#                                                                     #
#  For information on the license of this code when distributed       #
#  with and used in conjunction with the other modules in the         #
#  Amp project, please see the root-level LICENSE file.               #
#                                                                     #
#  © Michael J. Edgar and Ari Brown, 2009-2010                        #
#                                                                     #
#######################################################################

module Amp
  module Mercurial
    
    ##
    # A Changeset is a simple way of accessing the repository within a certain
    # revision. For example, if the user specifies revision # 36, or revision
    # 3adf21, then we can look those up, and work within the repository at the
    # moment of that revision.
    class Changeset < Amp::Repositories::AbstractChangeset
      include Mercurial::RevlogSupport::Node
      
      attr_reader :repo
      alias_method :repository, :repo
      
      ##
      # Initializes a new changeset. We need a repository to work with, and also
      # a change_id. this change_id could be a revision index or a node_id for
      # the revision.
      #
      # @param [Repository] repo a repository to work with.
      # @param [Integer, String] change_id an ID or index to lookup to find this
      #   changeset.
      #
      def initialize(repo, change_id='')
        change_id = '.' if change_id == ''
        @repo = repo
        if change_id.kind_of? Integer
          @revision = change_id
          @node_id  = @repo.changelog.node_id_for_index change_id
        else
          @node_id = @repo.lookup change_id
          @revision = @repo.changelog.rev @node_id
        end
        @parents = nil
      end
      
      ##
      # Converts the revision to a number
      def to_i; @revision; end
      
      ##
      # Converts the revision to an easy-to-digest string
      def to_s(opts = {})
        if opts[:template]
          to_templated_s(opts)
        else
          @node_id[0..5].hexlify
        end
      end
      
      ##
      #
      def to_templated_s(opts={})
        # Extract local variables for the template. Should prolly do this
        # in a nicer way.... bah
        log = @repo.changelog
        parents = useful_parents log, revision
        changes = log.read node
        
        locals = {:change_node => node,
                  :revision    => self.revision,
                  :username    => changes.user,
                  :date        => Time.at(changes.time.first),
                  :files       => changes.files,
                  :description => changes.description,
                  :extra       => changes.extra,
                  :branch      => changes.extra["branch"],
                  :cs_tags     => tags,
        
                  :added   => opts[:added]   || [],
                  :removed => opts[:removed] || [],
                  :updated => opts[:updated] || [],
                  :config  => opts,
        
                  :p1      => parents.first,
                  :p2      => parents[1],
                  :parents => parents.map {|p| [p, log.node(p).short_hex] }
                }
        
        return "" if opts[:no_output]
        
        type = opts[:template_type] || 'log'
        if opts[:"template-raw"]
          template = Support::RawERbTemplate.new(opts[:"template-raw"])
        elsif opts[:template].is_a?(Support::Template)
          template = opts[:template]
        else
          template = opts[:template]
          template = "default-#{type}" if template.nil? || template.to_s == "default"
          template = Support::Template['mercurial', template]
        end
        template.render(locals, binding)
      end
      
      def useful_parents(log, revision)
        parents = log.parent_indices_for_index revision
        if parents[1] == -1
          if parents[0] >= revision - 1
            parents = []
          else
            parents = [parents[0]]
          end
        end
        parents
      end
      
      ##
      # Commits the given changeset to the repository.
      # 
      #  commit_changeset:
      #    foreach file in commit:
      #        commit_file file
      #    end
      #    add_manifest_entry
      #    add_changelog_entry
      # Is this changeset a working changeset?
      #
      # @param changeset the changeset to commit. Could be working dir, for
      #   example.
      # @param opts the options for committing the changeset.
      # @option opts [Boolean] :force (false) force the commit, even though
      #   nothing has changed.
      # @option opts [Boolean] :force_editor (false) force the user to open
      #   their editor, even though they provided a message already
      # @option opts [Boolean] :empty_ok (false) is it ok if they have no
      #   description of the commit?
      # @option opts [Boolean] :use_dirstate (true) use the DirState for this
      #   commit? Used if you're committing the working directory (typical)
      # @option opts [Boolean] :update_dirstate (true) should we update the
      #   DirState after the commit? Used if you're committing the working
      #   directory.
      # @return [String] the digest referring to this entry in the revlog
      def commit(opts = {:use_dirstate => true, :update_dirstate => true})
        valid = false # don't update the DirState if this is set!
        
        commit = ((modified || []) + (added || [])).sort
        remove = removed
        xtra = extra.dup
        branchname = xtra["branch"]
        text = description
        
        p1, p2 = parents.map {|p| p.node }
        c1 = repo.changelog.read(p1) # 1 parent's changeset as an array
        c2 = repo.changelog.read(p2) # 2nd parent's changeset as an array
        m1 = repo.manifest.read(c1[0]).dup # 1st parent's manifest
        m2 = repo.manifest.read(c2[0])     # 2nd parent's manifest
        
        if opts[:use_dirstate]
          oldname = c1[5]["branch"]
          tests = [ commit.empty?, remove.empty?, !opts[:force],
                    p2 == NULL_ID, branchname == oldname ]
          if tests.all?
            UI::status "nothing changed"
            return nil
          end
        end
        
        xp1 = p1.hexlify
        xp2 = p2 == NULL_ID ? "" : p2.hexlify
        
        Hook.run_hook :pre_commit
        journal = Amp::Mercurial::Journal.new(:opener => repo.store_opener)
  
        fresh    = {} # new = reserved haha i don't know why someone wrote "haha"
        changed  = []
        link_rev = repo.size
        
        (commit + (remove || [])).each {|file| UI::status file }
        
        # foreach file in commit:
        #     commit_file file
        # end
        commit.each do |file|
          versioned_file = self[file]
          fresh[file]    = versioned_file.commit :manifests     => [m1, m2],
                                                 :link_revision => link_rev,
                                                 :journal       => journal ,
                                                 :changed       => changed
          
          new_flags = versioned_file.flags
          
          # TODO
          # Clean this shit up
          if [ changed.empty? || changed.last != file, 
               m2[file] != fresh[file]
             ].all?
            changed << file if m1.flags[file] != new_flags
          end
          m1.flags[file] = new_flags
          
          repo.staging_area.normal file if opts[:use_dirstate]
        end
        
        #    add_manifest_entry
        man_entry, updated, added = *add_manifest_entry(:manifests  => [m1, m2],
                                                        :changesets => [c1, c2],
                                                        :journal    => journal ,
                                                        :link_rev   => link_rev,
                                                        :fresh      => fresh   ,
                                                        :remove     => remove  ,
                                                        :changed    => changed )

        #    get_commit_text
        text = get_commit_text text, :added   => added,   :updated => updated,
                                     :removed => removed, :user    => user   ,
                                     :empty_ok     => opts[:empty_ok]        ,
                                     :use_dirstate => opts[:use_dirstate]
        
        # atomically write to the changelog
        #    add_changelog_entry
        # for the unenlightened, rents = 'rents = parents
        new_rents = add_changelog_entry :manifest_entry => man_entry,
                                        :files   => (changed + removed),
                                        :text    => text,
                                        :journal => journal,
                                        :parents => [p1, p2],
                                        :user    => user,
                                        :date    => date,
                                        :extra   => xtra
      
        
        # Write the dirstate if it needs to be updated
        # basically just bring it up to speed
        if opts[:use_dirstate] || opts[:update_dirstate]
          repo.dirstate.parents = new_rents
          removed.each {|f| repo.dirstate.forget(f) } if opts[:use_dirstate]
          repo.staging_area.save
        end
      
        # The journal and dirstates are awesome. Leave them be.
        valid = true
        journal.close
        
        # if an error and we've gotten this far, then the journal is complete
        # and it deserves to stay (if an error is thrown and journal isn't nil,
        # the rescue will destroy it)
        journal = nil
        
        # Run any hooks
        Hook.run_hook :post_commit, :added => added, :modified => updated, :removed => removed, 
                                    :user  => user,  :date     => date,    :text    => text,
                                    :revision => repo.changelog.index_size
        return new_rents
      rescue StandardError => e
        if !valid
          repo.dirstate.invalidate!
        end
        if e.kind_of?(AbortError)
          UI::warn "Abort: #{e}"
        else
          UI::warn "Got exception while committing. #{e}"
          UI::warn e.backtrace.join("\n")
        end
        
        # the journal is a vestigial and incomplete file.
        # destroyzzzzzzzzzzz
        journal.delete if journal
      end
      
      ##
      # Add an entry to the changelog (the final receipt of the commit).
      # 
      # @param [Hash] opts
      # @return [String] the changelog id as to where the revision is in
      #   the changelog
      def add_changelog_entry(opts={})
        repo.changelog.delay_update 
        new_parents = repo.changelog.add opts[:manifest_entry],
                                         opts[:files],
                                         opts[:text],
                                         opts[:journal],
                                         opts[:parents][0],
                                         opts[:parents][1],
                                         opts[:user],
                                         opts[:date],
                                         opts[:extra]
  
        repo.changelog.write_pending
        repo.changelog.finalize opts[:journal]
        new_parents
      end
      
      ##
      # Get the commit text. Ask for it if none is given.
      # 
      # @param [String, NilClass] text (optional) the commit message
      # @param [Hash] opts
      def get_commit_text(text=nil, opts={})
        user = opts.delete :user
        
        unless opts[:empty_ok] || (text && !text.empty?)
          edit_text = to_templated_s :added   => added,   :updated       => modified,
                                     :removed => removed, :template_type => :commit
          text = UI::edit edit_text, user
        end
        
        lines = text.rstrip.split("\n").map {|r| r.rstrip }.reject {|l| l.empty? }
        raise abort("empty commit message") if lines.empty? && opts[:use_dirstate]
        lines.join("\n")
      end
      
      def add_manifest_entry(opts={})
        # changed, m1, m2, c1, c2, fresh, remove, journal, link_rev
        updated, added = [], []
        
        fresh   = opts[:fresh]
        remove  = opts[:remove]
        changed = opts[:changed]
        
        changesets = opts[:changesets]
        manifests  = opts[:manifests]
        
        changed.sort.each do |file|
          if manifests[0][file] || manifests[1][file]
            updated << file
          else
            added << file
          end
        end
        
        manifests[0].merge! fresh
        
        remove.sort!
        remove.reject! {|f| not manifests[0][f] }
        remove.each {|f| manifests[0].delete f }
        
        UI::debug "before adding manifest entry"
        
        # sorry for making this destructive
        # but it's clean and memory efficient
        # GHC's GC goes like 3 times per second, so STFU
        # I don't have that kind of luxury
        fresh.replace fresh.inject([]) {|a, (k, v)| v ? a << k : a }
        man_entry = repo.manifest.add manifests[0], opts[:journal],
                                      opts[:link_rev], changesets[0][0], changesets[1][0], [fresh, remove]
        [man_entry, updated, added]
      end
      
      ##
      # @return [Boolean] is the changeset representing the working directory?
      def working?
        false
      end
      
      ##
      # Gives an easier way to digest this changeset while reminding us it's a
      # changeset
      def inspect
        "#<Changeset #{to_s}>"
      end
      
      ##
      # Hash function for putting these bad boys in hashes
      # 
      # @return [Integer] a hash value.
      def hash
        return @revision.hash if @revision
        return object_id
      end
      
      ##
      # Compares 2 changesets so we can sort them and whatnot
      # 
      # @param [Changeset] other a changeset we will compare against
      # @return [Integer] -1, 0, or 1. Typical comparison.
      def <=>(other)
        return 0 if @revision.nil? || other.revision.nil?
        @revision <=> other.revision
      end
      
      ##
      # Are we a null revision?
      # @return [Boolean] null?
      def nil?
       @revision != NULL_REV
      end
      alias_method :null?, :nil?
      
      # Gets the raw changeset data for this revision. This includes
      # the user who committed it, the description of the commit, and so on.
      # Returns this: [manifest, user, [time, timezone], files, desc, extra]
      def raw_changeset
        @repo.changelog.read(@node_id)
      end
      
      ##
      # Returns the {ManifestEntry} for this revision. This will give
      # us info on any file we want, including flags such as executable
      # or if it's a link. Sizes and so on are also included.
      #
      # @return [ManifestEntry] the manifest at this point in time
      def manifest_entry
        @manifest_entry ||= @repo.manifest.read(raw_changeset.manifest_node)
      end
      
      ##
      # Provides access to all the tracked files in the changeset. Needed
      # for API compatibility.
      #
      # @return [Array<String>] all the files tracked in this changeset.
      def all_files
        return manifest_entry.files
      end
      
      ##
      # Returns the change in the manifest at this revision. I don't entirely
      # know what this is yet.
      def manifest_delta
        @manifest_entry_delta ||= @repo.manifest.read_delta(raw_changeset.manifest_node)
      end
      
      ##
      # Returns the parents of this changeset as {Changeset}s.
      #
      # @return [[Changeset]] the parents of this changeset.
      def parents
        return @parents if @parents
        
        p = @repo.changelog.parent_indices_for_index @revision
        p = [p[0]] if p[1] == NULL_REV
        
        @parents = p.map {|x| Changeset.new(@repo, x) }
      end
      
      ##
      # Returns the children of this changeset as {Changeset}s.
      #
      # @return [Array<Changeset>] the children of this changeset.
      def children
        @repo.changelog.children(node_id).map do |node|
          Changeset.new(@repo, node)
        end
      end
      
      ##
      # Iterates over each entry in the manifest entry.
      # 
      # @return [Changeset] self, because that's how #each works
      def each(&block)
        manifest_entry.sort.each(&block)
        self
      end
      
      ##
      # Checks whether this changeset included a given file or not.
      # 
      # @param [String] file the file to lookup
      # @return [Boolean] whether the file is in this changeset's manifest
      def include?(file)
        manifest_entry[file] != nil
      end 
      
      ##
      # Gets the file with the given name, as a {VersionedFile}.
      # @param file the path to the file to retrieve
      # @return [VersionedFile] the file at this revision
      #
      def [](file)
        get_file(file)
      end
      
      ##
      # Returns the file's info, namely it's node_id and flags it may
      # have at this point in time, such as "x" for executable.
      # 
      # @param path the path to the file
      # @return [[String, String]] the [node_id, flags] pair for this file
      def file_info(path)
        if manifest_entry # have we loaded our manifest yet? if so, use that sucker
          result = [manifest_entry[path], manifest_entry.flags[path]]
          if result[0].nil?
            return [NULL_ID, '']
          else
            return result
          end
        end
        if manifest_delta || files[path] # check if it's in the delta... i dunno
          if manifest_delta[path]
            return [manifest_delta[path], manifest_delta.flags[path]]
          end
        end
        # Give us, just look it up the long way in the manifest. not fun. slow.
        node, flag = @repo.manifest.find(raw_changeset[0], path)
        if node.nil?
          return [NULL_ID, '']
        end
        return [node, flag]
      end
      
      ##
      # Gets the flags for the file at the given path at this revision.
      # @param path the path to the file in question
      # @return [String] the flags for the file, such as "x", "l", or "".
      #
      def flags(path)
        info = file_info(path)[1]
        return "" if info.nil?
        info
      end
      
      ##
      # Gets the node_id in the manifest_entry for the file at this path, for this
      # specific revision.
      # 
      # @param path the path to the file
      # @return [String] the node's ID in the manifest_entry, which we'll use every
      #   where we need a node_id.
      def file_node(path)
        file_info(path).first[0..19]
      end
      
      ##
      # Creates a versioned file for the file at the given path, for the frame
      # of reference of this revision.
      # @param path the path to the file
      # @param [String] file_id the node_id, to save us some computation
      # @param [FileLog] file_log the file_log to use, again to save us computation
      # @return [VersionedFile] the file at this revision.
      #
      def get_file(path, file_id = nil, file_log = nil)
        file_id = file_node(path) if file_id.nil?
        VersionedFile.new(@repo, path, :file_id => file_id, :changeset => self,
                                       :file_log => file_log)
      end
      #accessors
      # revision index
      def revision; @revision; end
      alias_method :rev, :revision
      # node_id
      def node_id; @node_id; end
      # @see node_id
      alias_method :node, :node_id
      # our node_id in sexy hexy
      def hex; @node_id.hexlify; end
      # the user who committed me!
      def user; raw_changeset.user; end
      # the date i was committed!
      def date; raw_changeset.time; end
      def easy_date; Time.at(raw_changeset.time.first); end
      # the files affected in this commit!
      def altered_files; raw_changeset.files; end
      # pre-API compatibility
      alias_method :files, :altered_files
      
      # the message with this commit
      def description; raw_changeset.description; end
      # What branch i was committed onto
      def branch 
        extra["branch"] 
      end
      # Any extra stuff I've got in me
      def extra; raw_changeset.extra; end
      # tags
      def tags; @repo.tags_for_node node; end
      
      def ancestor(other_changeset)
        node = @repo.changelog.ancestor(self.node, other_changeset.node)
        return Changeset.new(@repo, node)
      end
      
      def ancestors
        results = []
        @repo.changelog.ancestors(revision)
      end
    end
    
    ##
    # This is a special changeset that specifically works within the
    # working directory. We sort of have to combine the old revision
    # logs with the fact that files might be changed, and not in the
    # revision logs! oh, mercy!
    class WorkingDirectoryChangeset < Changeset
      
      def initialize(repo, opts={:text => ""})
        @repo = repo
        @revision = nil
        @parents = nil
        @node_id  = nil
        @text = opts[:text]
        require 'time' if opts[:date].kind_of?(String)
        @date = opts[:date].kind_of?(String) ? Time.parse(opts[:date]) : opts[:date]
        @user = opts[:user] if opts[:user]
        @parents = opts[:parents].map {|p| Changeset.new(@repo, p)} if opts[:parents]
        @status = opts[:changes] if opts[:changes]
        @manifest = nil
        @extra = opts[:extra] ? opts[:extra].dup : {}
        unless @extra["branch"]
          branch = @repo.dirstate.branch
          # encoding - to UTF-8
          @extra["branch"] = branch
        end
        @extra["branch"] = "default" if @extra["branch"] && @extra["branch"].empty?
        
      end
      
      ##
      # Is this changeset a working changeset?
      #
      # @return [Boolean] is the changeset representing the working directory?
      def working?
        true
      end
      
      ##
      # Converts to a string.
      # I'm my first parent, plus a little extra.
      # "I am my own grandpa"
      # 
      # @return [String]
      def to_s
        if (altered_files + status[:deleted]).any?
        then parents.first.to_s + "+"
        else parents.first.to_s
        end
      end
      
      
      ##
      # Do I include a given file? (not sure this is ever used yet)
      def include?(key)
        status = @repo.staging_area.file_status(key)
        ![:unknown, :removed].include?(status)
      end
      
      def all_files
        repo.staging_area.all_files
      end
      
      ##
      # Am I nil? never!
      def nil?; false; end
      
      ##
      # What is the status of the working directory? This little
      # method hides quite a bit of work!
      def status
        @status ||= @repo.status(:unknown => true)
      end
      
      ##
      # Who is the user working on me?
      def user 
        @user ||= @repo.config.username
      end
      
      ##
      # Well, I guess the working directory's date is... right now!
      def date
        @date ||= Time.new
      end
      
      ##
      # Who is the working directory's father? Is it Chef? Mr. Garrison?
      # the 1989 denver broncos?
      #
      # hahaha mike that's hilarious
      def parents
        @parents ||= begin
                       p = @repo.dirstate.parents
                       p = [p[0]] if p[1] == NULL_ID
                       p.map {|x| Changeset.new(@repo, x) }
                     end
      end
      
      ##
      # OK, so we've got the last revision's manifest entry, that part's simple and makes sense.
      # except now, we need to get the status of the working directory, and
      # add in all the other files, because they're in the "manifest entry" by being
      # in existence. Oh, and we need to remove any files from the parent's
      # manifest entry that don't exist anymore. Make sense?
      def manifest_entry
        return @manifest_entry if @manifest_entry
        
        # Start off with the last revision's manifest_entry, that's safe.
        man = parents()[0].manifest_entry.dup
        # Any copied files since the last revision?
        copied = @repo.dirstate.copy_map
        # Any modified, added, etc files since the last revision?
        modified, added, removed  = status[:modified], status[:added], status[:removed]
        deleted, unknown          = status[:deleted], status[:unknown]
        # Merge these discoveries in!
        {:a => added, :m => modified, :u => unknown}.each do |k, list|
          list.each do |file|
            copy_name = (copied[file] || file)
            man[file] = (man.flags[copy_name] || NULL_ID) + k.to_s
            man.flags[file] = @repo.dirstate.flags(file)
          end
        end
        
        # Delete files from the real manifest entry that don't exist.
        (deleted + removed).each do |file|
          man.delete file if man[file]
        end
        
        man
      end
      
      ##
      # Returns a {VersionedWorkingFile} to represent the file at the given
      # point in time. It represents a file in the working directory, which
      # obvious don't read from the history, but from the actual file in
      # question.
      # 
      # @param path the path to the file
      # @param file_log the log for the file to save some computation
      # @return [Amp::VersionedWorkingFile] the file object we can work with
      def get_file(path, file_log=nil)
        VersionedWorkingFile.new(@repo, path, :working_changeset => self, 
                                              :file_log => file_log)
      end
      
      ##
      # Gets the flags for the file at current state in time
      # 
      # @param [String] path the path to the file
      # @return [String] the flags, such as "x", "l", or ""
      def flags(path)
        if @manifest_entry ||= nil
          return manifest_entry.flags[path] || ""
        end
        pnode = parents[0].raw_changeset[0]
    
        orig = @repo.dirstate.copy_map[path] || path
        node, flag = @repo.manifest.find(pnode, orig)
        return @repo.dirstate.flags(@repo.working_join(path))
      end
      
      def useful_parents(log, revision)
        parents = @parents.map {|p| p.revision}
        if parents[1] == -1
          if parents[0] >= @repo.size - 1
            parents = []
          else
            parents = [parents[0]]
          end
        end
        parents
      end
      
      ##
      # Recursively walk the directory tree, getting all files that +match+ says
      # are good.
      # 
      # @param [Amp::Match] match how to select the files in the tree
      # @param [Boolean] check_ignored (false) should we check for ignored files?
      # @return [Array<String>] an array of filenames in the tree that match +match+
      def walk(match, check_ignored = false)
        tree = @repo.staging_area.walk true, check_ignored, match
        tree.keys.sort
      end
      
      # If there's a description, ok then
      def description; @text; end
      # Files affected in this transaction: modified, added, removed.
      def altered_files; (status[:modified] + status[:added] + status[:removed]).sort; end
      # What files have changed?
      def modified; status[:modified]; end
      # What files have we added?
      def added; status[:added]; end
      # What files have been removed?
      def removed; status[:removed]; end
      # What files have been deleted (but not officially)?
      def deleted; status[:deleted]; end
      # What files are hanging out, but untracked?
      def unknown; status[:unknown]; end
      # What files are pristine since the last revision?
      def clean; status[:normal]; end
      # What branch are we in?
      def branch; @extra["branch"]; end
      # Any other extra data? i'd like to hear it
      def extra; @extra; end
      # No children. Returns the empty array.
      def children; []; end
      # The date, nicely handled
      def easy_date; date; end
      # The tags
      def tags; self.parents.first.tags; end
    end
  end
end