// 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.

// +build !gogit

package git

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"math"
	"path"
	"sort"
	"strings"
)

// GetCommitsInfo gets information of all commits that are corresponding to these entries
func (tes Entries) GetCommitsInfo(commit *Commit, treePath string, cache *LastCommitCache) ([]CommitInfo, *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()
	}

	var err error

	var revs map[string]*Commit
	if cache != nil {
		var unHitPaths []string
		revs, unHitPaths, err = getLastCommitForPathsByCache(commit.ID.String(), treePath, entryPaths, cache)
		if err != nil {
			return nil, nil, err
		}
		if len(unHitPaths) > 0 {
			sort.Strings(unHitPaths)
			commits, err := GetLastCommitForPaths(commit, treePath, unHitPaths)
			if err != nil {
				return nil, nil, err
			}

			for i, found := range commits {
				if err := cache.Put(commit.ID.String(), path.Join(treePath, unHitPaths[i]), found.ID.String()); err != nil {
					return nil, nil, err
				}
				revs[unHitPaths[i]] = found
			}
		}
	} else {
		sort.Strings(entryPaths)
		revs = map[string]*Commit{}
		var foundCommits []*Commit
		foundCommits, err = GetLastCommitForPaths(commit, treePath, entryPaths)
		for i, found := range foundCommits {
			revs[entryPaths[i]] = found
		}
	}
	if err != nil {
		return nil, nil, err
	}

	commitsInfo := make([]CommitInfo, len(tes))
	for i, entry := range tes {
		commitsInfo[i] = CommitInfo{
			Entry: entry,
		}
		if entryCommit, ok := revs[entry.Name()]; ok {
			commitsInfo[i].Commit = entryCommit
			if entry.IsSubModule() {
				subModuleURL := ""
				var fullPath string
				if len(treePath) > 0 {
					fullPath = treePath + "/" + entry.Name()
				} else {
					fullPath = entry.Name()
				}
				if subModule, err := commit.GetSubModule(fullPath); err != nil {
					return nil, nil, err
				} else if subModule != nil {
					subModuleURL = subModule.URL
				}
				subModuleFile := NewSubModuleFile(entryCommit, subModuleURL, entry.ID.String())
				commitsInfo[i].SubModuleFile = subModuleFile
			}
		}
	}

	// 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
	var ok bool
	if treePath == "" {
		treeCommit = commit
	} else if treeCommit, ok = revs[""]; ok {
		treeCommit.repo = commit.repo
	}
	return commitsInfo, treeCommit, nil
}

func getLastCommitForPathsByCache(commitID, treePath string, paths []string, cache *LastCommitCache) (map[string]*Commit, []string, error) {
	var unHitEntryPaths []string
	var results = make(map[string]*Commit)
	for _, p := range paths {
		lastCommit, err := cache.Get(commitID, path.Join(treePath, p))
		if err != nil {
			return nil, nil, err
		}
		if lastCommit != nil {
			results[p] = lastCommit.(*Commit)
			continue
		}

		unHitEntryPaths = append(unHitEntryPaths, p)
	}

	return results, unHitEntryPaths, nil
}

// GetLastCommitForPaths returns last commit information
func GetLastCommitForPaths(commit *Commit, treePath string, paths []string) ([]*Commit, error) {
	// We read backwards from the commit to obtain all of the commits

	// We'll do this by using rev-list to provide us with parent commits in order
	revListReader, revListWriter := io.Pipe()
	defer func() {
		_ = revListWriter.Close()
		_ = revListReader.Close()
	}()

	go func() {
		stderr := strings.Builder{}
		err := NewCommand("rev-list", "--format=%T", commit.ID.String()).RunInDirPipeline(commit.repo.Path, revListWriter, &stderr)
		if err != nil {
			_ = revListWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
		} else {
			_ = revListWriter.Close()
		}
	}()

	// We feed the commits in order into cat-file --batch, followed by their trees and sub trees as necessary.
	// so let's create a batch stdin and stdout
	batchStdinReader, batchStdinWriter := io.Pipe()
	batchStdoutReader, batchStdoutWriter := io.Pipe()
	defer func() {
		_ = batchStdinReader.Close()
		_ = batchStdinWriter.Close()
		_ = batchStdoutReader.Close()
		_ = batchStdoutWriter.Close()
	}()

	go func() {
		stderr := strings.Builder{}
		err := NewCommand("cat-file", "--batch").RunInDirFullPipeline(commit.repo.Path, batchStdoutWriter, &stderr, batchStdinReader)
		if err != nil {
			_ = revListWriter.CloseWithError(ConcatenateError(err, (&stderr).String()))
		} else {
			_ = revListWriter.Close()
		}
	}()

	// For simplicities sake we'll us a buffered reader
	batchReader := bufio.NewReader(batchStdoutReader)

	mapsize := 4096
	if len(paths) > mapsize {
		mapsize = len(paths)
	}

	path2idx := make(map[string]int, mapsize)
	for i, path := range paths {
		path2idx[path] = i
	}

	fnameBuf := make([]byte, 4096)
	modeBuf := make([]byte, 40)

	allShaBuf := make([]byte, (len(paths)+1)*20)
	shaBuf := make([]byte, 20)
	tmpTreeID := make([]byte, 40)

	// commits is the returnable commits matching the paths provided
	commits := make([]string, len(paths))
	// ids are the blob/tree ids for the paths
	ids := make([][]byte, len(paths))

	// We'll use a scanner for the revList because it's simpler than a bufio.Reader
	scan := bufio.NewScanner(revListReader)
revListLoop:
	for scan.Scan() {
		// Get the next parent commit ID
		commitID := scan.Text()
		if !scan.Scan() {
			break revListLoop
		}
		commitID = commitID[7:]
		rootTreeID := scan.Text()

		// push the tree to the cat-file --batch process
		_, err := batchStdinWriter.Write([]byte(rootTreeID + "\n"))
		if err != nil {
			return nil, err
		}

		currentPath := ""

		// OK if the target tree path is "" and the "" is in the paths just set this now
		if treePath == "" && paths[0] == "" {
			// If this is the first time we see this set the id appropriate for this paths to this tree and set the last commit to curCommit
			if len(ids[0]) == 0 {
				ids[0] = []byte(rootTreeID)
				commits[0] = string(commitID)
			} else if bytes.Equal(ids[0], []byte(rootTreeID)) {
				commits[0] = string(commitID)
			}
		}

	treeReadingLoop:
		for {
			_, _, size, err := ReadBatchLine(batchReader)
			if err != nil {
				return nil, err
			}

			// Handle trees

			// n is counter for file position in the tree file
			var n int64

			// Two options: currentPath is the targetTreepath
			if treePath == currentPath {
				// We are in the right directory
				// Parse each tree line in turn. (don't care about mode here.)
				for n < size {
					fname, sha, count, err := ParseTreeLineSkipMode(batchReader, fnameBuf, shaBuf)
					shaBuf = sha
					if err != nil {
						return nil, err
					}
					n += int64(count)
					idx, ok := path2idx[string(fname)]
					if ok {
						// Now if this is the first time round set the initial Blob(ish) SHA ID and the commit
						if len(ids[idx]) == 0 {
							copy(allShaBuf[20*(idx+1):20*(idx+2)], shaBuf)
							ids[idx] = allShaBuf[20*(idx+1) : 20*(idx+2)]
							commits[idx] = string(commitID)
						} else if bytes.Equal(ids[idx], shaBuf) {
							commits[idx] = string(commitID)
						}
					}
					// FIXME: is there any order to the way strings are emitted from cat-file?
					// if there is - then we could skip once we've passed all of our data
				}
				break treeReadingLoop
			}

			var treeID []byte

			// We're in the wrong directory
			// Find target directory in this directory
			idx := len(currentPath)
			if idx > 0 {
				idx++
			}
			target := strings.SplitN(treePath[idx:], "/", 2)[0]

			for n < size {
				// Read each tree entry in turn
				mode, fname, sha, count, err := ParseTreeLine(batchReader, modeBuf, fnameBuf, shaBuf)
				if err != nil {
					return nil, err
				}
				n += int64(count)

				// if we have found the target directory
				if bytes.Equal(fname, []byte(target)) && bytes.Equal(mode, []byte("40000")) {
					copy(tmpTreeID, sha)
					treeID = tmpTreeID
					break
				}
			}

			if n < size {
				// Discard any remaining entries in the current tree
				discard := size - n
				for discard > math.MaxInt32 {
					_, err := batchReader.Discard(math.MaxInt32)
					if err != nil {
						return nil, err
					}
					discard -= math.MaxInt32
				}
				_, err := batchReader.Discard(int(discard))
				if err != nil {
					return nil, err
				}
			}

			// if we haven't found a treeID for the target directory our search is over
			if len(treeID) == 0 {
				break treeReadingLoop
			}

			// add the target to the current path
			if idx > 0 {
				currentPath += "/"
			}
			currentPath += target

			// if we've now found the current path check its sha id and commit status
			if treePath == currentPath && paths[0] == "" {
				if len(ids[0]) == 0 {
					copy(allShaBuf[0:20], treeID)
					ids[0] = allShaBuf[0:20]
					commits[0] = string(commitID)
				} else if bytes.Equal(ids[0], treeID) {
					commits[0] = string(commitID)
				}
			}
			treeID = to40ByteSHA(treeID)
			_, err = batchStdinWriter.Write(treeID)
			if err != nil {
				return nil, err
			}
			_, err = batchStdinWriter.Write([]byte("\n"))
			if err != nil {
				return nil, err
			}
		}
	}

	commitsMap := make(map[string]*Commit, len(commits))
	commitsMap[commit.ID.String()] = commit

	commitCommits := make([]*Commit, len(commits))
	for i, commitID := range commits {
		c, ok := commitsMap[commitID]
		if ok {
			commitCommits[i] = c
			continue
		}

		if len(commitID) == 0 {
			continue
		}

		_, err := batchStdinWriter.Write([]byte(commitID + "\n"))
		if err != nil {
			return nil, err
		}
		_, typ, size, err := ReadBatchLine(batchReader)
		if err != nil {
			return nil, err
		}
		if typ != "commit" {
			return nil, fmt.Errorf("unexpected type: %s for commit id: %s", typ, commitID)
		}
		c, err = CommitFromReader(commit.repo, MustIDFromString(string(commitID)), io.LimitReader(batchReader, int64(size)))
		if err != nil {
			return nil, err
		}
		commitCommits[i] = c
	}

	return commitCommits, scan.Err()
}
