# -----------------------------------------------------------------------------
# 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 logging
import os
import shutil
from itertools import chain
from pathlib import Path

from gitfourchette import settings
from gitfourchette.exttools.mergedriver import MergeDriver
from gitfourchette.gitdriver import argsIf, GitDelta, GitDriver
from gitfourchette.localization import *
from gitfourchette.nav import NavLocator, NavContext
from gitfourchette.porcelain import *
from gitfourchette.qt import *
from gitfourchette.tasks.repotask import AbortTask, RepoTask, TaskEffects
from gitfourchette.toolbox import *
from gitfourchette.trash import Trash
from gitfourchette.trtables import TrTables

logger = logging.getLogger(__name__)


class _BaseStagingTask(RepoTask):
    def canKill(self, task: RepoTask):
        # Jump/Refresh tasks shouldn't prevent a staging task from starting
        # when the user holds down RETURN/DELETE in a FileListView
        # to stage/unstage a series of files.
        from gitfourchette import tasks
        return isinstance(task, tasks.Jump | tasks.RefreshRepo)

    def denyConflicts(self, deltas: list[GitDelta], purpose: PatchPurpose):
        # Filter on conflicts
        conflictPaths = [d.new.path for d in deltas if d.conflict is not None]

        # Bail if no conflicts
        if not conflictPaths:
            return

        numPatches = len(deltas)
        numConflicts = len(conflictPaths)

        if numPatches == numConflicts:
            intro = _n("You have selected an unresolved merge conflict.",
                       "You have selected {n} unresolved merge conflicts.", numConflicts)
        else:
            intro = _n("There is an unresolved merge conflict among your selection.",
                       "There are {n} unresolved merge conflicts among your selection.", numConflicts)

        if purpose == PatchPurpose.Stage:
            please = _np("please fix (the merge conflicts)", "Please fix it before staging:", "Please fix them before staging:", numConflicts)
        else:
            please = _np("please fix (the merge conflicts)", "Please fix it before discarding:", "Please fix them before discarding:", numConflicts)

        message = paragraphs(intro, please)
        message += toTightUL(conflictPaths)
        raise AbortTask(message)


class StageFiles(_BaseStagingTask):
    def flow(self, deltas: list[GitDelta]):
        if not deltas:  # Nothing to stage (may happen if user keeps pressing Enter in file list view)
            QApplication.beep()
            raise AbortTask()

        self.denyConflicts(deltas, PatchPurpose.Stage)

        paths = [d.new.path for d in deltas]

        self.epilog.effects |= TaskEffects.Workdir
        yield from self.flowCallGit("add", "--", *paths)

        yield from self.debriefPostStage(deltas)

        self.epilog.status = _n("File staged.", "{n} files staged.", len(deltas))

    def debriefPostStage(self, deltas: list[GitDelta]):
        debrief = {}

        for delta in deltas:
            # Staging a tree that isn't registered as a submodule
            if delta.new.mode == FileMode.TREE:
                m = _("You’ve added another Git repo inside your current repo. "
                      "It is STRONGLY RECOMMENDED to absorb it as a submodule before committing.")

            # Staging a submodule with uncommitted changes within
            elif delta.new.mode == FileMode.COMMIT and delta.submoduleWorkdirDirty:
                m = _("Uncommitted changes in the submodule can’t be staged from the parent repository.")

            # Staging a submodule deletion, and the submodule is still in .gitmodules
            elif (delta.status == "D"
                  and delta.old.mode == FileMode.COMMIT
                  and delta.old.path in self.repo.listall_submodules_dict_at_head()):
                m = _("Don’t forget to remove the submodule from {0} to complete its deletion.", tquo(DOT_GITMODULES))

            # Otherwise, no special message
            else:
                continue

            debrief[delta.new.path] = m

        if not debrief:
            return

        # For better perceived responsivity, show message box asynchronously
        # so that RefreshRepo occurs in the background after the task completes
        yield from self.flowEnterUiThread()
        qmb = asyncMessageBox(
            self.parentWidget(),
            'information',
            self.name(),
            _n("An item requires your attention after staging:", "{n} items require your attention after staging:", len(debrief)))
        addULToMessageBox(qmb, [f"{btag(path)}: {issue}" for path, issue in debrief.items()])
        qmb.show()


class DiscardFiles(_BaseStagingTask):
    def flow(self, deltas: list[GitDelta]):
        context = NavContext.UNSTAGED
        assert all(d.context == context for d in deltas)

        textPara = []
        really = ""
        verb = _("Discard changes")

        if not deltas:  # Nothing to discard (may happen if user keeps pressing Delete in file list view)
            QApplication.beep()
            raise AbortTask()

        self.denyConflicts(deltas, PatchPurpose.Discard)

        # Filter on submodules
        submoPaths = [d.new.path for d in deltas if d.isSubtreeCommitPatch()]
        numSubmos = len(submoPaths)

        if len(deltas) == 1:
            delta = deltas[0]
            bpath = bquo(delta.new.path)
            if delta.status == "?":  # untracked
                really = _("Really delete {0}?", bpath)
                really += " " + _("Git isn’t tracking this file, so you may not be able to recover it from older commits.")
                verb = _("Delete")
            elif delta.new.mode == FileMode.COMMIT:  # modeWorktree
                really = _("Really discard changes in submodule {0}?", bpath)
            else:
                really = _("Really discard changes to {0}?", bpath)
        else:
            numFiles = len(deltas) - numSubmos
            if numSubmos and numFiles:
                really = _("Really discard changes to {nf} files and in {ns} submodules?", nf=numFiles, ns=numSubmos)
            elif numSubmos:
                really = _("Really discard changes in {n} submodules?", n=numSubmos)
            else:
                really = _("Really discard changes to {n} files?", n=numFiles)

        textPara.append(really)
        if numSubmos:
            submoPostamble = _n(
                "Any uncommitted changes in the submodule will be <b>cleared</b> and the submodule’s HEAD will be reset.",
                "Any uncommitted changes in {n} submodules will be <b>cleared</b> and the submodules’ HEAD will be reset.",
                numSubmos)
            textPara.append(submoPostamble)

        textPara.append(_("This cannot be undone!"))

        yield from self.flowConfirm(text=paragraphs(textPara), verb=verb, buttonIcon="git-discard")

        self.epilog.effects |= TaskEffects.Workdir
        if numSubmos:
            self.epilog.effects |= TaskEffects.Refs  # We don't have TaskEffects.Submodules so .Refs is the next best thing

        # Back up discarded patches
        if Trash.enabled():
            for delta in deltas:
                try:
                    yield from self._backupDelta(delta)
                except Trash.BackupSkipped as ex:
                    logger.warning(f"Backup skipped: {ex}")

        tracked = [d.new.path for d in deltas if d.status != "?"]
        untrackedFiles = [d.new.path for d in deltas if d.status == "?" and d.new.mode != FileMode.TREE]
        untrackedTrees = [d.new.path for d in deltas if d.status == "?" and d.new.mode == FileMode.TREE]

        # Discard untracked trees. They have already been backed up above,
        # but restore_files_from_index isn't capable of removing trees.
        for untrackedTree in untrackedTrees:
            untrackedTreePath = self.repo.in_workdir(untrackedTree)
            assert os.path.isdir(untrackedTreePath)
            shutil.rmtree(untrackedTreePath)

        if untrackedFiles:
            yield from self.flowCallGit("clean", "--force", "--", *untrackedFiles)
        if tracked:
            yield from self.flowCallGit("checkout", "--", *tracked)

        if submoPaths:
            for submo in submoPaths:
                subWd = os.path.join(self.repo.workdir, submo)
                yield from self.flowCallGit("clean", "-d", "--force", workdir=subWd)
            yield from self.flowCallGit("submodule", "update", "--force", "--init", "--recursive", "--checkout", "--", *submoPaths)

        self.epilog.status = _n("File discarded.", "{n} files discarded.", len(deltas))

    def _backupDelta(self, delta: GitDelta):
        # Don't back up deletions
        if delta.status == "D":
            return

        trash = Trash.instance()
        path = delta.new.path
        workdir = self.repo.workdir

        if delta.status == "?":
            if delta.new.mode == FileMode.TREE:
                # Untracked tree
                trash.backupTree(workdir, path)
            else:
                # Untracked file
                # TODO: Also binary files?
                trash.backupFile(workdir, path)
        else:
            # TODO: Cache patch in GitDelta? So we don't have to regenerate the patch if we've already displayed it
            tokens = GitDriver.buildDiffCommand(delta)
            driver = yield from self.flowCallGit(*tokens, autoFail=False)
            patchText = driver.stdoutScrollback()
            trash.backupPatch(workdir, patchText, path)


class UnstageFiles(_BaseStagingTask):
    def flow(self, deltas: list[GitDelta]):
        if not deltas:  # Nothing to unstage (may happen if user keeps pressing Delete in file list view)
            QApplication.beep()
            raise AbortTask()

        paths = []
        for delta in deltas:
            paths.append(delta.new.path)
            if delta.status == "R":
                paths.append(delta.old.path)

        self.epilog.effects |= TaskEffects.Workdir
        # Not using 'restore --staged' because it doesn't work in an empty repo
        yield from self.flowCallGit("reset", "--", *paths)

        self.epilog.status = _n("File unstaged.", "{n} files unstaged.", len(deltas))


class DiscardModeChanges(_BaseStagingTask):
    def flow(self, deltas: list[GitDelta]):
        paths = [delta.new.path for delta in deltas]
        numFiles = len(paths)
        textPara = []

        if numFiles == 0:  # Nothing to unstage (may happen if user keeps pressing Delete in file list view)
            QApplication.beep()
            raise AbortTask()
        elif numFiles == 1:
            textPara.append(_("Really discard mode change in {0}?", bquo(paths[0])))
        else:
            textPara.append(_("Really discard mode changes in <b>{n} files</b>?", n=numFiles))
        textPara.append(_("This cannot be undone!"))
        yield from self.flowConfirm(text=paragraphs(textPara), verb=_("Discard mode changes"), buttonIcon="git-discard")

        yield from self.flowEnterWorkerThread()
        self.epilog.effects |= TaskEffects.Workdir
        self.repo.discard_mode_changes(paths)


class UnstageModeChanges(_BaseStagingTask):
    def flow(self, deltas: list[GitDelta]):
        if not deltas:  # Nothing to unstage (may happen if user keeps pressing Delete in file list view)
            QApplication.beep()
            raise AbortTask()

        yield from self.flowEnterWorkerThread()
        self.epilog.effects |= TaskEffects.Workdir

        self.repo.refresh_index()
        index = self.repo.index
        for delta in deltas:
            of = delta.old
            nf = delta.new
            if (of.mode != nf.mode
                    and delta.status not in "AD?"  # ADDED, DELETED, UNTRACKED
                    and of.mode in [FileMode.BLOB, FileMode.BLOB_EXECUTABLE]):
                index.add(IndexEntry(nf.path, Oid(hex=nf.id), of.mode))
        index.write()


class ApplyPatch(RepoTask):
    def flow(self, delta: GitDelta, subPatch: str, purpose: PatchPurpose):
        if not subPatch:
            QApplication.beep()
            verb = TrTables.enum(purpose & PatchPurpose.VerbMask).lower()
            message = _("Can’t {verb} the selection because no red/green lines are selected.", verb=verb)
            raise AbortTask(message, asStatusMessage=True)

        if purpose & PatchPurpose.Discard:
            title = TrTables.enum(purpose)
            textPara = []
            if purpose & PatchPurpose.Hunk:
                textPara.append(_("Really discard this hunk?"))
            else:
                textPara.append(_("Really discard the selected lines?"))
            textPara.append(_("This cannot be undone!"))
            yield from self.flowConfirm(title, text=paragraphs(textPara), verb=title, buttonIcon="git-discard-lines")

            try:
                Trash.instance().backupPatch(self.repo.workdir, subPatch, delta.new.path)
            except Trash.BackupSkipped as ex:
                logger.warning(f"Backup skipped: {ex}")

            applyLocation = ApplyLocation.WORKDIR
        else:
            applyLocation = ApplyLocation.INDEX

        yield from self.flowEnterWorkerThread()
        self.epilog.effects |= TaskEffects.Workdir

        # libgit2's index must be fresh in order to apply a partial patch to the index.
        if applyLocation == ApplyLocation.INDEX:
            self.repo.refresh_index()

        self.repo.apply(subPatch, applyLocation)

        self.epilog.status = TrTables.patchPurposePastTense(purpose)


class HardSolveConflicts(RepoTask):
    def flow(self, ours: list[str], theirs: list[str], remove: list[str]):
        # Back up affected files
        if Trash.enabled():
            for path in chain(theirs, remove):
                try:
                    Trash.instance().backupFile(self.repo.workdir, path)
                except Trash.BackupSkipped as ex:
                    logger.warning(f"Backup skipped: {ex}")

        # Restore desired sides
        self.epilog.effects |= TaskEffects.Workdir

        if ours:
            yield from self.flowCallGit("restore", "--progress", "--ours", "--", *ours)

        if theirs:
            yield from self.flowCallGit("restore", "--progress", "--theirs", "--", *theirs)

        # Stage the files we kept to resolve the conflict
        if ours or theirs:
            yield from self.flowCallGit("add", "--force", "--", *chain(ours, theirs))

        # Stage deletions to resolve the conflict
        if remove:
            yield from self.flowCallGit("rm", "--", *remove)

        # Jump to any staged file after the task
        for path in chain(ours, theirs):
            if Path(self.repo.in_workdir(path)).is_file():
                self.epilog.jumpTo = NavLocator.inStaged(path)
                break

        self.epilog.status = _n("Conflict resolved.", "{n} conflicts resolved.", len(ours) + len(theirs) + len(remove))


class AcceptMergeConflictResolution(RepoTask):
    def canKill(self, task: RepoTask) -> bool:
        from gitfourchette.tasks import RefreshRepo, Jump
        return isinstance(task, RefreshRepo | Jump)

    def flow(self, mergeDriver: MergeDriver):
        self.epilog.effects |= TaskEffects.Workdir
        path = mergeDriver.relativeTargetPath
        mergeDriver.copyScratchToTarget()
        mergeDriver.deleteNow()
        yield from self.flowCallGit("add", "--force", "--", path)

        # Jump to staged file after confirming conflict resolution
        self.epilog.jumpTo = NavLocator.inStaged(path)
        self.epilog.status = _("Merge conflict resolved in {0}.", tquo(path))


class ApplyPatchFile(RepoTask):
    def flow(self, reverse: bool = False, path: str = ""):
        if reverse:
            verb, title = _("revert"), _("Revert patch file")
        else:
            verb, title = _("apply"), _("Apply patch file")

        patchFileCaption = _("Patch file")
        allFilesCaption = _("All files")

        if not path:
            qfd = PersistentFileDialog.openFile(
                self.parentWidget(), "OpenPatch", title,
                filter=f"{patchFileCaption} (*.patch);;{allFilesCaption} (*)")
            path = yield from self.flowFileDialog(qfd)

        question = _("Do you want to {verb} patch file {path}?",
                     verb=btag(verb), path=bquoe(os.path.basename(path)))

        yield from ApplyPatchFile.do(self, reverse, -1, path, title, question)

    @staticmethod
    def do(task: RepoTask, reverse: bool, context: int, path: str, title: str, question: str):
        stem = [
            "apply",
            *argsIf(reverse, "--reverse"),
            *argsIf(context >= 0, f"-C{context}"),
            path
        ]

        # Do a dry run first.
        driver = yield from task.flowCallGit(*stem, "--numstat", "-z", "--check")

        table = driver.stdoutTableNumstatZ()
        numFiles = len(table)
        details = []
        firstFile = ""
        for adds, dels, patchFile in table:
            if adds == "-" or dels == "-":
                details.append(_("(binary)") + " " + escape(patchFile))
            else:
                details.append(f"(<add>+{adds}</add> <del>-{dels}</del>) {escape(patchFile)}")
            firstFile = firstFile or patchFile

        addDelStyle = settings.prefs.addDelColorsStyleTag()
        listIntro = _n("<b>{n}</b> file will be modified in your working directory:",
                       "<b>{n}</b> files will be modified in your working directory:", n=numFiles)
        confirmText = addDelStyle + paragraphs(question, listIntro)

        yield from task.flowConfirm(title, confirmText, verb=_("Apply"), detailList=details)

        # Dry run confirmed, go ahead
        task.epilog.effects |= TaskEffects.Workdir

        yield from task.flowCallGit(*stem)

        task.epilog.jumpTo = NavLocator.inUnstaged(firstFile)
        task.epilog.status = _n("{n} file modified in the working directory.",
                                "{n} files modified in the working directory.", n=numFiles)


class ApplyPatchFileReverse(ApplyPatchFile):
    def flow(self, path: str = ""):
        yield from ApplyPatchFile.flow(self, reverse=True, path=path)


class ApplyPatchData(RepoTask):
    def flow(self, patchData: str, title: str, question: str, reverse: bool = False, context: int = -1):
        assert isinstance(patchData, str), "patchData should be str"

        if not patchData:
            raise AbortTask(_("There’s nothing to apply in the selection."))

        template = os.path.join(qTempDir(), self.__class__.__name__ + "-XXXXXX.patch")
        tempPatch = QTemporaryFile(template, self)
        tempPatch.open()
        tempPatch.write(patchData.encode("utf-8"))
        tempPatch.close()
        path = tempPatch.fileName()

        yield from ApplyPatchFile.do(self, reverse, context, path, title, question)


class RestoreRevisionToWorkdir(RepoTask):
    def flow(self, delta: GitDelta, old: bool):
        if old:
            preposition = _p("preposition slotted into '...BEFORE this commit'", "before")
            diffFile = delta.old
            delete = delta.status == "A"
        else:
            preposition = _p("preposition slotted into '...AT this commit'", "at")
            diffFile = delta.new
            delete = delta.status == "D"

        path = self.repo.in_workdir(diffFile.path)
        pathObj = Path(path)
        existsNow = pathObj.is_file()

        if not existsNow and delete:
            message = _("Your working copy of {path} already matches the revision {preposition} this commit.",
                        path=bquo(diffFile.path), preposition=preposition)
            raise AbortTask(message, icon="information")

        if not existsNow:
            actionVerb = _("recreated")
        elif delete:
            actionVerb = _("deleted")
        else:
            actionVerb = _("overwritten")
        prompt = paragraphs(
            _("Do you want to restore {path} as it was {preposition} this commit?",
              path=bquo(diffFile.path), preposition=preposition),
            _("This file will be {processed} in your working directory.", processed=actionVerb))

        yield from self.flowConfirm(text=prompt, verb=_("Restore"))

        self.epilog.effects |= TaskEffects.Workdir

        if delete:
            pathObj.unlink()
        else:
            data = diffFile.read(self.repo)
            pathObj.parent.mkdir(parents=True, exist_ok=True)
            pathObj.write_bytes(data)
            pathObj.chmod(diffFile.mode)

        self.epilog.status = _("File {path} {processed}.", path=tquoe(diffFile.path), processed=actionVerb)
        self.epilog.jumpTo = NavLocator.inUnstaged(diffFile.path)


class AbortMerge(RepoTask):
    def flow(self):
        self.repo.refresh_index()

        isMerging = self.repo.state() == RepositoryState.MERGE
        isCherryPicking = self.repo.state() == RepositoryState.CHERRYPICK
        isReverting = self.repo.state() == RepositoryState.REVERT
        anyConflicts = self.repo.index.conflicts

        if not (isMerging or isCherryPicking or isReverting or anyConflicts):
            raise AbortTask(_("No abortable state is in progress."), icon='information')

        if isCherryPicking:
            clause = _("abort the ongoing cherry-pick")
            title = _("Abort cherry-pick")
            postStatus = _("Cherry-pick aborted.")
            gitCommand = ["cherry-pick", "--abort"]
        elif isMerging:
            clause = _("abort the ongoing merge")
            title = _("Abort merge")
            postStatus = _("Merge aborted.")
            gitCommand = ["merge", "--abort"]
        elif isReverting:
            clause = _("abort the ongoing revert")
            title = _("Abort revert")
            postStatus = _("Revert aborted.")
            gitCommand = ["revert", "--abort"]
        else:
            clause = _("reset the index")
            title = _("Reset index")
            postStatus = _("Index reset.")
            gitCommand = ["reset", "--merge"]

        try:
            abortList = self.repo.get_reset_merge_file_list()
        except MultiFileError as exc:
            exc.message = _n(
                "Cannot {verb} right now, because a file contains both staged and unstaged changes.",
                "Cannot {verb} right now, because {n} files contain both staged and unstaged changes.",
                n=len(exc.file_exceptions), verb=clause)
            exc.message += " " + _("Please unstage the changes and try again.")
            raise exc

        lines = [_("Do you want to {0}?", clause)]

        if not abortList:
            lines.append(_("No files are affected."))
        else:
            if anyConflicts:
                lines.append(_("All conflicts will be cleared and all <b>staged</b> changes will be lost."))
            else:
                lines.append(_("All <b>staged</b> changes will be lost."))
            lines.append(_n("This file will be reset:", "{n} files will be reset:", len(abortList)))

        yield from self.flowConfirm(title=title, text=paragraphs(lines), verb=englishTitleCase(title),
                                    detailList=[escape(f) for f in abortList])

        self.epilog.effects |= TaskEffects.DefaultRefresh

        yield from self.flowCallGit(*gitCommand)
        # self.repo.reset_merge()
        # self.repo.state_cleanup()

        self.epilog.status = postStatus

        # Clear draft commit message that was set in CherrypickCommit/RevertCommit/MergeBranch
        if isCherryPicking or isReverting or isMerging:
            self.repoModel.prefs.clearDraftCommit()
