// Copyright 2017 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package git

import (
	"github.com/emirpasic/gods/trees/binaryheap"
	"gopkg.in/src-d/go-git.v4/plumbing"
	"gopkg.in/src-d/go-git.v4/plumbing/object"
)

// GetCommitsInfo gets information of all commits that are corresponding to these entries
func (tes Entries) GetCommitsInfo(commit *Commit, treePath string, cache LastCommitCache) ([][]interface{}, *Commit, error) {
	entryPaths := make([]string, len(tes)+1)
	// Get the commit for the treePath itself
	entryPaths[0] = ""
	for i, entry := range tes {
		entryPaths[i+1] = entry.Name()
	}

	c, err := commit.repo.gogitRepo.CommitObject(plumbing.Hash(commit.ID))
	if err != nil {
		return nil, nil, err
	}

	revs, err := getLastCommitForPaths(c, treePath, entryPaths)
	if err != nil {
		return nil, nil, err
	}

	commit.repo.gogitStorage.Close()

	commitsInfo := make([][]interface{}, len(tes))
	for i, entry := range tes {
		if rev, ok := revs[entry.Name()]; ok {
			entryCommit := convertCommit(rev)
			if entry.IsSubModule() {
				subModuleURL := ""
				if subModule, err := commit.GetSubModule(entry.Name()); err != nil {
					return nil, nil, err
				} else if subModule != nil {
					subModuleURL = subModule.URL
				}
				subModuleFile := NewSubModuleFile(entryCommit, subModuleURL, entry.ID.String())
				commitsInfo[i] = []interface{}{entry, subModuleFile}
			} else {
				commitsInfo[i] = []interface{}{entry, entryCommit}
			}
		} else {
			commitsInfo[i] = []interface{}{entry, nil}
		}
	}

	// Retrieve the commit for the treePath itself (see above). We basically
	// get it for free during the tree traversal and it's used for listing
	// pages to display information about newest commit for a given path.
	var treeCommit *Commit
	if rev, ok := revs[""]; ok {
		treeCommit = convertCommit(rev)
	}
	return commitsInfo, treeCommit, nil
}

type commitAndPaths struct {
	commit *object.Commit
	// Paths that are still on the branch represented by commit
	paths []string
	// Set of hashes for the paths
	hashes map[string]plumbing.Hash
}

func getCommitTree(c *object.Commit, treePath string) (*object.Tree, error) {
	tree, err := c.Tree()
	if err != nil {
		return nil, err
	}

	// Optimize deep traversals by focusing only on the specific tree
	if treePath != "" {
		tree, err = tree.Tree(treePath)
		if err != nil {
			return nil, err
		}
	}

	return tree, nil
}

func getFullPath(treePath, path string) string {
	if treePath != "" {
		if path != "" {
			return treePath + "/" + path
		}
		return treePath
	}
	return path
}

func getFileHashes(c *object.Commit, treePath string, paths []string) (map[string]plumbing.Hash, error) {
	tree, err := getCommitTree(c, treePath)
	if err == object.ErrDirectoryNotFound {
		// The whole tree didn't exist, so return empty map
		return make(map[string]plumbing.Hash), nil
	}
	if err != nil {
		return nil, err
	}

	hashes := make(map[string]plumbing.Hash)
	for _, path := range paths {
		if path != "" {
			entry, err := tree.FindEntry(path)
			if err == nil {
				hashes[path] = entry.Hash
			}
		} else {
			hashes[path] = tree.Hash
		}
	}

	return hashes, nil
}

func getLastCommitForPaths(c *object.Commit, treePath string, paths []string) (map[string]*object.Commit, error) {
	// We do a tree traversal with nodes sorted by commit time
	seen := make(map[plumbing.Hash]bool)
	heap := binaryheap.NewWith(func(a, b interface{}) int {
		if a.(*commitAndPaths).commit.Committer.When.Before(b.(*commitAndPaths).commit.Committer.When) {
			return 1
		}
		return -1
	})

	result := make(map[string]*object.Commit)
	initialHashes, err := getFileHashes(c, treePath, paths)
	if err != nil {
		return nil, err
	}

	// Start search from the root commit and with full set of paths
	heap.Push(&commitAndPaths{c, paths, initialHashes})

	for {
		cIn, ok := heap.Pop()
		if !ok {
			break
		}
		current := cIn.(*commitAndPaths)
		currentID := current.commit.ID()

		if seen[currentID] {
			continue
		}
		seen[currentID] = true

		// Load the parent commits for the one we are currently examining
		numParents := current.commit.NumParents()
		var parents []*object.Commit
		for i := 0; i < numParents; i++ {
			parent, err := current.commit.Parent(i)
			if err != nil {
				break
			}
			parents = append(parents, parent)
		}

		// Examine the current commit and set of interesting paths
		numOfParentsWithPath := make([]int, len(current.paths))
		pathChanged := make([]bool, len(current.paths))
		parentHashes := make([]map[string]plumbing.Hash, len(parents))
		for j, parent := range parents {
			parentHashes[j], err = getFileHashes(parent, treePath, current.paths)
			if err != nil {
				break
			}

			for i, path := range current.paths {
				if parentHashes[j][path] != plumbing.ZeroHash {
					numOfParentsWithPath[i]++
					if parentHashes[j][path] != current.hashes[path] {
						pathChanged[i] = true
					}
				}
			}
		}

		var remainingPaths []string
		for i, path := range current.paths {
			switch numOfParentsWithPath[i] {
			case 0:
				// The path didn't exist in any parent, so it must have been created by
				// this commit. The results could already contain some newer change from
				// different path, so don't override that.
				if result[path] == nil {
					result[path] = current.commit
				}
			case 1:
				// The file is present on exactly one parent, so check if it was changed
				// and save the revision if it did.
				if pathChanged[i] {
					if result[path] == nil {
						result[path] = current.commit
					}
				} else {
					remainingPaths = append(remainingPaths, path)
				}
			default:
				// The file is present on more than one of the parent paths, so this is
				// a merge. We have to examine all the parent trees to find out where
				// the change occurred. pathChanged[i] would tell us that the file was
				// changed during the merge, but it wouldn't tell us the relevant commit
				// that introduced it.
				remainingPaths = append(remainingPaths, path)
			}
		}

		if len(remainingPaths) > 0 {
			// Add the parent nodes along with remaining paths to the heap for further
			// processing.
			for j, parent := range parents {
				if seen[parent.ID()] {
					continue
				}

				// Combine remainingPath with paths available on the parent branch
				// and make union of them
				var remainingPathsForParent []string
				for _, path := range remainingPaths {
					if parentHashes[j][path] != plumbing.ZeroHash {
						remainingPathsForParent = append(remainingPathsForParent, path)
					}
				}

				heap.Push(&commitAndPaths{parent, remainingPathsForParent, parentHashes[j]})
			}
		}
	}

	return result, nil
}