package repository

import (
	"bytes"
	"errors"
	"io"
	"sort"
	"strings"
	"unicode/utf8"

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

var (
	ErrNotTree         = errors.New("path is not a tree")
	ErrNotFile         = errors.New("path is not a file")
	ErrEmptyRepository = errors.New("repository has no commits")
)

var readmeNames = []string{
	"README.md",
	"README.txt",
	"README",
	"Readme.md",
}

type Repository struct {
	name string
	path string
	repo *git.Repository
}

type ResolvedRevision struct {
	Name   string
	Hash   plumbing.Hash
	Commit *object.Commit
}

type TreeEntry struct {
	Name  string
	Path  string
	IsDir bool
	Mode  filemode.FileMode
}

type Readme struct {
	Name    string
	Content string
}

type Blob struct {
	Name        string
	Path        string
	Content     []byte
	ContentType string
	Text        bool
}

type CommitPage struct {
	Commits []*object.Commit
	HasMore bool
}

type Branch struct {
	Name    string
	Hash    plumbing.Hash
	Current bool
}

func Open(name, path string) (*Repository, error) {
	repo, err := git.PlainOpen(path)
	if err != nil {
		return nil, err
	}

	return &Repository{name: name, path: path, repo: repo}, nil
}

func (r *Repository) Name() string {
	return r.name
}

func (r *Repository) DefaultRevision() (*ResolvedRevision, error) {
	head, err := r.repo.Head()
	if err != nil {
		if errors.Is(err, plumbing.ErrReferenceNotFound) {
			return nil, ErrEmptyRepository
		}
		return nil, err
	}

	name := shortReferenceName(head)
	commit, err := r.repo.CommitObject(head.Hash())
	if err != nil {
		return nil, err
	}

	return &ResolvedRevision{Name: name, Hash: head.Hash(), Commit: commit}, nil
}

func (r *Repository) ResolveRevision(spec string) (*ResolvedRevision, error) {
	spec = strings.TrimSpace(spec)
	if spec == "" || spec == "HEAD" {
		return r.DefaultRevision()
	}

	hash, err := r.repo.ResolveRevision(plumbing.Revision(spec))
	if err != nil {
		if errors.Is(err, plumbing.ErrReferenceNotFound) {
			return nil, ErrEmptyRepository
		}
		return nil, err
	}

	commit, err := r.repo.CommitObject(*hash)
	if err != nil {
		return nil, err
	}

	return &ResolvedRevision{Name: spec, Hash: *hash, Commit: commit}, nil
}

func (r *Repository) Tree(commit *object.Commit, treePath string) (*object.Tree, error) {
	tree, err := commit.Tree()
	if err != nil {
		return nil, err
	}
	if treePath == "" {
		return tree, nil
	}

	subtree, err := tree.Tree(treePath)
	if err != nil {
		if errors.Is(err, object.ErrDirectoryNotFound) || errors.Is(err, object.ErrEntryNotFound) {
			return nil, ErrNotTree
		}
		return nil, err
	}

	return subtree, nil
}

func (r *Repository) TreeEntries(tree *object.Tree, treePath string) []TreeEntry {
	entries := make([]TreeEntry, 0, len(tree.Entries))
	for _, entry := range tree.Entries {
		entryPath := joinGitPath(treePath, entry.Name)
		entries = append(entries, TreeEntry{
			Name:  entry.Name,
			Path:  entryPath,
			IsDir: entry.Mode == filemode.Dir,
			Mode:  entry.Mode,
		})
	}

	sort.Slice(entries, func(i, j int) bool {
		if entries[i].IsDir != entries[j].IsDir {
			return entries[i].IsDir
		}
		return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
	})

	return entries
}

func (r *Repository) Readme(tree *object.Tree) (*Readme, error) {
	for _, name := range readmeNames {
		file, err := tree.File(name)
		if err != nil {
			if errors.Is(err, object.ErrFileNotFound) {
				continue
			}
			return nil, err
		}

		content, err := readAll(file)
		if err != nil {
			return nil, err
		}

		return &Readme{Name: name, Content: string(content)}, nil
	}

	return nil, nil
}

func (r *Repository) Blob(commit *object.Commit, filePath string) (*Blob, error) {
	tree, err := commit.Tree()
	if err != nil {
		return nil, err
	}

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

	content, err := readAll(file)
	if err != nil {
		return nil, err
	}

	contentType := detectContentType(content)
	return &Blob{
		Name:        file.Name,
		Path:        filePath,
		Content:     content,
		ContentType: contentType,
		Text:        isTextContent(content, contentType),
	}, nil
}

func (r *Repository) Log(commit *object.Commit, filePath string, page, perPage int) (*CommitPage, error) {
	options := &git.LogOptions{
		From:  commit.Hash,
		Order: git.LogOrderCommitterTime,
	}
	if filePath != "" {
		options.FileName = &filePath
	}

	iter, err := r.repo.Log(options)
	if err != nil {
		return nil, err
	}
	defer iter.Close()

	skip := page * perPage
	limit := skip + perPage + 1
	items := make([]*object.Commit, 0, perPage+1)
	index := 0

	for {
		next, err := iter.Next()
		if err != nil {
			if errors.Is(err, io.EOF) {
				break
			}
			return nil, err
		}

		if index < skip {
			index++
			continue
		}
		if len(items) >= limit-skip {
			break
		}

		items = append(items, next)
		index++
	}

	hasMore := len(items) > perPage
	if hasMore {
		items = items[:perPage]
	}

	return &CommitPage{Commits: items, HasMore: hasMore}, nil
}

func (r *Repository) Branches() ([]Branch, error) {
	head, err := r.repo.Head()
	if err != nil {
		if !errors.Is(err, plumbing.ErrReferenceNotFound) {
			return nil, err
		}
		head = nil
	}

	iter, err := r.repo.Branches()
	if err != nil {
		return nil, err
	}
	defer iter.Close()

	var branches []Branch
	err = iter.ForEach(func(ref *plumbing.Reference) error {
		current := false
		if head != nil {
			current = head.Name() == ref.Name()
		}
		branches = append(branches, Branch{
			Name:    ref.Name().Short(),
			Hash:    ref.Hash(),
			Current: current,
		})
		return nil
	})
	if err != nil {
		return nil, err
	}

	sort.Slice(branches, func(i, j int) bool {
		return strings.ToLower(branches[i].Name) < strings.ToLower(branches[j].Name)
	})

	return branches, nil
}

func shortReferenceName(ref *plumbing.Reference) string {
	if ref.Name().IsBranch() || ref.Name().IsTag() {
		return ref.Name().Short()
	}
	if ref.Type() == plumbing.SymbolicReference {
		return ref.Target().Short()
	}
	return shortHash(ref.Hash().String())
}

func shortHash(hash string) string {
	if len(hash) <= 7 {
		return hash
	}
	return hash[:7]
}

func joinGitPath(parent, name string) string {
	if parent == "" {
		return name
	}
	return parent + "/" + name
}

func readAll(file *object.File) ([]byte, error) {
	reader, err := file.Reader()
	if err != nil {
		return nil, err
	}
	defer reader.Close()

	return io.ReadAll(reader)
}

func detectContentType(content []byte) string {
	if len(content) == 0 {
		return "text/plain; charset=utf-8"
	}
	sniff := content
	if len(sniff) > 512 {
		sniff = sniff[:512]
	}
	return httpDetectContentType(sniff)
}

func isTextContent(content []byte, contentType string) bool {
	if !utf8.Valid(content) {
		return false
	}
	if bytes.IndexByte(content, 0) >= 0 {
		return false
	}

	if strings.HasPrefix(contentType, "text/") {
		return true
	}

	switch {
	case strings.Contains(contentType, "json"),
		strings.Contains(contentType, "xml"),
		strings.Contains(contentType, "javascript"),
		strings.Contains(contentType, "svg+xml"),
		strings.Contains(contentType, "x-sh"):
		return true
	default:
		return false
	}
}