package utils

import (
	"bytes"
	"errors"
	"fmt"
	"os"
	"slices"
	"strings"

	"github.com/samber/lo"
	"github.com/stefanhaller/git-todo-parser/todo"
)

type Todo struct {
	Hash   string // for todos that have one, e.g. pick, drop, fixup, etc.
	Ref    string // for update-ref todos
	Action todo.TodoCommand
}

// In order to change a TODO in git-rebase-todo, we need to specify the old action,
// because sometimes the same hash appears multiple times in the file (e.g. in a pick
// and later in a merge)
type TodoChange struct {
	Hash      string
	OldAction todo.TodoCommand
	NewAction todo.TodoCommand
}

// Read a git-rebase-todo file, change the actions for the given commits,
// and write it back
func EditRebaseTodo(filePath string, changes []TodoChange, commentChar byte) error {
	todos, err := ReadRebaseTodoFile(filePath, commentChar)
	if err != nil {
		return err
	}

	matchCount := 0
	for i := range todos {
		t := &todos[i]
		// This is a nested loop, but it's ok because the number of todos should be small
		for _, change := range changes {
			if t.Command == change.OldAction && equalHash(t.Commit, change.Hash) {
				matchCount++
				t.Command = change.NewAction
			}
		}
	}

	if matchCount < len(changes) {
		// Should never get here
		return errors.New("Some todos not found in git-rebase-todo")
	}

	return WriteRebaseTodoFile(filePath, todos, commentChar)
}

func equalHash(a, b string) bool {
	return strings.HasPrefix(a, b) || strings.HasPrefix(b, a)
}

func findTodo(todos []todo.Todo, todoToFind Todo) (int, bool) {
	_, idx, ok := lo.FindIndexOf(todos, func(t todo.Todo) bool {
		// Comparing just the hash is not enough; we need to compare both the
		// action and the hash, as the hash could appear multiple times (e.g. in a
		// pick and later in a merge). For update-ref todos we also must compare
		// the Ref.
		return t.Command == todoToFind.Action &&
			equalHash(t.Commit, todoToFind.Hash) &&
			t.Ref == todoToFind.Ref
	})
	return idx, ok
}

func ReadRebaseTodoFile(fileName string, commentChar byte) ([]todo.Todo, error) {
	f, err := os.Open(fileName)
	if err != nil {
		return nil, err
	}

	todos, err := todo.Parse(f, commentChar)
	err2 := f.Close()
	if err == nil {
		err = err2
	}
	return todos, err
}

func WriteRebaseTodoFile(fileName string, todos []todo.Todo, commentChar byte) error {
	f, err := os.Create(fileName)
	if err != nil {
		return err
	}
	err = todo.Write(f, todos, commentChar)
	err2 := f.Close()
	if err == nil {
		err = err2
	}
	return err
}

func todosToString(todos []todo.Todo, commentChar byte) ([]byte, error) {
	buffer := bytes.Buffer{}
	err := todo.Write(&buffer, todos, commentChar)
	return buffer.Bytes(), err
}

func PrependStrToTodoFile(filePath string, linesToPrepend []byte) error {
	existingContent, err := os.ReadFile(filePath)
	if err != nil {
		return err
	}

	linesToPrepend = append(linesToPrepend, existingContent...)
	return os.WriteFile(filePath, linesToPrepend, 0o644)
}

// Unlike the other functions in this file, which write the changed todos file
// back to disk, this one returns the new content as a byte slice. This is
// because when deleting update-ref todos, we must perform a "git rebase
// --edit-todo" command to pass the changed todos to git so that it can do some
// housekeeping around the deleted todos. This can only be done by our caller.
func DeleteTodos(fileName string, todosToDelete []Todo, commentChar byte) ([]byte, error) {
	todos, err := ReadRebaseTodoFile(fileName, commentChar)
	if err != nil {
		return nil, err
	}
	rearrangedTodos, err := deleteTodos(todos, todosToDelete)
	if err != nil {
		return nil, err
	}
	return todosToString(rearrangedTodos, commentChar)
}

func deleteTodos(todos []todo.Todo, todosToDelete []Todo) ([]todo.Todo, error) {
	for _, todoToDelete := range todosToDelete {
		idx, ok := findTodo(todos, todoToDelete)

		if !ok {
			// Should never happen
			return []todo.Todo{}, fmt.Errorf("Todo %s not found in git-rebase-todo", todoToDelete.Hash)
		}

		todos = Remove(todos, idx)
	}

	return todos, nil
}

func MoveTodosDown(fileName string, todosToMove []Todo, commentChar byte) error {
	todos, err := ReadRebaseTodoFile(fileName, commentChar)
	if err != nil {
		return err
	}
	rearrangedTodos, err := moveTodosDown(todos, todosToMove)
	if err != nil {
		return err
	}
	return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar)
}

func MoveTodosUp(fileName string, todosToMove []Todo, commentChar byte) error {
	todos, err := ReadRebaseTodoFile(fileName, commentChar)
	if err != nil {
		return err
	}
	rearrangedTodos, err := moveTodosUp(todos, todosToMove)
	if err != nil {
		return err
	}
	return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar)
}

func moveTodoDown(todos []todo.Todo, todoToMove Todo) ([]todo.Todo, error) {
	rearrangedTodos, err := moveTodoUp(lo.Reverse(todos), todoToMove)
	return lo.Reverse(rearrangedTodos), err
}

func moveTodosDown(todos []todo.Todo, todosToMove []Todo) ([]todo.Todo, error) {
	rearrangedTodos, err := moveTodosUp(lo.Reverse(todos), lo.Reverse(todosToMove))
	return lo.Reverse(rearrangedTodos), err
}

func moveTodoUp(todos []todo.Todo, todoToMove Todo) ([]todo.Todo, error) {
	sourceIdx, ok := findTodo(todos, todoToMove)

	if !ok {
		// Should never happen
		return []todo.Todo{}, fmt.Errorf("Todo %s not found in git-rebase-todo", todoToMove.Hash)
	}

	// The todos are ordered backwards compared to our model commits, so
	// actually move the commit _down_ in the todos slice (i.e. towards
	// the end of the slice)

	// Find the next todo that we show in lazygit's commits view (skipping the rest)
	_, skip, ok := lo.FindIndexOf(todos[sourceIdx+1:], isRenderedTodo)

	if !ok {
		// We expect callers to guard against this
		return []todo.Todo{}, errors.New("Destination position for moving todo is out of range")
	}

	destinationIdx := sourceIdx + 1 + skip

	rearrangedTodos := MoveElement(todos, sourceIdx, destinationIdx)

	return rearrangedTodos, nil
}

func moveTodosUp(todos []todo.Todo, todosToMove []Todo) ([]todo.Todo, error) {
	for _, todoToMove := range todosToMove {
		var newTodos []todo.Todo
		newTodos, err := moveTodoUp(todos, todoToMove)
		if err != nil {
			return nil, err
		}
		todos = newTodos
	}

	return todos, nil
}

func MoveFixupCommitDown(fileName string, originalHash string, fixupHash string, commentChar byte) error {
	todos, err := ReadRebaseTodoFile(fileName, commentChar)
	if err != nil {
		return err
	}

	newTodos, err := moveFixupCommitDown(todos, originalHash, fixupHash)
	if err != nil {
		return err
	}

	return WriteRebaseTodoFile(fileName, newTodos, commentChar)
}

func moveFixupCommitDown(todos []todo.Todo, originalHash string, fixupHash string) ([]todo.Todo, error) {
	isOriginal := func(t todo.Todo) bool {
		return (t.Command == todo.Pick || t.Command == todo.Merge) && equalHash(t.Commit, originalHash)
	}

	isFixup := func(t todo.Todo) bool {
		return t.Command == todo.Pick && equalHash(t.Commit, fixupHash)
	}

	originalHashCount := lo.CountBy(todos, isOriginal)
	if originalHashCount != 1 {
		return nil, fmt.Errorf("Expected exactly one original hash, found %d", originalHashCount)
	}

	fixupHashCount := lo.CountBy(todos, isFixup)
	if fixupHashCount != 1 {
		return nil, fmt.Errorf("Expected exactly one fixup hash, found %d", fixupHashCount)
	}

	_, fixupIndex, _ := lo.FindIndexOf(todos, isFixup)
	_, originalIndex, _ := lo.FindIndexOf(todos, isOriginal)

	newTodos := MoveElement(todos, fixupIndex, originalIndex+1)

	newTodos[originalIndex+1].Command = todo.Fixup

	return newTodos, nil
}

func RemoveUpdateRefsForCopiedBranch(fileName string, commentChar byte) error {
	todos, err := ReadRebaseTodoFile(fileName, commentChar)
	if err != nil {
		return err
	}

	// Filter out comments
	todos = lo.Filter(todos, func(t todo.Todo, _ int) bool {
		return t.Command != todo.Comment
	})

	// Delete any update-ref todos at the end of the todo list. These are not
	// part of a stack of branches, and so shouldn't be updated. This makes it
	// possible to create a copy of a branch and rebase the copy without
	// affecting the original branch.
	if _, i, found := lo.FindLastIndexOf(todos, func(t todo.Todo) bool {
		return t.Command != todo.UpdateRef
	}); found && i < len(todos)-1 {
		todos = slices.Delete(todos, i+1, len(todos))
		return WriteRebaseTodoFile(fileName, todos, commentChar)
	}

	return nil
}

// We render a todo in the commits view if it's a commit or if it's an
// update-ref. We don't render label, reset, or comment lines.
func isRenderedTodo(t todo.Todo) bool {
	return t.Commit != "" || t.Command == todo.UpdateRef
}