module Amp
  module Mercurial
    
    ##
    # This class allows you to access a file at a given revision in the repo's
    # history. You can compare them, sort them, access the changeset, and
    # all sorts of stuff.
    class VersionedFile < Amp::Repositories::AbstractVersionedFile
      include Mercurial::RevlogSupport::Node
      
      attr_accessor :file_id
      attr_accessor :change_id
      attr_accessor :path
      attr_accessor :repo
      
      ##
      # Creates a new {VersionedFile}. You need to pass in the repo and the path
      # to the file, as well as one of the following: a revision index/ID, the
      # node_id of the file's revision in the filelog, or a changeset at a given
      # index.
      # 
      # Oh, and just FYI because it might interest you, there are three ways to
      # specify the revision of a VFile: change_id (revision in changelog),
      # file_id (revision in file_log), and an actual changeset. Instead of
      # sticking with one way, favoring one way, or converting everything
      # to a single form, they do everything
      # three different ways. It's not particularly difficult to deal with, per se,
      # but it's just fucking stupid. like seriously wtf. with that said, mad props
      # to the mercurial team because they put out a rock solid piece of code that
      # has inspired me.
      # 
      # @param [Repository] repo The repo we're working with
      # @param [String] path the path to the file
      # @param [Hash] opts the options to customize how we load this file
      # @option [FileLog] opts :file_log (nil) The FileLog to use for loading data
      # @option [String] opts :change_id (nil) The revision ID/index to use to
      #   figure out which revision we're working with
      # @option [Changeset] opts :changeset (nil) the changeset to use to figure
      #   which revision we're working with
      # @option [String] opts :file_id (nil) perhaps the ID of the revision in
      #   the file_log to use?
      def initialize(repo, path, opts={})
        @repo, @path = repo, path
        raise StandardError.new("specify a revision!") unless opts[:change_id] ||
                                                              opts[:file_id]   ||
                                                              opts[:changeset]
        @file_log  = opts[:file_log]  || nil
        @change_id = opts[:change_id] || nil
        @changeset = opts[:changeset] || nil
        @file_id   = opts[:file_id]   || nil
      end
      
      ##
      # Commit this {VersionedFile} as part of a larger transaction. This will
      # not commit anything if the file hasn't actually been changed.
      # 
      #  commit_file:
      #    if file_has_been_copied:
      #      get_new_file_pointers # for the old file_log, since it needs to be transfered
      #    elsif file_has_been_merged:
      #      deal_with_merges
      #    end
      #    
      #    add_file_log_entry
      # 
      # @param [Hash] opts
      # @option opts [Array<Amp::Mercurial::ManifestEntry>] manifests the manifests of
      #   the parents
      # @option opts [String] link_revision the revision index we'll be adding
      #   this under
      # @option opts [Amp::Mercurial::Journal] journal the journal for aborting
      #   failed commits
      # @option opts [Array<String>] changed the running tally of changed files
      # @return [String] the file_id (where this revision is in its file log)
      def commit(opts={})
        f_log     = repo.file_log @path # :: FileLog
        copied    = renamed? # [ path,  ]
        meta_data = {}
        
        fp1 = opts[:manifests][0][path] || NULL_ID # :: String (file_id)
        fp2 = opts[:manifests][1][path] || NULL_ID # :: String (file_id)
        
        # DEALIN' WITH MERGES AN' COPIES!
        if copied && copied[0] != @path
          # COPIES!!!
          # 
          # we need new pointers because when we deal with merges, we need
          # to know if we have more work to do. If fp1 is NULL_ID, then it means
          # we need to look up the copy data so we can get the old file log data.
          fp1, fp2, meta_data = *determine_new_file_pointers(fp1, fp2, copied[0], opts)
        elsif fp2 != NULL_ID
          # MERGES
          fpa = f_log.ancestor fp1, fp2
          
          fp1, fp2 = fp2, NULL_ID if fpa == fp1
          fp2 = NULL_ID           if fpa != fp2 && fpa == fp2
        end
        
        # Else, if there is no second parent and the file hasn't been copied
        # and nothing has changed in the file (that's the last little
        # comparison), then we just return fp1 because there's nothing to write.
        if fp2 == NULL_ID && meta_data.empty? && !(f_log.cmp(fp1, data))
          return fp1
        end
        
        # Add it to the list of CHANGED files. If we've made it this far,
        # we have a file that has changed.
        opts[:changed] << @path
        
        # Add the motherfucking file log entry. Have a nice day.
        f_log.add data, meta_data, opts[:journal], opts[:link_revision], fp1, fp2
      end
      
      ##
      # Determine the new file indices of the {FileLog} for this
      # {VersionedFile}. This deals with merges and copies and anything else
      # that might stymie the standard process.
      # 
      # @param [String] fp1 the file index of the first parent
      # @param [String] fp2 the file index of the second parent
      # @param [Hash<Symbol => Object>] opts
      # @option opts [Array<ManifestEntry>] :manifests the manifests of the
      #   first and second parents, respectively.
      # @return [(String, String, Hash<String => String>)] the first file index,
      #   the second file index, and any meta data
      def determine_new_file_pointers(fp1, fp2, copied_file, opts={})
        # Mark the new revision of this file as a copy of another
        # file.  This copy data will effectively act as a parent
        # of this new revision.  If this is a merge, the first
        # parent will be the nullid (meaning "look up the copy data")
        # and the second one will be the other parent.  For example:
        #
        # 0 --- 1 --- 3   rev1 changes file foo
        #   \       /     rev2 renames foo to bar and changes it
        #    \- 2 -/      rev3 should have bar with all changes and
        #                      should record that bar descends from
        #                      bar in rev2 and foo in rev1
        #
        # this allows this merge to succeed:
        #
        # 0 --- 1 --- 3   rev4 reverts the content change from rev2
        #   \       /     merging rev3 and rev4 should use bar@rev2
        #    \- 2 --- 4        as the merge base
        
        copied_revision = opts[:manifests][0][copied_file]
        new_fp          = fp2
        
        if opts[:manifests][1] # branch merge
          # copied on remote side
          if fp2 == NULL_ID || copied_revision == nil
            if opts[:manifests][1][copied_file]
              copied_revision = opts[:manifests][1][copied_file]
              new_fp = fp1
            end
          end
        end

        if copied_revision.nil? || copied_revision.empty?
          ancestor        = repo["."].ancestors.detect {|ancestor| ancestor[copied_file] }
          copied_revision = ancestor[copied_file].file_node
        end
        
        UI::say "#{@path}: copy #{copied_file}:#{copied_revision.hexlify}"
        
        meta_data            = {}
        meta_data["copy"]    = copied_file
        meta_data["copyrev"] = copied_revision.hexlify
        fp1, fp2 = NULL_ID, new_fp
        
        [fp1, fp2, meta_data]
      end
      
      ##
      # Is it more recent than +other+?
      def <=>(other)
        change_id <=> other.change_id
      end
      
      ##
      # Returns the changeset that this file belongs to
      # 
      # @return [Changeset] the changeset this file belongs to
      def changeset
        @changeset ||= Changeset.new @repo, change_id
      end
      
      ##
      # Dunno why this is here
      #
      # @return [String]
      def repo_path
        @path
      end
      
      ##
      # The file log that tracks this file
      # 
      # @return [FileLog] The revision log tracking this file
      def file_log
        @file_log ||= @repo.file_log @path
      end
      
      ##
      # The revision of the repository that this {VersionedFile} belongs to.
      def change_id
        @change_id ||= @changeset.revision         if     @changeset
        @change_id ||= file_log[file_rev].link_rev unless @changeset
        @change_id
      end
      
      ##
      # The version of the file in its own history.
      def file_node
        @file_node ||= file_log.lookup_id(@file_id) if     @file_id
        @file_node ||= changeset.file_node(@path)   unless @file_id
        @file_node ||= NULL_ID
      end
      
      ##
      # Returns the index into the file log's history for this file
      def file_rev
        @file_rev ||= file_log.rev(file_node)
      end
      
      ##
      # Is this a null version?
      def nil?
        file_node.nil?
      end
      
      ##
      # String representation.
      def to_s
        "#{path}@#{node.hexlify[0..11]}"
      end
      
      ##
      # IRB Inspector string representation
      def inspect
        "#<HG Versioned File: #{to_s}>"
      end
      
      ##
      # Hash value for sticking this fucker in a hash
      def hash
        return (path + file_id.to_s).hash
      end
      
      ##
      # Equality! Compares paths and revision indexes
      def ==(other)
        return false unless @path && @file_id && other.path && other.file_id
        @path == other.path && @file_id == other.file_id
      end
      
      ##
      # Retrieves the file with a different ID
      # 
      # @param [String] file_id a new file ID revision in file_log
      def file(file_id)
        self.class.new @repo, @path, :file_id => file_id, :file_log => file_log
      end
      
      # Gets the flags for this file (x, l, or empty string)
      def flags; changeset.flags(@path); end
      # The manifest that this file revision is from
      def manifest_entry; changeset.manifest_entry; end
      # Node ID for this file's revision
      def node; changeset.node; end
      # All files in this changeset that this revision of this file was committed
      def files; changeset.all_files; end
      # The data in this file
      def data; file_log.read(file_node); end
      # The path to this file
      def path; @path; end
      # The size of this file
      def size; file_log.size(file_rev); end
      
      # Returns the revision index
      def revision
        return changeset.rev if @changeset || @change_id
        link_rev
      end
      
      # Link-revision index
      def link_rev; file_log[file_rev].link_rev; end
      
      ##
      # Compares to a bit of text.
      # Returns true if different, false for the same.
      # (much like <=> == 0 for the same)
      def cmp(text)
        file_log.cmp(file_node, text)
      end
            
      ##
      # Has this file been renamed? If so give some good info. Returns
      # the new location and the flags ('x', 'l', '')
      # 
      # @return [Array<String, String>] [new_path, flags]
      def renamed?
        renamed = file_log.renamed?(file_node)
        return renamed unless renamed
        
        return renamed if rev == link_rev
        
        name = path
        fnode = file_node
        changeset.parents.each do |p|
          pnode = p.filenode(name)
          next if pnode.nil?
          
          # Why the fuck does this method return nil. This could fuck things
          # up down the line. There better be a good fucking reason for this.
          # Sorry I'm so irritated. I just need some food.
          return nil if fnode == pnode
        end
        renamed
      end
    
      ##
      # What are this revised file's parents? Return them as {VersionedFile}s.
      def parents
        p = @path
        fl = file_log
        pl = file_log.parents(file_node).map {|n| [p, n, fl] }
        
        r = file_log.renamed?(file_node)
        pl[0] = [r[0], r[1], nil] if r
        
        pl.select {|parent,n,l| n != NULL_ID}.map do |parent, n, l|
          VersionedFile.new(@repo, parent, :file_id => n, :file_log => l)
        end
      end
      
      ##
      # What are this file's children?
      def children
        c = file_log.children(file_node)
        c.map do |x|
          VersionedFile.new(@repo, @path, :file_id => x, :file_log => file_log)
        end  
      end
      
      def annotate_decorate(text, revision, line_number = false)
        if line_number
          size = text.split("\n").size
          retarr = [nil,text]
          retarr[0] = (1..size).map {|i| [revision, i]}
        else
          retarr = [nil, text]
          retarr[0] = [[revision, false]] * text.split("\n").size
        end
        retarr
      end
      
      def annotate_diff_pair(parent, child)
        Diffs::BinaryDiff.blocks_as_array(parent[1], child[1]).each do |a1,a2,b1,b2|
          child[0][b1..(b2-1)] = parent[0][a1..(a2-1)]
        end
        child
      end
      
      def annotate_get_file(path, file_id)
        log = path == @path ? file_log : @repo.get_file(path)
        return VersionedFile.new(@repo, path, :file_id => file_id, :file_log => log)
      end
      
      def annotate_parents_helper(file, follow_copies = false)
        path = file.path
        if file.file_rev.nil?
          parent_list = file.parents.map {|n| [n.path, n.file_rev]}
        else
          parent_list = file.file_log.parent_indices_for_index(file.file_rev)
          parent_list.map! {|n| [path, n]}
        end
        if follow_copies
          r = file.renamed?
          pl[0] = [r[0], @repo.get_file(r[0]).revision(r[1])] if r
        end
        return parent_list.select {|p, n| n != NULL_REV}.
                           map {|p, n| annotate_get_file(p, n)}
      end
      
      def annotate(follow_copies = false, line_number = false)
        base = (revision != link_rev) ? file(file_rev) : self
        
        needed = {base => 1}
        counters = {(base.path + base.file_id.to_s) => 1}
        visit = [base]
        files = [base.path]
        
        while visit.any?
          file = visit.shift
          annotate_parents_helper(file).each do |p|
            unless needed.include? p
              needed[p] = 1
              counters[p.path + p.file_id.to_s] = 1
              visit << p
              files << p.path unless files.include? p.path
            end
          end
        end
        
        visit = []
        files.each do |f|
          filenames = needed.keys.select {|k| k.path == f}.map {|n| [n.revision, n]}
          visit += filenames
        end
        
        hist = {}
        lastfile = ""
        visit.sort.each do |rev, file_ann|
          curr = annotate_decorate(file_ann.data, file_ann, line_number)
          annotate_parents_helper(file_ann).each do |p|
            next if p.file_id == NULL_ID
            curr = annotate_diff_pair(hist[p.path + p.file_id.to_s], curr)
            counters[p.path + p.file_id.to_s] -= 1
            hist.delete(p.path + p.file_id.to_s) if counters[p.path + p.file_id.to_s] == 0
          end
          hist[file_ann.path+file_ann.file_id.to_s] = curr
          lastfile = file_ann
        end
        returnarr = []
        hist[lastfile.path+lastfile.file_id.to_s].inspect # force all lazy-loading to stoppeth
        ret = hist[lastfile.path+lastfile.file_id.to_s][0].each_with_index do |obj, i|
          returnarr << obj + [hist[lastfile.path+lastfile.file_id.to_s][1].split_newlines[i]]
        end
        #   hist[lastfile.path+lastfile.file_id.to_s][0][i] + hist[lastfile.path+lastfile.file_id.to_s][1].split_newlines[i]
        # end
        ret = hist[lastfile.path+lastfile.file_id.to_s][0].zip(hist[lastfile.path+lastfile.file_id.to_s][1].split_newlines)
        returnarr
      end
      
      def get_parents_helper(vertex, ancestor_cache, filelog_cache)
        return ancestor_cache[vertex] if ancestor_cache[vertex]
        file, node = vertex
        filelog_cache[file] = @repo.get_file(file) unless filelog_cache[file]
        
        filelog = filelog_cache[file]
        parent_list = filelog.parents(node).select {|p| p != NULL_ID}.map {|p| [file, p]}
        
        has_renamed = filelog.renamed?(node)
        
        parent_list << has_renamed if has_renamed
        ancestor_cache[vertex] = parent_list
        parent_list
      end
      
      def ancestor(file_2)
        ancestor_cache = {}
        [self, file_2].each do |c|
          if c.file_rev == NULL_REV || c.file_rev.nil?
            parent_list = c.parents.map {|n| [n.path, n.file_node]}
            ancestor_cache[[c.path, c.file_node]] = parent_list
          end
        end
        
        filelog_cache = {repo_path => file_log, file_2.repo_path => file_2.file_log}
        a, b = [path, file_node], [file_2.path, file_2.file_node]
        parents_proc = proc {|vertex| get_parents_helper(vertex, ancestor_cache, filelog_cache)}
        
        v = Graphs::AncestorCalculator.ancestors(a, b, parents_proc)
        if v
          file, node = v
          return VersionedFile.new(@repo, file, :file_id => node, :file_log => filelog_cache[file])
        end
        return nil
      end
      
    end
    
    ##
    # This is a VersionedFile, except it's in the working directory, so its data
    # is stored on disk in the actual file. Other than that, it's basically the
    # same in its interface!
    class VersionedWorkingFile < VersionedFile
      
      ##
      # Initializes a new working dir file - slightly different semantics here
      def initialize(repo, path, opts={})
        @repo, @path = repo, path
        @change_id = nil
        @file_rev, @file_node = nil, nil
        
        @file_log = opts[:file_log] if opts[:file_log]
        @changeset = opts[:working_changeset]
      end
      
      ##
      # Gets the working directory changeset
      def changeset
        @changeset ||= WorkingDirectoryChangeset.new(@repo)
      end
      
      ##
      # Dunno why this is here
      def repo_path
        @repo.dirstate.copy_map[@path] || @path
      end
      
      ##
      # Gets the file log?
      # 
      # @todo add a @file_log ||= in the front?
      def file_log
        @repo.file_log repo_path
      end
      
      ##
      # String representation
      def to_s
        "#{path}@#{@changeset}"
      end
      
      ##
      # Returns the file at a different revision
      def file(file_id)
        VersionedFile.new(@repo, repo_path, :file_id => file_id, :file_log => file_log)
      end
      
      ##
      # Get what revision this is
      def revision
        return @changeset.revision if @changeset
        file_log[@file_rev].link_rev
      end
      
      ##
      # Get the contents of this file
      def data
        data = @repo.working_read(@path)
        data
      end
      
      ##
      # Has this file been renamed? If so give some good info. Returns
      # the new location and the flags ('x', 'l', '')
      # 
      # @return [Array<String, String>] [new_path, flags]
      def renamed?
        rp = repo_path
        return nil if rp == @path
        [rp, (self.changeset.parents[0].manifest_entry[rp] || NULL_ID)]
      end
      
      ##
      # The working directory's parents are the heads, so get this file in
      # the previous revision.
      def parents
        p = @path
        rp = repo_path
        pcl = @changeset.parents
        fl = file_log
        pl = [[rp, pcl[0].manifest_entry[rp] || NULL_ID, fl]]
        if pcl.size > 1
          if rp != p
            fl = nil
          end
          pl << [p, pcl[1].manifest_entry[p] || NULL_ID, fl]
        end
        pl.select {|_, n, __| n != NULL_ID}.map do |parent, n, l|
          VersionedFile.new(@repo, parent, :file_id => n, :file_log => l)
        end
      end
      
      ##
      # Returns the current size of the file
      #
      def size
        File.stat(@repo.join(@path)).size
      end
      
      ##
      # Returns the date that this file was last modified.
      def date
        t, tz = changeset.date
        begin
          return [FileUtils.lstat(@repo.join(@path)).mtime, tz]
        rescue Errno::ENOENT
          return [t, tz]
        end
      end
      
      ##
      # Compares to the given text. Overridden because this file is
      # stored on disk in the actual working directory.
      # 
      # @param [String] text the text to compare to
      # @return [Boolean] true if the two are different
      def cmp(text)
        @repo.working_read(@path) != text
      end
      
    end
  end
end