// Copyright 2017 The Gogs 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 (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"io/ioutil"
	"strconv"
	"strings"
	"time"
)

// DiffLineType represents the type of a line in diff.
type DiffLineType uint8

const (
	DIFF_LINE_PLAIN DiffLineType = iota + 1
	DIFF_LINE_ADD
	DIFF_LINE_DEL
	DIFF_LINE_SECTION
)

// DiffFileType represents the file status in diff.
type DiffFileType uint8

const (
	DIFF_FILE_ADD DiffFileType = iota + 1
	DIFF_FILE_CHANGE
	DIFF_FILE_DEL
	DIFF_FILE_RENAME
)

// DiffLine represents a line in diff.
type DiffLine struct {
	LeftIdx  int
	RightIdx int
	Type     DiffLineType
	Content  string
}

func (d *DiffLine) GetType() int {
	return int(d.Type)
}

// DiffSection represents a section in diff.
type DiffSection struct {
	Name  string
	Lines []*DiffLine
}

// Line returns a specific line by type (add or del) and file line number from a section.
func (diffSection *DiffSection) Line(lineType DiffLineType, idx int) *DiffLine {
	var (
		difference    = 0
		addCount      = 0
		delCount      = 0
		matchDiffLine *DiffLine
	)

LOOP:
	for _, diffLine := range diffSection.Lines {
		switch diffLine.Type {
		case DIFF_LINE_ADD:
			addCount++
		case DIFF_LINE_DEL:
			delCount++
		default:
			if matchDiffLine != nil {
				break LOOP
			}
			difference = diffLine.RightIdx - diffLine.LeftIdx
			addCount = 0
			delCount = 0
		}

		switch lineType {
		case DIFF_LINE_DEL:
			if diffLine.RightIdx == 0 && diffLine.LeftIdx == idx-difference {
				matchDiffLine = diffLine
			}
		case DIFF_LINE_ADD:
			if diffLine.LeftIdx == 0 && diffLine.RightIdx == idx+difference {
				matchDiffLine = diffLine
			}
		}
	}

	if addCount == delCount {
		return matchDiffLine
	}
	return nil
}

// DiffFile represents a file in diff.
type DiffFile struct {
	Name               string
	OldName            string
	Index              string // 40-byte SHA, Changed/New: new SHA; Deleted: old SHA
	Addition, Deletion int
	Type               DiffFileType
	IsCreated          bool
	IsDeleted          bool
	IsBin              bool
	IsRenamed          bool
	IsSubmodule        bool
	Sections           []*DiffSection
	IsIncomplete       bool
}

func (diffFile *DiffFile) GetType() int {
	return int(diffFile.Type)
}

func (diffFile *DiffFile) NumSections() int {
	return len(diffFile.Sections)
}

// Diff contains all information of a specific diff output.
type Diff struct {
	TotalAddition, TotalDeletion int
	Files                        []*DiffFile
	IsIncomplete                 bool
}

func (diff *Diff) NumFiles() int {
	return len(diff.Files)
}

const _DIFF_HEAD = "diff --git "

// ParsePatch takes a reader and parses everything it receives in diff format.
func ParsePatch(done chan<- error, maxLines, maxLineCharacteres, maxFiles int, reader io.Reader) *Diff {
	var (
		diff = &Diff{Files: make([]*DiffFile, 0)}

		curFile    *DiffFile
		curSection = &DiffSection{
			Lines: make([]*DiffLine, 0, 10),
		}

		leftLine, rightLine int
		lineCount           int
		curFileLinesCount   int
	)
	input := bufio.NewReader(reader)
	isEOF := false
	for !isEOF {
		// TODO: would input.ReadBytes be more memory-efficient?
		line, err := input.ReadString('\n')
		if err != nil {
			if err == io.EOF {
				isEOF = true
			} else {
				done <- fmt.Errorf("ReadString: %v", err)
				return nil
			}
		}

		if len(line) > 0 && line[len(line)-1] == '\n' {
			// Remove line break.
			line = line[:len(line)-1]
		}

		if strings.HasPrefix(line, "+++ ") || strings.HasPrefix(line, "--- ") || len(line) == 0 {
			continue
		}

		curFileLinesCount++
		lineCount++

		// Diff data too large, we only show the first about maxlines lines
		if curFileLinesCount >= maxLines || len(line) >= maxLineCharacteres {
			curFile.IsIncomplete = true
		}

		switch {
		case line[0] == ' ':
			diffLine := &DiffLine{Type: DIFF_LINE_PLAIN, Content: line, LeftIdx: leftLine, RightIdx: rightLine}
			leftLine++
			rightLine++
			curSection.Lines = append(curSection.Lines, diffLine)
			continue
		case line[0] == '@':
			curSection = &DiffSection{}
			curFile.Sections = append(curFile.Sections, curSection)
			ss := strings.Split(line, "@@")
			diffLine := &DiffLine{Type: DIFF_LINE_SECTION, Content: line}
			curSection.Lines = append(curSection.Lines, diffLine)

			// Parse line number.
			ranges := strings.Split(ss[1][1:], " ")
			leftLine, _ = strconv.Atoi(strings.Split(ranges[0], ",")[0][1:])
			if len(ranges) > 1 {
				rightLine, _ = strconv.Atoi(strings.Split(ranges[1], ",")[0])
			} else {
				rightLine = leftLine
			}
			continue
		case line[0] == '+':
			curFile.Addition++
			diff.TotalAddition++
			diffLine := &DiffLine{Type: DIFF_LINE_ADD, Content: line, RightIdx: rightLine}
			rightLine++
			curSection.Lines = append(curSection.Lines, diffLine)
			continue
		case line[0] == '-':
			curFile.Deletion++
			diff.TotalDeletion++
			diffLine := &DiffLine{Type: DIFF_LINE_DEL, Content: line, LeftIdx: leftLine}
			if leftLine > 0 {
				leftLine++
			}
			curSection.Lines = append(curSection.Lines, diffLine)
		case strings.HasPrefix(line, "Binary"):
			curFile.IsBin = true
			continue
		}

		// Get new file.
		if strings.HasPrefix(line, _DIFF_HEAD) {
			middle := -1

			// Note: In case file name is surrounded by double quotes (it happens only in git-shell).
			// e.g. diff --git "a/xxx" "b/xxx"
			hasQuote := line[len(_DIFF_HEAD)] == '"'
			if hasQuote {
				middle = strings.Index(line, ` "b/`)
			} else {
				middle = strings.Index(line, " b/")
			}

			beg := len(_DIFF_HEAD)
			a := line[beg+2 : middle]
			b := line[middle+3:]
			if hasQuote {
				a = string(UnescapeChars([]byte(a[1 : len(a)-1])))
				b = string(UnescapeChars([]byte(b[1 : len(b)-1])))
			}

			curFile = &DiffFile{
				Name:     a,
				Type:     DIFF_FILE_CHANGE,
				Sections: make([]*DiffSection, 0, 10),
			}
			diff.Files = append(diff.Files, curFile)
			if len(diff.Files) >= maxFiles {
				diff.IsIncomplete = true
				io.Copy(ioutil.Discard, reader)
				break
			}
			curFileLinesCount = 0

			// Check file diff type and submodule.
		CHECK_TYPE:
			for {
				line, err := input.ReadString('\n')
				if err != nil {
					if err == io.EOF {
						isEOF = true
					} else {
						done <- fmt.Errorf("ReadString: %v", err)
						return nil
					}
				}

				switch {
				case strings.HasPrefix(line, "new file"):
					curFile.Type = DIFF_FILE_ADD
					curFile.IsCreated = true
					curFile.IsSubmodule = strings.HasSuffix(line, " 160000\n")
				case strings.HasPrefix(line, "deleted"):
					curFile.Type = DIFF_FILE_DEL
					curFile.IsDeleted = true
					curFile.IsSubmodule = strings.HasSuffix(line, " 160000\n")
				case strings.HasPrefix(line, "index"):
					if curFile.IsDeleted {
						curFile.Index = line[6:46]
					} else if len(line) >= 88 {
						curFile.Index = line[49:88]
					} else {
						curFile.Index = curFile.Name
					}
					break CHECK_TYPE
				case strings.HasPrefix(line, "similarity index 100%"):
					curFile.Type = DIFF_FILE_RENAME
					curFile.IsRenamed = true
					curFile.OldName = curFile.Name
					curFile.Name = b
					curFile.Index = b
					break CHECK_TYPE
				case strings.HasPrefix(line, "old mode"):
					break CHECK_TYPE
				}
			}
		}
	}

	done <- nil
	return diff
}

// GetDiffRange returns a parsed diff object between given commits.
func GetDiffRange(repoPath, beforeCommitID, afterCommitID string, maxLines, maxLineCharacteres, maxFiles int) (*Diff, error) {
	repo, err := OpenRepository(repoPath)
	if err != nil {
		return nil, err
	}

	commit, err := repo.GetCommit(afterCommitID)
	if err != nil {
		return nil, err
	}

	cmd := NewCommand()
	if len(beforeCommitID) == 0 {
		// First commit of repository
		if commit.ParentCount() == 0 {
			cmd.AddArguments("show", "--full-index", afterCommitID)
		} else {
			c, _ := commit.Parent(0)
			cmd.AddArguments("diff", "--full-index", "-M", c.ID.String(), afterCommitID)
		}
	} else {
		cmd.AddArguments("diff", "--full-index", "-M", beforeCommitID, afterCommitID)
	}

	stdout, w := io.Pipe()
	done := make(chan error)
	var diff *Diff
	go func() {
		diff = ParsePatch(done, maxLines, maxLineCharacteres, maxFiles, stdout)
	}()

	stderr := new(bytes.Buffer)
	err = cmd.RunInDirTimeoutPipeline(2*time.Minute, repoPath, w, stderr)
	w.Close() // Close writer to exit parsing goroutine
	if err != nil {
		return nil, concatenateError(err, stderr.String())
	}

	return diff, <-done
}

// RawDiffType represents the type of raw diff format.
type RawDiffType string

const (
	RAW_DIFF_NORMAL RawDiffType = "diff"
	RAW_DIFF_PATCH  RawDiffType = "patch"
)

// GetRawDiff dumps diff results of repository in given commit ID to io.Writer.
func GetRawDiff(repoPath, commitID string, diffType RawDiffType, writer io.Writer) error {
	repo, err := OpenRepository(repoPath)
	if err != nil {
		return fmt.Errorf("OpenRepository: %v", err)
	}

	commit, err := repo.GetCommit(commitID)
	if err != nil {
		return err
	}

	cmd := NewCommand()
	switch diffType {
	case RAW_DIFF_NORMAL:
		if commit.ParentCount() == 0 {
			cmd.AddArguments("show", commitID)
		} else {
			c, _ := commit.Parent(0)
			cmd.AddArguments("diff", "-M", c.ID.String(), commitID)
		}
	case RAW_DIFF_PATCH:
		if commit.ParentCount() == 0 {
			cmd.AddArguments("format-patch", "--no-signature", "--stdout", "--root", commitID)
		} else {
			c, _ := commit.Parent(0)
			query := fmt.Sprintf("%s...%s", commitID, c.ID.String())
			cmd.AddArguments("format-patch", "--no-signature", "--stdout", query)
		}
	default:
		return fmt.Errorf("invalid diffType: %s", diffType)
	}

	stderr := new(bytes.Buffer)
	if err = cmd.RunInDirPipeline(repoPath, writer, stderr); err != nil {
		return concatenateError(err, stderr.String())
	}
	return nil
}

// GetDiffCommit returns a parsed diff object of given commit.
func GetDiffCommit(repoPath, commitID string, maxLines, maxLineCharacteres, maxFiles int) (*Diff, error) {
	return GetDiffRange(repoPath, "", commitID, maxLines, maxLineCharacteres, maxFiles)
}