// Package gitx is the git access layer of compare.sr.ht. It opens bare
// repositories on disk with go-git and answers ref, log, and diff queries
// entirely in-process (no git binary is executed at runtime). Every operation
// is bounded by a context timeout, generated patch text is capped in size (so
// a pathological diff cannot be streamed unbounded to a browser), and every
// user-controlled revision is validated with core.ValidRef before it reaches
// go-git's revision parser.
package gitx

import (
	"context"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

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

	"sourcecraft.dev/bigbes/sr-ht-compare/core"
)

const (
	// defaultTimeout bounds a single gitx operation.
	defaultTimeout = 10 * time.Second
	// pageDiffLimit caps an in-page rendered diff; overflow sets Truncated.
	pageDiffLimit = 5 << 20 // 5 MiB
	// rawDiffLimit caps a downloadable .patch.
	rawDiffLimit = 50 << 20 // 50 MiB
	// shortSHALen is how many hex chars an abbreviated SHA carries.
	shortSHALen = 8
)

// diffOpts are the go-git tree-diff options used everywhere: rename detection
// on, matching git's default behaviour.
var diffOpts = object.DefaultDiffTreeOptions

// Repo is a handle to a bare git repository on disk. It is safe for concurrent
// use: go-git's object reads are read-only and the struct holds no mutable
// per-request state.
type Repo struct {
	dir  string
	repo *git.Repository

	// limitOverride, when > 0, replaces the diff-family byte cap. It exists so
	// tests can exercise truncation with a tiny cap instead of huge fixtures.
	limitOverride int64

	// timeout overrides defaultTimeout when > 0 (tests may shorten it).
	timeout time.Duration
}

// Open validates owner/name, resolves the bare-repo path under reposRoot,
// verifies it looks like a bare repository (its HEAD file exists), and opens it
// with go-git. Invalid names and missing/broken repositories all yield
// core.ErrNotFound, so repository existence is never leaked and no crafted name
// can escape reposRoot: the validators reject '/', '..' and a leading '-'
// before any path is built.
func Open(reposRoot, owner, name string) (*Repo, error) {
	if !core.ValidOwner(owner) {
		return nil, fmt.Errorf("%w: invalid owner", core.ErrNotFound)
	}
	if !core.ValidRepoName(name) {
		return nil, fmt.Errorf("%w: invalid repo name", core.ErrNotFound)
	}

	dir := filepath.Join(reposRoot, "~"+owner, name)

	// Cheap bare-repo sanity check before handing the path to go-git.
	if fi, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || fi.IsDir() {
		return nil, fmt.Errorf("%w: ~%s/%s", core.ErrNotFound, owner, name)
	}

	repo, err := git.PlainOpen(dir)
	if err != nil {
		return nil, fmt.Errorf("%w: ~%s/%s: %v", core.ErrNotFound, owner, name, err)
	}
	return &Repo{dir: dir, repo: repo}, nil
}

// Dir returns the on-disk path of the bare repository.
func (r *Repo) Dir() string { return r.dir }

// withTimeout derives a per-operation timeout context.
func (r *Repo) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
	d := r.timeout
	if d <= 0 {
		d = defaultTimeout
	}
	return context.WithTimeout(ctx, d)
}

// diffLimit returns the effective byte cap for a diff-family command, honoring
// a test override.
func (r *Repo) diffLimit(def int64) int64 {
	if r.limitOverride > 0 {
		return r.limitOverride
	}
	return def
}

// badRef wraps a go-git revision-resolution failure as core.ErrBadRef.
func badRef(rev string, err error) error {
	return fmt.Errorf("%w: %q: %v", core.ErrBadRef, rev, err)
}

// commitInfo projects a go-git commit into the package's CommitInfo.
func commitInfo(c *object.Commit) *CommitInfo {
	subject, body := splitMessage(c.Message)
	sha := c.Hash.String()
	short := sha
	if len(short) > shortSHALen {
		short = short[:shortSHALen]
	}
	ci := &CommitInfo{
		SHA:         sha,
		ShortSHA:    short,
		AuthorName:  c.Author.Name,
		AuthorEmail: c.Author.Email,
		Date:        c.Author.When,
		Subject:     subject,
		Body:        body,
	}
	for _, p := range c.ParentHashes {
		ci.ParentSHAs = append(ci.ParentSHAs, p.String())
	}
	return ci
}

// splitMessage splits a commit message into its subject (first line) and body
// (the remainder, with the separating blank line removed).
func splitMessage(msg string) (subject, body string) {
	msg = strings.TrimRight(msg, "\n")
	if i := strings.IndexByte(msg, '\n'); i >= 0 {
		return msg[:i], strings.TrimLeft(msg[i+1:], "\n")
	}
	return msg, ""
}

// cutPatch enforces the size cap on generated patch text. go-git materializes
// the whole patch in memory, so the cap is applied after generation. To keep
// the browser-side parser from ever seeing a torn hunk, the text is cut at a
// file boundary: the last "\ndiff --git " that starts at or before the limit.
// If a single leading file already exceeds the limit there is no earlier
// boundary and the text is cut hard at the limit.
func cutPatch(text string, limit int64) (string, bool) {
	if limit <= 0 || int64(len(text)) <= limit {
		return text, false
	}
	head := text[:limit]
	// A unified-diff file header is the only place "diff --git" starts a line;
	// content lines always carry a +/-/space prefix after the newline. Cutting
	// before the last such header keeps only whole files.
	if idx := strings.LastIndex(head, "\ndiff --git"); idx >= 0 {
		return text[:idx+1], true
	}
	return head, true
}
