package gitx

import (
	"context"
	"fmt"

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

	"go.bigb.es/sourcehut-compare/core"
)

// Patch is a rendered unified diff. Truncated is set when the generated text
// exceeded the byte cap and was cut at a file boundary; callers should then
// fall back to the file list plus a link to the raw .patch.
type Patch struct {
	Text      string
	Truncated bool
}

// FileChange is one entry in a diff's file list. Status is the single-letter
// git class: A(dded), M(odified), D(eleted), R(enamed). (go-git's tree diff
// does not surface copy or type-change classes, so C and T never appear.)
// OldPath is set only for renames. Binary is true when the change is binary, in
// which case Additions and Deletions are zero.
type FileChange struct {
	Path      string
	OldPath   string
	Status    string
	Additions int
	Deletions int
	Binary    bool
}

// validateSpec checks both endpoints of a compare spec.
func validateSpec(spec core.CompareSpec) error {
	if !core.ValidRef(spec.Base) {
		return fmt.Errorf("%w: invalid base %q", core.ErrBadRef, spec.Base)
	}
	if !core.ValidRef(spec.Head) {
		return fmt.Errorf("%w: invalid head %q", core.ErrBadRef, spec.Head)
	}
	return nil
}

// specChanges resolves a compare spec to the ordered tree changes. For a
// three-dot spec the old side is the merge base of base and head; for two-dot
// it is base directly. The new side is always head.
func (r *Repo) specChanges(ctx context.Context, spec core.CompareSpec) (object.Changes, error) {
	if err := validateSpec(spec); err != nil {
		return nil, err
	}
	baseCommit, err := r.resolveCommit(spec.Base)
	if err != nil {
		return nil, err
	}
	headCommit, err := r.resolveCommit(spec.Head)
	if err != nil {
		return nil, err
	}

	oldCommit := baseCommit
	if spec.ThreeDot {
		bases, err := baseCommit.MergeBase(headCommit)
		if err != nil {
			return nil, err
		}
		if len(bases) == 0 {
			return nil, fmt.Errorf("%w: no merge base for %s...%s", core.ErrBadRef, spec.Base, spec.Head)
		}
		oldCommit = bases[0]
	}

	oldTree, err := oldCommit.Tree()
	if err != nil {
		return nil, err
	}
	newTree, err := headCommit.Tree()
	if err != nil {
		return nil, err
	}
	return object.DiffTreeWithOptions(ctx, oldTree, newTree, diffOpts)
}

// Diff renders the unified diff for a compare spec, capped at the in-page byte
// limit (Truncated set and the text cut at a file boundary on overflow).
func (r *Repo) Diff(ctx context.Context, spec core.CompareSpec) (*Patch, error) {
	return r.diffPatch(ctx, spec, r.diffLimit(pageDiffLimit))
}

// RawDiff is Diff with the larger .patch-download byte cap.
func (r *Repo) RawDiff(ctx context.Context, spec core.CompareSpec) (*Patch, error) {
	return r.diffPatch(ctx, spec, r.diffLimit(rawDiffLimit))
}

func (r *Repo) diffPatch(ctx context.Context, spec core.CompareSpec, limit int64) (*Patch, error) {
	ctx, cancel := r.withTimeout(ctx)
	defer cancel()

	changes, err := r.specChanges(ctx, spec)
	if err != nil {
		return nil, err
	}
	return renderPatch(ctx, changes, limit)
}

// DiffStat returns the per-file change list for a compare spec.
func (r *Repo) DiffStat(ctx context.Context, spec core.CompareSpec) ([]FileChange, error) {
	ctx, cancel := r.withTimeout(ctx)
	defer cancel()

	changes, err := r.specChanges(ctx, spec)
	if err != nil {
		return nil, err
	}
	patch, err := changes.PatchContext(ctx)
	if err != nil {
		return nil, err
	}
	return mapFilePatches(patch.FilePatches()), nil
}

// MergeBase returns the SHA of the best common ancestor of two revisions.
func (r *Repo) MergeBase(ctx context.Context, a, b string) (string, error) {
	_, cancel := r.withTimeout(ctx)
	defer cancel()

	ca, err := r.resolveCommit(a)
	if err != nil {
		return "", err
	}
	cb, err := r.resolveCommit(b)
	if err != nil {
		return "", err
	}
	bases, err := ca.MergeBase(cb)
	if err != nil {
		return "", err
	}
	if len(bases) == 0 {
		return "", fmt.Errorf("%w: no merge base for %s and %s", core.ErrNotFound, a, b)
	}
	return bases[0].Hash.String(), nil
}

// CommitPatch renders a single commit as a diff plus its file list and
// metadata. A non-merge commit is diffed against its parent (a root commit
// against the empty tree). A merge commit is diffed against its first parent —
// the conventional, reviewable single-parent view; callers detect the merge via
// len(CommitInfo.ParentSHAs) > 1 to show a banner.
func (r *Repo) CommitPatch(ctx context.Context, rev string) (*Patch, []FileChange, *CommitInfo, error) {
	ctx, cancel := r.withTimeout(ctx)
	defer cancel()

	c, err := r.resolveCommit(rev)
	if err != nil {
		return nil, nil, nil, err
	}

	var oldTree *object.Tree
	if c.NumParents() > 0 {
		parent, err := c.Parent(0)
		if err != nil {
			return nil, nil, nil, err
		}
		oldTree, err = parent.Tree()
		if err != nil {
			return nil, nil, nil, err
		}
	}
	newTree, err := c.Tree()
	if err != nil {
		return nil, nil, nil, err
	}

	changes, err := object.DiffTreeWithOptions(ctx, oldTree, newTree, diffOpts)
	if err != nil {
		return nil, nil, nil, err
	}
	patch, err := changes.PatchContext(ctx)
	if err != nil {
		return nil, nil, nil, err
	}
	text, truncated := cutPatch(patch.String(), r.diffLimit(pageDiffLimit))
	files := mapFilePatches(patch.FilePatches())
	return &Patch{Text: text, Truncated: truncated}, files, commitInfo(c), nil
}

// renderPatch turns tree changes into a size-capped Patch.
func renderPatch(ctx context.Context, changes object.Changes, limit int64) (*Patch, error) {
	patch, err := changes.PatchContext(ctx)
	if err != nil {
		return nil, err
	}
	text, truncated := cutPatch(patch.String(), limit)
	return &Patch{Text: text, Truncated: truncated}, nil
}

// mapFilePatches converts go-git file patches into the package's FileChange
// list. It is a pure function of the FilePatch slice so it can be unit-tested
// with synthetic patches. Status is derived from the from/to file pair; line
// counts are tallied from the chunks (skipped for binary files).
func mapFilePatches(fps []diff.FilePatch) []FileChange {
	var out []FileChange
	for _, fp := range fps {
		from, to := fp.Files()
		if from == nil && to == nil {
			continue
		}
		fc := FileChange{Binary: fp.IsBinary()}
		switch {
		case from == nil:
			fc.Status = "A"
			fc.Path = to.Path()
		case to == nil:
			fc.Status = "D"
			fc.Path = from.Path()
		case from.Path() != to.Path():
			fc.Status = "R"
			fc.OldPath = from.Path()
			fc.Path = to.Path()
		default:
			fc.Status = "M"
			fc.Path = from.Path()
		}
		if !fc.Binary {
			for _, ch := range fp.Chunks() {
				s := ch.Content()
				if s == "" {
					continue
				}
				n := countLines(s)
				switch ch.Type() {
				case diff.Add:
					fc.Additions += n
				case diff.Delete:
					fc.Deletions += n
				}
			}
		}
		out = append(out, fc)
	}
	return out
}

// countLines counts the lines in a chunk's content, counting a final
// unterminated line as one line (matching git's stat behaviour).
func countLines(s string) int {
	n := 0
	for i := 0; i < len(s); i++ {
		if s[i] == '\n' {
			n++
		}
	}
	if len(s) > 0 && s[len(s)-1] != '\n' {
		n++
	}
	return n
}
