package git

import (
	"archive/tar"
	"bytes"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"path"
	"sort"
	"time"

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

var ()

var (
	MissingRefErr  = errors.New("Reference not found")
	TreeForFileErr = errors.New("Trying to get tree of a file")
)

type (
	GitRepository struct {
		path       string
		repository *git.Repository
		ref        plumbing.Hash
		setRef     bool
	}
	TagReference struct {
		ref *plumbing.Reference
		tag *object.Tag
	}
	infoWrapper struct {
		name    string
		size    int64
		mode    fs.FileMode
		modTime time.Time
		isDir   bool
	}
	tagList struct {
		refs []*TagReference
		r    *git.Repository
	}
)

func OpenRepository(dir string) (*GitRepository, error) {
	g := &GitRepository{
		path: dir,
	}

	repo, err := git.PlainOpen(dir)
	if err != nil {
		return nil, err
	}
	g.repository = repo

	return g, nil
}

func (g *GitRepository) SetRef(ref string) error {
	if ref == "" {
		head, err := g.repository.Head()
		if err != nil {
			return errors.Join(MissingRefErr, err)
		}
		g.ref = head.Hash()
	} else {
		hash, err := g.repository.ResolveRevision(plumbing.Revision(ref))
		if err != nil {
			return errors.Join(MissingRefErr, err)
		}
		g.ref = *hash
	}
	g.setRef = true
	return nil
}

func (g *GitRepository) Path() string {
	return g.path
}

func (g *GitRepository) LastCommit() (*object.Commit, error) {
	err := g.validateRef()
	if err != nil {
		return nil, err
	}

	c, err := g.repository.CommitObject(g.ref)
	if err != nil {
		return nil, err
	}
	return c, nil
}

func (g *GitRepository) Commits(count int) ([]*object.Commit, error) {
	err := g.validateRef()
	if err != nil {
		return nil, err
	}

	ci, err := g.repository.Log(&git.LogOptions{From: g.ref})
	if err != nil {
		return nil, fmt.Errorf("commits from ref: %w", err)
	}

	commits := []*object.Commit{}
	// TODO: for now only load first 1000
	for x := 0; x < count; x++ {
		c, err := ci.Next()
		if err != nil && errors.Is(err, io.EOF) {
			break
		} else if err != nil {
			return nil, err
		}
		commits = append(commits, c)
	}

	return commits, nil
}

func (g *GitRepository) Head() (*plumbing.Reference, error) {
	return g.repository.Head()
}

func (g *GitRepository) Tags() ([]*TagReference, error) {
	iter, err := g.repository.Tags()
	if err != nil {
		return nil, err
	}

	tags := make([]*TagReference, 0)

	if err := iter.ForEach(func(ref *plumbing.Reference) error {
		obj, err := g.repository.TagObject(ref.Hash())
		switch err {
		case nil:
			tags = append(tags, &TagReference{
				ref: ref,
				tag: obj,
			})
		case plumbing.ErrObjectNotFound:
			tags = append(tags, &TagReference{
				ref: ref,
			})
		default:
			return err
		}
		return nil
	}); err != nil {
		return nil, err
	}

	// tagList modify the underlying tag list.
	tagList := &tagList{r: g.repository, refs: tags}
	sort.Sort(tagList)

	return tags, nil
}

func (g *GitRepository) Branches() ([]*plumbing.Reference, error) {
	bs, err := g.repository.Branches()
	if err != nil {
		return nil, err
	}

	branches := []*plumbing.Reference{}
	err = bs.ForEach(func(ref *plumbing.Reference) error {
		branches = append(branches, ref)
		return nil
	})
	if err != nil {
		return nil, err
	}

	return branches, nil
}

func (g *GitRepository) Tree(path string) (*object.Tree, error) {
	err := g.validateRef()
	if err != nil {
		return nil, err
	}

	c, err := g.repository.CommitObject(g.ref)
	if err != nil {
		return nil, err
	}

	tree, err := c.Tree()
	if err != nil {
		return nil, err
	}

	if path == "" {
		return tree, nil
	} 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
			}
			return subtree, nil
		} else {
			return nil, TreeForFileErr
		}
	}
}

func (g *GitRepository) validateRef() error {
	if !g.setRef {
		return g.SetRef("")
	}
	return nil
}

func (g *GitRepository) IsBinary(path string) (bool, error) {
	tree, err := g.Tree("")
	if err != nil {
		return false, err
	}

	file, err := tree.File(path)
	if err != nil {
		return false, err
	}

	return file.IsBinary()
}

func (g *GitRepository) FileContent(path string) ([]byte, error) {
	err := g.validateRef()
	if err != nil {
		return nil, err
	}

	c, err := g.repository.CommitObject(g.ref)
	if err != nil {
		return nil, err
	}

	tree, err := c.Tree()
	if err != nil {
		return nil, err
	}

	file, err := tree.File(path)
	if err != nil {
		return nil, err
	}

	r, err := file.Blob.Reader()
	if err != nil {
		return nil, err
	}
	defer r.Close()

	var buf bytes.Buffer
	_, err = io.Copy(&buf, r)
	if err != nil {
		return nil, err
	}

	return buf.Bytes(), nil
}

func (g *GitRepository) WriteTar(w io.Writer, prefix string) error {
	tw := tar.NewWriter(w)
	defer tw.Close()

	tree, err := g.Tree("")
	if err != nil {
		return err
	}

	walker := object.NewTreeWalker(tree, true, nil)
	defer walker.Close()

	name, entry, err := walker.Next()
	for ; err == nil; name, entry, err = walker.Next() {
		info, err := newInfoWrapper(name, prefix, &entry, tree)
		if err != nil {
			return err
		}

		header, err := tar.FileInfoHeader(info, "")
		if err != nil {
			return err
		}

		err = tw.WriteHeader(header)
		if err != nil {
			return err
		}

		if !info.IsDir() {
			file, err := tree.File(name)
			if err != nil {
				return err
			}

			reader, err := file.Blob.Reader()
			if err != nil {
				return err
			}

			_, err = io.Copy(tw, reader)
			if err != nil {
				reader.Close()
				return err
			}
			reader.Close()
		}
	}

	return nil
}

func newInfoWrapper(
	filename string,
	prefix string,
	entry *object.TreeEntry,
	tree *object.Tree,
) (*infoWrapper, error) {
	var (
		size  int64
		mode  fs.FileMode
		isDir bool
	)

	if entry.Mode.IsFile() {
		file, err := tree.TreeEntryFile(entry)
		if err != nil {
			return nil, err
		}
		mode = fs.FileMode(file.Mode)

		size, err = tree.Size(filename)
		if err != nil {
			return nil, err
		}
	} else {
		isDir = true
		mode = fs.ModeDir | fs.ModePerm
	}

	fullname := path.Join(prefix, filename)
	return &infoWrapper{
		name:    fullname,
		size:    size,
		mode:    mode,
		modTime: time.Unix(0, 0),
		isDir:   isDir,
	}, nil
}

func (i *infoWrapper) Name() string {
	return i.name
}

func (i *infoWrapper) Size() int64 {
	return i.size
}

func (i *infoWrapper) Mode() fs.FileMode {
	return i.mode
}

func (i *infoWrapper) ModTime() time.Time {
	return i.modTime
}

func (i *infoWrapper) IsDir() bool {
	return i.isDir
}

func (i *infoWrapper) Sys() any {
	return nil
}

func (t *TagReference) HashString() string {
	return t.ref.Hash().String()
}

func (t *TagReference) ShortName() string {
	return t.ref.Name().Short()
}

func (t *TagReference) Message() string {
	if t.tag != nil {
		return t.tag.Message
	}
	return ""

}

func (self *tagList) Len() int {
	return len(self.refs)
}

func (self *tagList) Swap(i, j int) {
	self.refs[i], self.refs[j] = self.refs[j], self.refs[i]
}

func (self *tagList) Less(i, j int) bool {
	var dateI time.Time
	var dateJ time.Time

	if self.refs[i].tag != nil {
		dateI = self.refs[i].tag.Tagger.When
	} else {
		c, err := self.r.CommitObject(self.refs[i].ref.Hash())
		if err != nil {
			dateI = time.Now()
		} else {
			dateI = c.Committer.When
		}
	}

	if self.refs[j].tag != nil {
		dateJ = self.refs[j].tag.Tagger.When
	} else {
		c, err := self.r.CommitObject(self.refs[j].ref.Hash())
		if err != nil {
			dateJ = time.Now()
		} else {
			dateJ = c.Committer.When
		}
	}

	return dateI.After(dateJ)
}