package git

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"io"
	"mime"
	"path"
	"path/filepath"
	"strings"
	"time"

	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
)

type NiceTree struct {
	IsFile bool
	Name   string
	Commit *Commit
	Mode   string
	Size   int64
}

func (g *Repo) makeNiceTree(ctx context.Context, t *object.Tree, parent string) []NiceTree {
	var nts []NiceTree

	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	cms, err := g.lastCommitForFilesInTree(ctx, t, parent)
	if err != nil {
		return nts
	}

	for _, e := range t.Entries {
		fpath := path.Join(parent, e.Name)
		mode, _ := e.Mode.ToOSFileMode()
		sz, _ := t.Size(e.Name)
		nts = append(nts, NiceTree{
			Commit: cms[fpath],
			Name:   e.Name,
			Mode:   mode.String(),
			IsFile: e.Mode.IsFile(),
			Size:   sz,
		})
	}
	return nts
}

func (g *Repo) FileTree(ctx context.Context, path string) ([]NiceTree, error) {
	c, err := g.r.CommitObject(g.h)
	if err != nil {
		return nil, fmt.Errorf("commit object: %w", err)
	}

	tree, err := c.Tree()
	if err != nil {
		return nil, fmt.Errorf("file tree: %w", err)
	}

	var files []NiceTree
	if path == "" {
		files = g.makeNiceTree(ctx, tree, path)
	} else {
		o, err := tree.FindEntry(path)
		if err != nil {
			return nil, err
		}

		if !o.Mode.IsFile() {
			subtree, err := tree.Tree(path)
			if err != nil {
				return nil, err
			}
			files = g.makeNiceTree(ctx, subtree, path)
		}
	}

	return files, nil
}

type FileContent struct {
	IsBinary bool
	IsImage  bool
	Content  []byte
	Mime     string
	Size     int64
}

func (fc *FileContent) String() string {
	if fc.IsBinary || fc.IsImage {
		return ""
	}
	return string(fc.Content)
}

func (g *Repo) FileContent(path string) (*FileContent, error) {
	c, err := g.r.CommitObject(g.h)
	if err != nil {
		return &FileContent{}, fmt.Errorf("commit object: %w", err)
	}

	tree, err := c.Tree()
	if err != nil {
		return &FileContent{}, fmt.Errorf("file tree: %w", err)
	}

	file, err := tree.File(path)
	if err != nil {
		if errors.Is(err, object.ErrFileNotFound) {
			return &FileContent{}, ErrFileNotFound
		}
		return &FileContent{}, err
	}

	reader, err := file.Reader()
	if err != nil {
		return nil, fmt.Errorf("file reader: %w", err)
	}
	defer func() { _ = reader.Close() }()

	content, err := io.ReadAll(reader)
	if err != nil {
		return nil, fmt.Errorf("read file: %w", err)
	}

	isBin, _ := file.IsBinary()
	mimeType := mime.TypeByExtension(filepath.Ext(path))
	if mimeType == "" {
		mimeType = "text/plain"
		if isBin {
			mimeType = "application/octet-stream"
		}
	}

	return &FileContent{
		IsBinary: isBin,
		IsImage:  strings.HasPrefix(mimeType, "image/"),
		Content:  content,
		Mime:     mimeType,
		Size:     file.Size,
	}, nil
}

type logCommit struct {
	Commit
	hash  plumbing.Hash
	files []string
}

func (g *Repo) lastFileCommitHash(ctx context.Context, fpath string) (string, error) {
	output, err := g.streamingGitLog(ctx, "-n", "1", "--format=%H", "--", fpath)
	if err != nil {
		return "", fmt.Errorf("last file commit for %q: %w", fpath, err)
	}
	defer func() { _ = output.Close() }()

	raw, err := io.ReadAll(output)
	if err != nil {
		return "", fmt.Errorf("reading log output for %q: %w", fpath, err)
	}

	hash := string(raw)
	if hash == "" {
		return "", fmt.Errorf("no last commit found for %q", fpath)
	}

	return hash, nil
}

func (g *Repo) lastCommitForFilesInTree(ctx context.Context, subtree *object.Tree, parent string) (map[string]*Commit, error) {
	filesToDo := make(map[string]struct{})
	filesDone := make(map[string]*Commit)
	for _, e := range subtree.Entries {
		fpath := path.Clean(path.Join(parent, e.Name))
		filesToDo[fpath] = struct{}{}
	}

	if len(filesToDo) == 0 {
		return filesDone, nil
	}

	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	pathSpec := "."
	if parent != "" {
		pathSpec = parent
	}

	output, err := g.streamingGitLog(ctx, "--pretty=format:%H,%ad,%ae,%an,%cd,%ce,%cn,%s", "--date=iso", "--name-only", "--", pathSpec)
	if err != nil {
		return nil, err
	}
	defer func() { _ = output.Close() }()

	var current logCommit
	reader := bufio.NewReader(output)
	for {
		line, err := reader.ReadString('\n')
		if err != nil && err != io.EOF {
			return nil, err
		}

		line = strings.TrimSpace(line)
		if line == "" {
			if !current.hash.IsZero() {
				c := current.Commit
				// we have a fully parsed commit
				for _, f := range current.files {
					if _, ok := filesToDo[f]; ok {
						filesDone[f] = &c
						delete(filesToDo, f)
					}
				}

				if len(filesToDo) == 0 {
					cancel()
					break
				}

				current = logCommit{}
			}
		} else if current.hash.IsZero() {
			parts := strings.SplitN(line, ",", 8)
			if len(parts) == 8 {
				current.hash = plumbing.NewHash(parts[0])

				// NOTE: this is copy-paste of [newCommit]
				current.Hash = parts[0]
				current.HashShort = parts[0][:7]
				current.Authored, _ = time.Parse("2006-01-02 15:04:05 -0700", parts[1])
				current.AuthorEmail = parts[2]
				current.AuthorName = parts[3]
				current.Committed, _ = time.Parse("2006-01-02 15:04:05 -0700", parts[4])
				current.CommitterEmail = parts[5]
				current.CommitterName = parts[6]
				current.Message = parts[7]
			}
		} else {
			// all ancestors along this path should also be included
			file := path.Clean(line)
			ancestors := ancestors(file)
			current.files = append(current.files, file)
			current.files = append(current.files, ancestors...)
		}

		if err == io.EOF {
			break
		}
	}

	return filesDone, nil
}

func ancestors(p string) []string {
	var ancestors []string
	for {
		p = path.Dir(p)
		if p == "." || p == "/" {
			break
		}
		ancestors = append(ancestors, p)
	}
	return ancestors
}
