#######################################################################
#                  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 Repositories
    module Mercurial
      
      ##
      # This module contains all the code that makes a repository able to
      # update its working directory.
      module Updatable
        include Amp::Mercurial::RevlogSupport::Node
        
        ##
        # Updates the repository to the given node. One of the major operations on a repository.
        # This will update the working directory to the given node.
        #
        # @todo add lock
        # @param [String, Integer] node the revision to which we are updating the repository. Can
        #   be either nil, a node ID, or an integer. If it is nil, it will update
        #   to the latest revision.
        # @param [Boolean] branch_merge whether to merge between branches
        # @param [Boolean] force whether to force branch merging or file overwriting
        # @param [Proc, #call] partial a function to filter file lists (dirstate not updated if this
        #   is passed)
        # @return [Array<Integer>] a set of statistics about the update. In the form:
        #   [updated, merged, removed, unresolved] where each entry is the # of files in that category.
        def update(node=nil, branch_merge=false, force=false, partial=nil)
          # debugger
          self.status(:node_1 => self.dirstate.parents.first, :node_2 => nil)
          working_changeset = self[nil]
          # tip of current branch
          node ||= branch_tags[working_changeset.branch]
          node = self.lookup("tip") if node.nil? && working_changeset.branch == "default"
          if node.nil?
            raise abort("branch #{working_changeset.branch} not found")
          end
          
          overwrite = force && !branch_merge
          parent_list = working_changeset.parents
          parent1, parent2 = parent_list.first, self[node]
          parent_ancestor = parent1.ancestor(parent2)
          
          fp1, fp2, xp1, xp2 = parent1.node, parent2.node, parent1.to_s, parent2.to_s
          fast_forward = false
          
          ## In this section, we make sure that we can actually do an update.
          ## No use starting an udpate if we can't finish!
          
          if !overwrite && parent_list.size > 1
            raise abort("outstanding uncommitted merges")
          end
          
          if branch_merge
            if parent_ancestor == parent2
              raise abort("can't merge with ancestor")
            elsif parent_ancestor == parent1
              if parent1.branch != parent2.branch
                fast_forward = true
              else
                raise abort("nothing to merge (use 'hg update' or check"+
                                     " 'hg heads')")
              end
            end
            if !force && (working_changeset.changed_files.any? || working_changeset.deleted.any?)
              raise abort("oustanding uncommitted changes")
            end
          elsif !overwrite
            if parent_ancestor == parent1 || parent_ancestor == parent2
              # do nothing
            elsif parent1.branch == parent2.branch
              if working_changeset.changed_files.any? || working_changeset.deleted.any?
                raise abort("crosses branches (use 'hg merge' or "+
                                     "'hg update -C' to discard changes)")
              end
              raise abort("crosses branches (use 'hg merge' or 'hg update -C')")
            elsif working_changeset.changed_files.any? || working_changeset.deleted.any?
              raise abort("crosses named branches (use 'hg update -C'"+
                                   " to discard changes)")
            else
              overwrite = true
            end
          end
          
          ## Alright, now let's figure out exactly what we have to do to make this update.
          ## Shall we?
          
          actions = []
          check_unknown(working_changeset, parent2) if force
          check_collision(parent2) if false # case-sensitive file-system? seriously?
          
          actions += forget_removed(working_changeset, parent2, branch_merge)
          actions += manifest_merge(working_changeset, parent2, parent_ancestor, 
                                    overwrite, partial)
          #p [working_changeset, parent2, parent_ancestor] # KILLME
          ## Apply phase - apply the changes we just generated.
          unless branch_merge # just jump to the new revision
            fp1, fp2, xp1, xp2 = fp2, NULL_ID, xp2, ''
          end
          unless partial
            run_hook :preupdate, :throw => true, :parent1 => xp1, :parent2 => xp2
          end
      
          stats = apply_updates(actions, working_changeset, parent2)
          
          unless partial
            record_updates(actions, branch_merge)
            dirstate.parents = [fp1, fp2]
            if !branch_merge && !fast_forward
              dirstate.branch = parent2.branch
            end
            run_hook :update, :parent1 => xp1, :parent2 => xp2, :error => stats[3]
            dirstate.write
          end
          
          return stats
        end
        
        ##
        # Merge two heads
        def merge(node, force=false)
          update node, true, force, false
        end
        
        ##
        # Updates the repository to the given node, clobbering (removing) changes
        # along the way. This has the effect of turning the working directory into
        # a pristine copy of the requested changeset. Really just a nice way of
        # skipping some arguments for the caller.
        #
        # @param [String] node the requested node
        def clean(node)
          update node, false, true, nil
        end
        
        private
        
        ##
        # This method will make sure that there are no differences between
        # untracked files in the working directory, and tracked files in
        # the new changeset. 
        #
        # @param [WorkingDirectoryChangeset] working_changeset the current working directory
        # @param [Changeset] target_changeset the destination changeset (that we're updating to)
        # @raise [AbortError] if an untracked file in the working directory is different from
        #   a tracked file in the target changeset, this abort error will be raised.
        def check_unknown(working_changeset, target_changeset)
          working_changeset.unknown.each do |file|
            if target_changeset.all_files.include?(file) && target_changeset[file].cmp(working_changeset[file].data())
              raise abort("Untracked file in the working directory differs from "+
                                   "a tracked file in the requested revision: #{file} #{target_changeset[file]}")
            end
          end
        end
        
        ##
        # This method will check if the target changeset will cause name collisions
        # when filenames are changed to all lower-case. This is important because
        # in the store, the file-logs are all changed to lower-case.
        #
        # @param [Changeset] target_changeset the destination changeset (that we're updating to)
        # @raise [AbortError] If two files have the same lower-case name, in 1 changeset,
        #   this error will be thrown.
        def check_collision(target_changeset)
          folded_names = {}
          target_changeset.each do |file|
            folded = file.downcase
            if folded_names[folded]
              raise abort("Case-folding name collision between #{folded_names[folded]} and #{file}.")
            end
            folded_names[folded] = file
          end
        end
        
        ##
        # Forget removed files (docs ripped from mercurial)
        # 
        # If we're jumping between revisions (as opposed to merging), and if
        # neither the working directory nor the target rev has the file,
        # then we need to remove it from the dirstate, to prevent the
        # dirstate from listing the file when it is no longer in the
        # manifest.
        # 
        # If we're merging, and the other revision has removed a file
        # that is not present in the working directory, we need to mark it
        # as removed.
        #
        # @param [WorkingDirectoryChangeset] working_changeset the current working directory
        # @param [Changeset] target_changeset the destination changeset (that we're updating to)
        # @param [Boolean] branch_merge whether or not to delete working files
        # @return [[String, Symbol]] a list of actions that should be taken to complete
        #   a successful transition from working_changeset to target_changeset.
        def forget_removed(working_changeset, target_changeset, branch_merge)
          action_list = []
          action = branch_merge ? :remove : :forget
          working_changeset.deleted.each do |file|
            action_list << [file, action] unless target_changeset[file]
          end
          
          unless branch_merge
            working_changeset.removed.each do |file|
              action_list << [file, :forget] unless target_changeset[file]
            end
          end
          
          action_list
        end
        
        ##
        # Merge the local working changeset (local), and the target changeset (remote),
        # using the common ancestor (ancestor). Generates a merge action list to update
        # the manifest.
        #
        # @param [Changeset] local The working-directory changeset we're merging from
        # @param [Changeset] remote The target changeset we need to merge to
        # @param [Changeset] ancestor A common ancestor between the 2 parents
        # @param [Boolean] overwrite Can we delete working files?
        # @param [Proc] partial a function to filter file lists
        # @return [[String, Symbol]] A list of actions that should be taken to complete
        #   a successful transition from local to remote.
        def manifest_merge(local, remote, ancestor, overwrite, partial)

          UI::status("resolving manifests")
          UI::debug(" overwrite #{overwrite} partial #{partial}")
          UI::debug(" ancestor #{ancestor} local #{local} remote #{remote}")
          
          local_manifest = local.manifest_entry
          remote_manifest = remote.manifest_entry
          ancestor_manifest = ancestor.manifest_entry
          backwards = (ancestor == remote)
          action_list = []
          copy, copied, diverge = {}, {}, {}
          
          flag_merge = lambda do |file_local, file_remote, file_ancestor|
            file_remote = file_ancestor = file_local unless file_remote
            
            a = ancestor_manifest.flags[file_ancestor]
            m = local_manifest.flags[file_local]
            n = remote_manifest.flags[file_remote]
            
            return m if m == n # flags are identical, we're fine
            
            if m.any? && n.any?
              unless a.any? # i'm so confused, ask the user what the flag should be!
                r = UI.ask("conflicting flags for #{file_local} (n)one, e(x)ec, or "+
                              "sym(l)ink?")
                return (r != "n") ? r : ''
              end
              return m == a ? n : m
            end
            
            return m if m && m.any? && m != a # changed from a to m
            return n if n && n.any? && n != a # changed from a to n
            return '' #no more flag
          end
          
          act = lambda do |message, move, file, *args|
            UI::debug(" #{file}: #{message} -> #{move}")
            action_list << [file, move] + args
          end
          
          if ancestor && !(backwards || overwrite)
            if @config["merge", "followcopies", Boolean, true]
              dirs = @config["merge", "followdirs", Boolean, false] # don't track directory renames
              copy, diverge = Amp::Graphs::Mercurial::CopyCalculator.find_copies(self, local, remote, ancestor, dirs)
            end
            copied = Hash.with_keys(copy.values)
            diverge.each do |of, fl|
              act["divergent renames", :divergent_rename, of, fl]
            end
          end
          
          # Compare manifests
          local_manifest.each do |file, node|
            next if partial && !partial[file]
            
            if remote_manifest[file]
              rflags = (overwrite || backwards) ? remote_manifest.flags[file] : flag_merge[file,nil,nil]
              # Are files different?
              if node != remote_manifest[file]
                anc_node = ancestor_manifest[file] || NULL_ID
                
                if overwrite # Can we kill the file?
                  act["clobbering", :get, file, rflags]
                elsif backwards # Or are we going back in time and cleaning?
                  if !(node[20..-1]) || !(remote[file].cmp(local[file].data))
                    act["reverting", :get, file, rflags]
                  end
                elsif node != anc_node && remote_manifest[file] != anc_node
                  # are both nodes different from the ancestor?
                  act["versions differ", :merge, file, file, file, rflags, false]
                elsif remote_manifest[file] != anc_node
                  # is remote's version newer?
                  act["remote is newer", :get, file, rflags]
                elsif local_manifest.flags[file] != rflags
                  # local is newer, not overwrite, check mode bits (wtf does this mean)
                  act["update permissions", :exec, file, rflags]
                end
              elsif local_manifest.flags[file] != rflags
                act["update permissions", :exec, file, rflags]
              end
            elsif copied[file]
              next
            elsif copy[file]
              file2 = copy[file]
              if !remote_manifest[file2] #directory rename
                act["remote renamed directory to #{file2}", :d, file, nil, file2, local_manifest.flags[file]]
              else # case 2 A,B/B/B, # case 4,21 A/B/B
                act["local copied to #{file2}", :merge, file, file2, file, # or local moved to
                    flag_merge[file, file2, file2], false]
              end
            elsif ancestor_manifest[file]
              if node != ancestor_manifest[file] && !overwrite
                if UI.ask("local changed #{file} which remote deleted\n" +
                          "use (c)hanged version or (d)elete?") == "d"
                  act["prompt delete", :remove, file]
                else
                  act["prompt keep", :add, file]
                end
              else
                act["other deleted", :remove, file]
              end
            else
              if (overwrite && node[20..-1] != "u") || (backwards && node[20..-1].empty?)
                act["remote deleted", :remove, file]
              end
            end   
          end
          
          remote_manifest.each do |file, node|
            next if partial && !(partial[file])
            next if local_manifest[file]
            next if copied[file]
            if copy[file]
              file2 = copy[file]
              if !(local_manifest[file2])
                act["local renamed directory to #{file2}", :directory, nil, file,
                    file2, remote_manifest.flags[file]]
              elsif remote_manifest[file2]
                act["remote copied to #{file}", :merge, file2, file, file,
                    flag_merge[file2, file, file2], false]
              else
                act["remote moved to #{file}", :merge, file2, file, file,
                    flag_merge[file2, file, file2], true]
              end
            elsif ancestor_manifest[file]
              if overwrite || backwards
                act["recreating", :get, file, remote_manifest.flags[file]]
              elsif node != ancestor_manifest[file]
                if UI.ask("remote changed #{file} which local deleted\n" +
                          "use (c)hanged version or leave (d)eleted?") == "c"
                  act["prompt recreating", :get, file, remote_manifest.flags[file]]
                end
              end
            else
              act["remote created", :get, file, remote_manifest.flags[file]]
            end
          end
          
          action_list
        end
        
        ##
        # Compare two actions in the update action list
        #
        def action_cmp(action1, action2)
          move1 = action1[1] # action out of the tuple
          move2 = action2[1] # action out of the tuple
          
          return action1 <=> action2 if move1 == move2
          return -1 if move1 == :remove
          return 1  if move2 == :remove
          return action1 <=> action2
        end
        
        ##
        # Apply the merge action list to the working directory, in order to migrate from
        # working_changeset to target_changeset.
        #
        # @todo add path auditor
        # @param [Array<Array>] actions list of actions to take to migrate from {working_changeset} to
        #   {target_changeset}.
        # @param [WorkingDirectoryChangeset] working_changeset the current changeset in the repository
        # @param [Changeset] target_changeset the changeset we are updating the working directory to.
        # @return [Hash] Statistics about the update. Keys are:
        #   :updated => files that were changed
        #   :merged  => files that were merged
        #   :removed => files that were removed
        #   :unresolved => files that had conflicts when merging that we couldn't fix
        def apply_updates(actions, working_changeset, target_changeset)
          updated, merged, removed, unresolved = [], [], [], []
          merge_state.reset(working_changeset.parents.first.node)
          
          moves = []
          actions.sort! {|a1, a2| action_cmp a1, a2 }
          
          moves = scan_for_merges actions, working_changeset, target_changeset
          
          # If we're moving any files, we can remove renamed ones now
          moves.each do |file|
            if File.amp_lexist?(working_join(file))
              UI.debug("removing #{file}")
              File.unlink(working_join(file))
            end
          end
          
          # TODO: add path auditor
          
          actions.each do |action|
            file, choice = action[0], action[1]
            next if file && file[0,1] == "/"
            case choice
            when :remove
              UI.note "removing #{file}"
              File.unlink(working_join(file))
              removed << file
            when :merge
              file2, file_dest, flags, move = action[2..-1]
              result = merge_state.resolve(file_dest, working_changeset, target_changeset)
              
              unresolved << file if result
              updated    << file if result.nil?
              merged     << file if result == false
              
              FileHelpers.set_executable(working_join(file_dest), flags && flags.include?('x'))
              if (file != file_dest && move && File.amp_lexist?(working_join(file)))
                UI.debug("removing #{file}")
                File.unlink(working_join(file))
              end
            when :get
              flags = action[2]
              UI.note("getting #{file}")
              data = target_changeset.get_file(file).data
              working_write(file, data, flags)
              updated << file
            when :directory
              file2, file_dest, flags = action[2..-1]
              if file && file.any?
                UI.note("moving #{file} to #{file_dest}")
                File.move(file, file_dest)
              end
              if file2 && file2.any?
                UI.note("getting #{file2} to #{file_dest}")
                data = target_changeset.get_file(file2).data
                working_write(file_dest, data, flags)
              end
              updated << file
            when :divergent_rename
              filelist = action[2]
              UI.warn("detected divergent renames of #{f} to:")
              filelist.each {|fn| UI.warn fn }
            when :exec
              flags = action[2]
              FileHelpers.set_executable(working_join(file), flags.include?('x'))
            end
            
          end
          
          hash = {:updated    => updated    ,
                  :merged     => merged     ,
                  :removed    => removed    ,
                  :unresolved => unresolved }
                  
          class << hash
            def success?; self[:unresolved].empty?; end
          end
          
          hash
        end
        
        ##
        # Add merges in the action list to the merge state.
        #
        # @param [Array<Object>] actions 
        def scan_for_merges(actions, working_changeset, target_changeset)
          moves = []
          # prescan for merges in the list of actions.
          actions.select {|act| act[1] == :merge}.each do |a|
            # destructure the list
            file, action, file2, filename_dest, flags, move = a
            UI.debug("preserving #{file} for resolve of #{filename_dest}")
            # look up our changeset for the merge state entry
            vf_local = working_changeset[file] 
            vf_other = target_changeset[file2]
            vf_base  = vf_local.ancestor(vf_other) || versioned_file(file, :file_id => NULL_REV)
            # track this merge!
            merge_state.add(vf_local, vf_other, vf_base, filename_dest, flags) 
            
            moves << file if file != filename_dest && move
          end
          moves
        end
        
        ##
        # Records all the updates being made while merging to the new working directory.
        # It records them by writing to the dirstate.
        #
        # @param [Array<Array>] actions a list of actions to take
        # @param [Boolean] branch_merge is this a branch merge?
        def record_updates(actions, branch_merge)
          actions.each do |action|
            file, choice = action[0], action[1]
            case choice
            when :remove
              branch_merge and dirstate.remove(file) or dirstate.forget(file)
            when :add
              dirstate.add file unless branch_merge
            when :forget
              dirstate.forget file
            when :get
              branch_merge and dirstate.dirty(file) or dirstate.normal(file)
            when :merge
              file2, file_dest, flag, move = action[2..-1]
              if branch_merge
                dirstate.merge(file_dest)
                if file != file2 #copy/rename
                  dirstate.remove file if move
                  dirstate.copy(file,  file_dest) if file != file_dest
                  dirstate.copy(file2, file_dest) if file == file_dest
                end
              else
                dirstate.maybe_dirty(file_dest)
                dirstate.forget(file) if move
              end
            when :directory
              file2, file_dest, flag = action[2..-1]
              next if !file2 && !(dirstate.include?(file))
              
              if branch_merge
                dirstate.add file_dest
                if file && file.any?
                  dirstate.remove file
                  dirstate.copy file, file_dest
                end
                dirstate.copy file2, file_dest if file2 && file2.any?
              else
                dirstate.normal file_dest
                dirstate.forget file if file && file.any?
              end
            end
          end
          staging_area.save
        end
      end
    end
  end
end