# -----------------------------------------------------------------------------
# Copyright (C) 2026 Iliyas Jorio.
# This file is part of GitFourchette, distributed under the GNU GPL v3.
# For full terms, see the included LICENSE file.
# -----------------------------------------------------------------------------

import itertools
import logging

from gitfourchette.forms.stashdialog import StashDialog
from gitfourchette.gitdriver import GitStatus
from gitfourchette.localization import *
from gitfourchette.nav import NavLocator
from gitfourchette.porcelain import *
from gitfourchette.qt import *
from gitfourchette.tasks.repotask import AbortTask, RepoTask, TaskEffects, TaskPrereqs
from gitfourchette.toolbox import *
from gitfourchette.trash import Trash

logger = logging.getLogger(__name__)

_indexStatusTable = {
    GitStatus.Added: FileStatus.INDEX_NEW,
    GitStatus.Deleted: FileStatus.INDEX_DELETED,
    GitStatus.Modified: FileStatus.INDEX_MODIFIED,
    GitStatus.Renamed: FileStatus.INDEX_RENAMED,
    GitStatus.TypeChanged: FileStatus.INDEX_TYPECHANGE,
}
"""
Vanilla git staged file status to libgit2 index status flags
"""

_wtStatusTable = {
    GitStatus.Untracked: FileStatus.WT_NEW,
    GitStatus.Added: FileStatus.WT_NEW,
    GitStatus.Deleted: FileStatus.WT_DELETED,
    GitStatus.Modified: FileStatus.WT_MODIFIED,
    GitStatus.Renamed: FileStatus.WT_RENAMED,
    GitStatus.TypeChanged: FileStatus.WT_TYPECHANGE,
}
"""
Vanilla git unstaged file status to libgit2 worktree status flags
"""

_statusZero = FileStatus.CURRENT
assert _statusZero == 0


def backupStash(repo: Repo, stashCommitId: Oid):
    try:
        trashFile = Trash.instance().newFile(repo.workdir, ext=".txt", originalPath="DELETED_STASH")
    except Trash.BackupSkipped as ex:
        logger.warning(f"Stash backup skipped: {ex}")
        return

    text = F"""\
To recover this stash, paste the hash below into "Repo > Recall Lost Commit" in {qAppName()}:

{stashCommitId}

----------------------------------------

Original stash message below:

{repo.peel_commit(stashCommitId).message}
"""

    with open(trashFile, "w", encoding="utf-8") as f:
        f.write(text)


class NewStash(RepoTask):
    def prereqs(self):
        # libgit2 will refuse to create a stash if there are conflicts (NoConflicts)
        # libgit2 will refuse to create a stash if there are no commits at all (NoUnborn)
        return TaskPrereqs.NoConflicts | TaskPrereqs.NoUnborn

    def flow(self, paths: list[str] | None = None):
        gitStatus = yield from self.flowCallGit(
            "--no-optional-locks",
            "status",
            "--porcelain=v2",
            "-z",
            "--untracked-files=all")
        # Get GitDelta lists from 'git status' output
        numEntries, stagedDeltas, unstagedDeltas = gitStatus.readStatusPorcelainV2Z(self.repo.head_commit_id)

        if not numEntries:
            raise AbortTask(_("There are no uncommitted changes to stash."), "information")

        # Filter out any submodules or trees
        stagedDeltas   = [d for d in stagedDeltas   if not d.isTreeOrSubmodule()]
        unstagedDeltas = [d for d in unstagedDeltas if not d.isTreeOrSubmodule()]

        # If the selection only contained submodules, bail here
        if not stagedDeltas and not unstagedDeltas:
            raise AbortTask(_("Cannot stash submodules or subtrees."), "information")

        # Convert to libgit2 status flags for StashDialog
        status: dict[str, FileStatus] = {}
        for delta, statusConversion in itertools.chain(
                ((sd, _indexStatusTable) for sd in stagedDeltas),
                ((ud, _wtStatusTable) for ud in unstagedDeltas)
        ):
            path = delta.new.path
            bits = status.get(path, _statusZero)
            bits |= statusConversion.get(delta.status, _statusZero)
            status[path] = bits

        # Ask user what to stash
        dlg = StashDialog(status, paths or [], self.parentWidget())
        dlg.setWindowModality(Qt.WindowModality.WindowModal)
        dlg.show()
        yield from self.flowDialog(dlg)

        paths = dlg.tickedPaths()
        stashMessage = dlg.ui.messageEdit.text()
        keepIntact = dlg.ui.keepCheckBox.isChecked()
        dlg.deleteLater()

        # Rationale for sticking with libgit2 here: 'git stash' always brings in
        # ALL the staged changes, even if we explicitly pass some paths.
        yield from self.flowEnterWorkerThread()
        self.epilog.effects |= TaskEffects.Refs
        self.repo.create_stash(stashMessage, paths=paths)
        self.epilog.status = _n("File stashed.", "{n} files stashed.", len(paths))
        yield from self.flowEnterUiThread()

        if keepIntact:
            return

        # Clean up with vanilla git so smudge filters apply properly
        self.epilog.effects |= TaskEffects.Workdir

        # Clean untracked files
        cleanPaths = [p for p in paths if status[p] == FileStatus.WT_NEW]
        if cleanPaths:
            yield from self.flowCallGit("clean", "--force", "--", *cleanPaths)

        # Restore other files
        restorePaths = [p for p in paths if p not in cleanPaths]
        if restorePaths:
            yield from self.flowCallGit("restore", "--progress", "--source=HEAD", "--worktree", "--staged", "--", *restorePaths)


class ApplyStash(RepoTask):
    def prereqs(self):
        # libgit2 will refuse to apply a stash if there are conflicts (NoConflicts)
        return TaskPrereqs.NoConflicts | TaskPrereqs.NoStagedChanges

    def flow(self, stashCommitId: Oid, tickDelete=True):
        stashCommit: Commit = self.repo.peel_commit(stashCommitId)
        stashMessage = strip_stash_message(stashCommit.message)

        question = _("Do you want to apply the changes stashed in {0} "
                     "to your working directory?", bquoe(stashMessage))

        qmb = asyncMessageBox(self.parentWidget(), 'question', self.name(), question,
                              QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
                              deleteOnClose=False)

        def updateButtonText(ticked: bool):
            okButton = qmb.button(QMessageBox.StandardButton.Ok)
            okButton.setText(_("&Apply && Delete") if ticked else _("&Apply && Keep"))

        deleteCheckBox = QCheckBox(_("&Delete the stash if it applies cleanly"), qmb)
        deleteCheckBox.clicked.connect(updateButtonText)
        deleteCheckBox.setChecked(tickDelete)
        qmb.setCheckBox(deleteCheckBox)
        updateButtonText(tickDelete)
        yield from self.flowDialog(qmb)

        deleteAfterApply = deleteCheckBox.isChecked()
        qmb.deleteLater()

        self.epilog.jumpTo = NavLocator.inWorkdir()
        self.epilog.effects |= TaskEffects.Workdir

        if deleteAfterApply:
            self.epilog.effects |= TaskEffects.Refs
            backupStash(self.repo, stashCommitId)

        # Although 'git stash apply stash@{<FULL_COMMIT_ID>}' works fine,
        # 'git stash pop' and 'drop' won't delete the stash if we pass the full
        # commit id. So, use a stash number instead.
        stashIndex = self.repo.find_stash_index(stashCommitId)
        popOrApply = "pop" if deleteAfterApply else "apply"

        driver = yield from self.flowCallGit("stash", popOrApply, str(stashIndex), autoFail=False)

        self.repo.refresh_index()
        anyConflicts = self.repo.index.conflicts

        if driver.exitCode() == 0:
            if deleteAfterApply:
                self.epilog.status = _("Stash {0} applied and deleted.", tquoe(stashMessage))
            else:
                self.epilog.status = _("Stash {0} applied.", tquoe(stashMessage))

        elif anyConflicts:
            self.epilog.status = _("Stash {0} applied, with conflicts.", tquoe(stashMessage))
            message = [_("Applying the stash {0} has caused merge conflicts "
                         "because your files have diverged since they were stashed.", bquoe(stashMessage))]
            if deleteAfterApply:
                message.append(_("The stash wasn’t deleted in case you need to re-apply it later."))
            showWarning(self.parentWidget(), _("Conflicts caused by stash application"), paragraphs(*message))

        else:
            self.epilog.status = _("Stash {0} couldn’t be applied.", tquoe(stashMessage))
            message = [self.epilog.status]
            if deleteAfterApply:
                message.append(_("The stash wasn’t deleted in case you need to re-apply it later."))
            markup = driver.htmlErrorText(paragraphs(*message))
            raise AbortTask(markup, details=driver.formatCommandLine())


class DropStash(RepoTask):
    def flow(self, stashCommitId: Oid):
        stashCommit = self.repo.peel_commit(stashCommitId)
        stashMessage = strip_stash_message(stashCommit.message)
        yield from self.flowConfirm(
            text=_("Really delete stash {0}?", bquoe(stashMessage)),
            verb=_("Delete stash"),
            buttonIcon="SP_DialogDiscardButton")

        backupStash(self.repo, stashCommitId)

        self.epilog.effects |= TaskEffects.Refs
        stashIndex = self.repo.find_stash_index(stashCommitId)
        stashName = f"stash@{{{stashIndex}}}"  # 'git stash drop' doesn't support stash numbers
        yield from self.flowCallGit("stash", "drop", stashName)

        self.epilog.status = _("Stash {0} deleted.", tquoe(stashMessage))
