package git

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

	"github.com/go-git/go-git/v5"
	gogit "github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/config"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/go-git/go-git/v5/plumbing/object"
	"tangled.org/core/knotserver/sandbox"
)

var (
	ErrBinaryFile        = errors.New("binary file")
	ErrNotBinaryFile     = errors.New("not binary file")
	ErrMissingGitModules = errors.New("no .gitmodules file found")
	ErrInvalidGitModules = errors.New("invalid .gitmodules file")
	ErrNotSubmodule      = errors.New("path is not a submodule")
)

type GitRepo struct {
	path    string
	r       *git.Repository
	h       plumbing.Hash
	sandbox sandbox.Backend
}

// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
// to tar WriteHeader
type infoWrapper struct {
	name    string
	size    int64
	mode    fs.FileMode
	modTime time.Time
	isDir   bool
}

func Open(path string, ref string) (*GitRepo, error) {
	var err error
	g := GitRepo{path: path}
	g.r, err = git.PlainOpen(path)
	if err != nil {
		return nil, fmt.Errorf("opening %s: %w", path, err)
	}

	if ref == "" {
		head, err := g.r.Head()
		if err != nil {
			return nil, fmt.Errorf("getting head of %s: %w", path, err)
		}
		g.h = head.Hash()
	} else {
		hash, err := g.r.ResolveRevision(plumbing.Revision(ref))
		if err != nil {
			return nil, fmt.Errorf("resolving rev %s for %s: %w", ref, path, err)
		}
		g.h = *hash
	}
	return &g, nil
}

func PlainOpen(path string) (*GitRepo, error) {
	var err error
	g := GitRepo{path: path}
	g.r, err = git.PlainOpen(path)
	if err != nil {
		return nil, fmt.Errorf("opening %s: %w", path, err)
	}
	return &g, nil
}

// WithSandbox returns a copy of the GitRepo that uses the given sandbox
// backend for all git subprocesses.
func (g *GitRepo) WithSandbox(sb sandbox.Backend) *GitRepo {
	cp := *g
	cp.sandbox = sb
	return &cp
}

func (g *GitRepo) Hash() plumbing.Hash {
	return g.h
}

// re-open a repository and update references
func (g *GitRepo) Refresh() error {
	refreshed, err := Open(g.path, g.Hash().String())
	if err != nil {
		return err
	}

	*g = *refreshed
	return nil
}

func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) {
	commits := []*object.Commit{}

	output, err := g.revList(
		g.h.String(),
		fmt.Sprintf("--skip=%d", offset),
		fmt.Sprintf("--max-count=%d", limit),
	)
	if err != nil {
		return nil, fmt.Errorf("commits from ref: %w", err)
	}

	lines := strings.Split(strings.TrimSpace(string(output)), "\n")
	if len(lines) == 1 && lines[0] == "" {
		return commits, nil
	}

	for _, item := range lines {
		obj, err := g.r.CommitObject(plumbing.NewHash(item))
		if err != nil {
			continue
		}
		commits = append(commits, obj)
	}

	return commits, nil
}

func (g *GitRepo) TotalCommits() (int, error) {
	output, err := g.revList(
		g.h.String(),
		"--count",
	)
	if err != nil {
		return 0, fmt.Errorf("failed to run rev-list: %w", err)
	}

	count, err := strconv.Atoi(strings.TrimSpace(string(output)))
	if err != nil {
		return 0, err
	}

	return count, nil
}

func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) {
	return g.r.CommitObject(h)
}

func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, 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)
	}

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

	isbin, _ := file.IsBinary()
	if isbin {
		return nil, ErrBinaryFile
	}

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

	buf := new(bytes.Buffer)
	if _, err = buf.ReadFrom(io.LimitReader(reader, cap)); err != nil {
		return nil, err
	}

	return buf.Bytes(), nil
}

func (g *GitRepo) RawContent(path string) ([]byte, 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)
	}

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

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

	return io.ReadAll(reader)
}

func (g *GitRepo) File(path string) (*object.File, 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)
	}

	return tree.File(path)
}

// read and parse .gitmodules
func (g *GitRepo) Submodules() (*config.Modules, 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("tree: %w", err)
	}

	// read .gitmodules file
	modulesEntry, err := tree.FindEntry(".gitmodules")
	if err != nil {
		return nil, fmt.Errorf("%w: %w", ErrMissingGitModules, err)
	}

	modulesFile, err := tree.TreeEntryFile(modulesEntry)
	if err != nil {
		return nil, fmt.Errorf("%w: failed to read file: %w", ErrInvalidGitModules, err)
	}

	content, err := modulesFile.Contents()
	if err != nil {
		return nil, fmt.Errorf("%w: failed to read contents: %w", ErrInvalidGitModules, err)
	}

	// parse .gitmodules
	modules := config.NewModules()
	if err = modules.Unmarshal([]byte(content)); err != nil {
		return nil, fmt.Errorf("%w: failed to parse: %w", ErrInvalidGitModules, err)
	}

	return modules, nil
}

func (g *GitRepo) Submodule(path string) (*config.Submodule, error) {
	modules, err := g.Submodules()
	if err != nil {
		return nil, err
	}

	for _, submodule := range modules.Submodules {
		if submodule.Path == path {
			return submodule, nil
		}
	}

	// path is not a submodule
	return nil, ErrNotSubmodule
}

func (g *GitRepo) SetDefaultBranch(branch string) error {
	ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))
	return g.r.Storer.SetReference(ref)
}

func (g *GitRepo) FindMainBranch() (string, error) {
	output, err := g.revParse("--abbrev-ref", "HEAD")
	if err != nil {
		return "", fmt.Errorf("failed to find main branch: %w", err)
	}

	return strings.TrimSpace(string(output)), nil
}

func (g *GitRepo) Remote() (string, error) {
	remote, err := g.r.Remote("origin")
	if errors.Is(err, gogit.ErrRemoteNotFound) {
		return "", nil
	}
	if err != nil {
		return "", err
	}

	if remote == nil {
		return "", nil
	}

	urls := remote.Config().URLs
	if len(urls) == 0 {
		return "", nil
	}

	return urls[0], nil
}

// WriteTar writes itself from a tree into a binary tar file format.
// prefix is root folder to be appended.
func (g *GitRepo) WriteTar(w io.Writer, prefix string) error {
	tw := tar.NewWriter(w)
	defer tw.Close()

	c, err := g.r.CommitObject(g.h)
	if err != nil {
		return fmt.Errorf("commit object: %w", err)
	}

	tree, err := c.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(
	name 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(name)
		if err != nil {
			return nil, err
		}
	} else {
		isDir = true
		mode = fs.ModeDir | fs.ModePerm
	}

	fullname := path.Join(prefix, name)
	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
}
