// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package gitrepo

import (
	"bufio"
	"bytes"
	"context"
	"io"

	"gitea.dev/modules/git"
	"gitea.dev/modules/git/gitcmd"
	"gitea.dev/modules/setting"
)

func LineBlame(ctx context.Context, repo Repository, revision, file string, line uint) (string, error) {
	stdout, _, err := RunCmdString(ctx, repo,
		gitcmd.NewCommand("blame").
			AddOptionFormat("-L %d,%d", line, line).
			AddOptionValues("-p", revision).
			AddDashesAndList(file))
	return stdout, err
}

// BlamePart represents block of blame - continuous lines with one sha
type BlamePart struct {
	Sha          string
	Lines        []string
	PreviousSha  string
	PreviousPath string
}

// BlameReader returns part of file blame one by one
type BlameReader struct {
	bufferedReader *bufio.Reader
	done           chan error
	lastSha        *string
	ignoreRevsFile string
	objectFormat   git.ObjectFormat
	cleanupFuncs   []func()
}

func (r *BlameReader) UsesIgnoreRevs() bool {
	return r.ignoreRevsFile != ""
}

// NextPart returns next part of blame (sequential code lines with the same commit)
func (r *BlameReader) NextPart() (*BlamePart, error) {
	var blamePart *BlamePart

	if r.lastSha != nil {
		blamePart = &BlamePart{
			Sha:   *r.lastSha,
			Lines: make([]string, 0),
		}
	}

	const previousHeader = "previous "
	var lineBytes []byte
	var isPrefix bool
	var err error

	for err != io.EOF {
		lineBytes, isPrefix, err = r.bufferedReader.ReadLine()
		if err != nil && err != io.EOF {
			return blamePart, err
		}

		if len(lineBytes) == 0 {
			// isPrefix will be false
			continue
		}

		var objectID string
		objectFormatLength := r.objectFormat.FullLength()

		if len(lineBytes) > objectFormatLength && lineBytes[objectFormatLength] == ' ' && r.objectFormat.IsValid(string(lineBytes[0:objectFormatLength])) {
			objectID = string(lineBytes[0:objectFormatLength])
		}
		if len(objectID) > 0 {
			if blamePart == nil {
				blamePart = &BlamePart{
					Sha:   objectID,
					Lines: make([]string, 0),
				}
			}

			if blamePart.Sha != objectID {
				r.lastSha = &objectID
				// need to munch to end of line...
				for isPrefix {
					_, isPrefix, err = r.bufferedReader.ReadLine()
					if err != nil && err != io.EOF {
						return blamePart, err
					}
				}
				return blamePart, nil
			}
		} else if lineBytes[0] == '\t' {
			blamePart.Lines = append(blamePart.Lines, string(lineBytes[1:]))
		} else if bytes.HasPrefix(lineBytes, []byte(previousHeader)) {
			offset := len(previousHeader) // already includes a space
			blamePart.PreviousSha = string(lineBytes[offset : offset+objectFormatLength])
			offset += objectFormatLength + 1 // +1 for space
			blamePart.PreviousPath = string(lineBytes[offset:])
		}

		// need to munch to end of line...
		for isPrefix {
			_, isPrefix, err = r.bufferedReader.ReadLine()
			if err != nil && err != io.EOF {
				return blamePart, err
			}
		}
	}

	r.lastSha = nil

	return blamePart, nil
}

// Close BlameReader - don't run NextPart after invoking that
func (r *BlameReader) Close() error {
	if r.bufferedReader == nil {
		return nil
	}

	err := <-r.done
	r.bufferedReader = nil
	r.cleanup()
	return err
}

func (r *BlameReader) cleanup() {
	for _, cleanup := range r.cleanupFuncs {
		cleanup()
	}
}

// CreateBlameReader creates reader for given repository, commit and file
func CreateBlameReader(ctx context.Context, objectFormat git.ObjectFormat, repo Repository, commit *git.Commit, file string, bypassBlameIgnore bool) (rd *BlameReader, retErr error) {
	defer func() {
		if retErr != nil {
			rd.cleanup()
		}
	}()

	rd = &BlameReader{
		done:         make(chan error, 1),
		objectFormat: objectFormat,
	}

	cmd := gitcmd.NewCommand("blame", "--porcelain")

	stdoutReader, stdoutReaderClose := cmd.MakeStdoutPipe()
	rd.bufferedReader = bufio.NewReader(stdoutReader)
	rd.cleanupFuncs = append(rd.cleanupFuncs, stdoutReaderClose)

	if git.DefaultFeatures().CheckVersionAtLeast("2.23") && !bypassBlameIgnore {
		ignoreRevsFileName, ignoreRevsFileCleanup, err := tryCreateBlameIgnoreRevsFile(commit)
		if err != nil && !git.IsErrNotExist(err) {
			return nil, err
		} else if err == nil {
			rd.ignoreRevsFile = ignoreRevsFileName
			rd.cleanupFuncs = append(rd.cleanupFuncs, ignoreRevsFileCleanup)
			// Possible improvement: use --ignore-revs-file /dev/stdin on unix
			// There is no equivalent on Windows. May be implemented if Gitea uses an external git backend.
			cmd.AddOptionValues("--ignore-revs-file", ignoreRevsFileName)
		}
	}

	cmd.AddDynamicArguments(commit.ID.String()).AddDashesAndList(file)

	go func() {
		// TODO: it doesn't work for directories (the directories shouldn't be "blamed"), and the "err" should be returned by "Read" but not by "Close"
		rd.done <- RunCmdWithStderr(ctx, repo, cmd)
	}()

	return rd, nil
}

func tryCreateBlameIgnoreRevsFile(commit *git.Commit) (string, func(), error) {
	entry, err := commit.GetTreeEntryByPath(".git-blame-ignore-revs")
	if err != nil {
		return "", nil, err
	}

	r, err := entry.Blob().DataAsync()
	if err != nil {
		return "", nil, err
	}
	defer r.Close()

	f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("git-blame-ignore-revs")
	if err != nil {
		return "", nil, err
	}
	filename := f.Name()
	_, err = io.Copy(f, r)
	_ = f.Close()
	if err != nil {
		cleanup()
		return "", nil, err
	}

	return filename, cleanup, nil
}
