package object

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"strconv"
	"strings"

	dmp "github.com/sergi/go-diff/diffmatchpatch"

	"github.com/go-git/go-git/v6/plumbing"
	"github.com/go-git/go-git/v6/plumbing/filemode"
	fdiff "github.com/go-git/go-git/v6/plumbing/format/diff"
	"github.com/go-git/go-git/v6/utils/diff"
)

// ErrCanceled is returned when the operation is canceled.
var ErrCanceled = errors.New("operation canceled")

func getPatch(message string, changes ...*Change) (*Patch, error) {
	ctx := context.Background()
	return getPatchContext(ctx, message, changes...)
}

func getPatchContext(ctx context.Context, message string, changes ...*Change) (*Patch, error) {
	if len(changes) == 0 {
		return &Patch{message: message}, nil
	}

	filePatches := make([]fdiff.FilePatch, 0, len(changes))
	for _, c := range changes {
		select {
		case <-ctx.Done():
			return nil, ErrCanceled
		default:
		}

		fp, err := filePatchWithContext(ctx, c)
		if err != nil {
			return nil, err
		}

		filePatches = append(filePatches, fp)
	}

	return &Patch{message, filePatches}, nil
}

func filePatchWithContext(ctx context.Context, c *Change) (fdiff.FilePatch, error) {
	from, to, err := c.Files()
	if err != nil {
		return nil, err
	}
	fromContent, fIsBinary, err := fileContent(from)
	if err != nil {
		return nil, err
	}

	toContent, tIsBinary, err := fileContent(to)
	if err != nil {
		return nil, err
	}

	if fIsBinary || tIsBinary {
		return &textFilePatch{from: c.From, to: c.To}, nil
	}

	diffs := diff.Do(fromContent, toContent)

	chunks := make([]fdiff.Chunk, 0, len(diffs))
	for _, d := range diffs {
		select {
		case <-ctx.Done():
			return nil, ErrCanceled
		default:
		}

		var op fdiff.Operation
		switch d.Type {
		case dmp.DiffEqual:
			op = fdiff.Equal
		case dmp.DiffDelete:
			op = fdiff.Delete
		case dmp.DiffInsert:
			op = fdiff.Add
		}

		chunks = append(chunks, &textChunk{d.Text, op})
	}

	return &textFilePatch{
		chunks: chunks,
		from:   c.From,
		to:     c.To,
	}, nil
}

func fileContent(f *File) (content string, isBinary bool, err error) {
	if f == nil {
		return content, isBinary, err
	}

	isBinary, err = f.IsBinary()
	if err != nil || isBinary {
		return content, isBinary, err
	}

	content, err = f.Contents()

	return content, isBinary, err
}

// Patch is an implementation of fdiff.Patch interface
type Patch struct {
	message     string
	filePatches []fdiff.FilePatch
}

// FilePatches returns the file patches.
func (p *Patch) FilePatches() []fdiff.FilePatch {
	return p.filePatches
}

// Message returns the patch message.
func (p *Patch) Message() string {
	return p.message
}

// Encode encodes the patch to the given writer.
func (p *Patch) Encode(w io.Writer) error {
	ue := fdiff.NewUnifiedEncoder(w, fdiff.DefaultContextLines)

	return ue.Encode(p)
}

// Stats returns the file stats.
func (p *Patch) Stats() FileStats {
	return getFileStatsFromFilePatches(p.FilePatches())
}

func (p *Patch) String() string {
	buf := bytes.NewBuffer(nil)
	err := p.Encode(buf)
	if err != nil {
		return fmt.Sprintf("malformed patch: %s", err.Error())
	}

	return buf.String()
}

// changeEntryWrapper is an implementation of fdiff.File interface
type changeEntryWrapper struct {
	ce ChangeEntry
}

func (f *changeEntryWrapper) Hash() plumbing.Hash {
	if !f.ce.TreeEntry.Mode.IsFile() {
		return plumbing.ZeroHash
	}

	return f.ce.TreeEntry.Hash
}

func (f *changeEntryWrapper) Mode() filemode.FileMode {
	return f.ce.TreeEntry.Mode
}

func (f *changeEntryWrapper) Path() string {
	if !f.ce.TreeEntry.Mode.IsFile() {
		return ""
	}

	return f.ce.Name
}

func (f *changeEntryWrapper) Empty() bool {
	return !f.ce.TreeEntry.Mode.IsFile()
}

// textFilePatch is an implementation of fdiff.FilePatch interface
type textFilePatch struct {
	chunks   []fdiff.Chunk
	from, to ChangeEntry
}

func (tf *textFilePatch) Files() (from, to fdiff.File) {
	f := &changeEntryWrapper{tf.from}
	t := &changeEntryWrapper{tf.to}

	if !f.Empty() {
		from = f
	}

	if !t.Empty() {
		to = t
	}

	return from, to
}

func (tf *textFilePatch) IsBinary() bool {
	return len(tf.chunks) == 0
}

func (tf *textFilePatch) Chunks() []fdiff.Chunk {
	return tf.chunks
}

// textChunk is an implementation of fdiff.Chunk interface
type textChunk struct {
	content string
	op      fdiff.Operation
}

func (t *textChunk) Content() string {
	return t.content
}

func (t *textChunk) Type() fdiff.Operation {
	return t.op
}

// FileStat stores the status of changes in content of a file.
type FileStat struct {
	Name     string
	Addition int
	Deletion int
}

func (fs FileStat) String() string {
	return printStat([]FileStat{fs})
}

// FileStats is a collection of FileStat.
type FileStats []FileStat

func (fileStats FileStats) String() string {
	return printStat(fileStats)
}

// printStat prints the stats of changes in content of files.
// Original implementation: https://github.com/git/git/blob/1a87c842ece327d03d08096395969aca5e0a6996/diff.c#L2615
// Parts of the output:
// <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
// example: " main.go | 10 +++++++--- "
func printStat(fileStats []FileStat) string {
	maxGraphWidth := uint(53)
	maxNameLen := 0
	maxChangeLen := 0

	scaleLinear := func(it, width, maxVal uint) uint {
		if it == 0 || maxVal == 0 {
			return 0
		}

		return 1 + (it * (width - 1) / maxVal)
	}

	for _, fs := range fileStats {
		if len(fs.Name) > maxNameLen {
			maxNameLen = len(fs.Name)
		}

		changes := strconv.Itoa(fs.Addition + fs.Deletion)
		if len(changes) > maxChangeLen {
			maxChangeLen = len(changes)
		}
	}

	var result strings.Builder
	for _, fs := range fileStats {
		add := uint(fs.Addition)
		del := uint(fs.Deletion)
		np := maxNameLen - len(fs.Name)
		cp := maxChangeLen - len(strconv.Itoa(fs.Addition+fs.Deletion))

		total := add + del
		if total > maxGraphWidth {
			add = scaleLinear(add, maxGraphWidth, total)
			del = scaleLinear(del, maxGraphWidth, total)
		}

		adds := strings.Repeat("+", int(add))
		dels := strings.Repeat("-", int(del))
		namePad := strings.Repeat(" ", np)
		changePad := strings.Repeat(" ", cp)

		fmt.Fprintf(&result, " %s%s | %s%d %s%s\n", fs.Name, namePad, changePad, total, adds, dels)
	}
	return result.String()
}

func getFileStatsFromFilePatches(filePatches []fdiff.FilePatch) FileStats {
	fileStats := make(FileStats, 0, len(filePatches))

	for _, fp := range filePatches {
		// ignore empty patches (binary files, submodule refs updates)
		if len(fp.Chunks()) == 0 {
			continue
		}

		cs := FileStat{}
		from, to := fp.Files()
		switch {
		case from == nil:
			// New File is created.
			cs.Name = to.Path()
		case to == nil:
			// File is deleted.
			cs.Name = from.Path()
		case from.Path() != to.Path():
			// File is renamed.
			cs.Name = fmt.Sprintf("%s => %s", from.Path(), to.Path())
		default:
			cs.Name = from.Path()
		}

		for _, chunk := range fp.Chunks() {
			s := chunk.Content()
			if len(s) == 0 {
				continue
			}

			switch chunk.Type() {
			case fdiff.Add:
				cs.Addition += strings.Count(s, "\n")
				if s[len(s)-1] != '\n' {
					cs.Addition++
				}
			case fdiff.Delete:
				cs.Deletion += strings.Count(s, "\n")
				if s[len(s)-1] != '\n' {
					cs.Deletion++
				}
			}
		}

		fileStats = append(fileStats, cs)
	}

	return fileStats
}
