# -----------------------------------------------------------------------------
# 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.
# -----------------------------------------------------------------------------

"""Non-textual diffs"""

from __future__ import annotations

import dataclasses
import os
import re
from contextlib import suppress
from pathlib import Path

from gitfourchette import settings
from gitfourchette.gitdriver import *
from gitfourchette.localization import *
from gitfourchette.nav import NavLocator, NavContext, NavFlags
from gitfourchette.porcelain import *
from gitfourchette.qt import *
from gitfourchette.settings import WhitespaceMode
from gitfourchette.tasks import RepoTask
from gitfourchette.toolbox import *
from gitfourchette import trtables


# convert.c, since Git 2.37.0
_workdirCrlfWarning = re.compile(r"warning: in the working copy of .+, (.+) will be replaced by (.+) the next time Git touches it")
# Old message in versions older than 2.37.0
_workdirCrlfWarningLegacy = re.compile(r"warning: (.+) will be replaced by (.+) in .+\.")


class ImageDelta:
    @dataclasses.dataclass
    class ImageDeltaFile:
        size: int
        image: QImage | None
        deltaFile: GitDeltaFile

    old: ImageDeltaFile
    new: ImageDeltaFile

    def __init__(self, repo: Repo, delta: GitDelta):
        oldData = delta.old.read(repo)
        newData = delta.new.read(repo)

        self.old = ImageDelta.ImageDeltaFile(
            size=len(oldData),
            image=QImage.fromData(oldData) if oldData else None,
            deltaFile=delta.old)

        self.new = ImageDelta.ImageDeltaFile(
            size=len(newData),
            image=QImage.fromData(newData) if newData else None,
            deltaFile=delta.new)


class SpecialDiffError:
    def __init__(
            self,
            message: str,
            details: str = "",
            icon: str = "SP_MessageBoxInformation",
            preformatted: str = "",
            longform: str = "",
    ):
        self.message = message
        self.details = details
        self.icon = icon
        self.preformatted = preformatted
        self.longform = longform
        self.links = DocumentLinks()
        self.taskInvoker: QObject | None = None

    def taskLink(self, taskClass: type[RepoTask], *args, **kwargs) -> str:
        return self.links.new(lambda: taskClass.invoke(self.taskInvoker, *args, **kwargs))

    @staticmethod
    def noChange(repo: Repo, delta: GitDelta, stderr: str = ""):
        message = _("File contents didn’t change.")
        details: list[str] = []
        longform: list[str] = []

        oldFile = delta.old
        newFile = delta.new
        oldFileExists = not oldFile.isId0()
        newFileExists = not newFile.isId0()

        if not newFileExists:
            message = _("Empty file was deleted.")

        if not oldFileExists:
            if delta.new.mode == FileMode.TREE:
                return SpecialDiffError.treeDiff(repo, delta)
            else:
                assert delta.status.isAddedOrUntracked
                message = _("New empty file.")

        if (newFileExists
                and oldFileExists
                and newFile.id != oldFile.id
                and settings.prefs.whitespaceMode != WhitespaceMode.Strict):
            message = _("Whitespace changes ignored. Contents otherwise identical.")
            detailsLine = "{} {}.".format(_("Current whitespace mode:"), trtables.enum(settings.prefs.whitespaceMode))
            details.append(detailsLine)

        if oldFile.path != newFile.path:
            intro = _("Renamed:")
            details.append(f"{intro} {hquo(oldFile.path)} &rarr; {hquo(newFile.path)}.")

        if oldFileExists and oldFile.mode != newFile.mode:
            intro = _("Mode change:")
            details.append(f"{intro} {trtables.enum(oldFile.mode)} &rarr; {trtables.enum(newFile.mode)}.")

        if delta.source == GitDeltaSource.Dirty and stderr:
            crlfWarningPattern = _workdirCrlfWarning
            if GitDriver.gitVersionTuple() < (2, 37):
                crlfWarningPattern = _workdirCrlfWarningLegacy
            crlfWarningMatch = crlfWarningPattern.search(stderr)
            if crlfWarningMatch:
                sourceEndings = crlfWarningMatch.group(1)
                targetEndings = crlfWarningMatch.group(2)
                longform.append(toRoomyUL([
                    _("In your working copy, {0} will be replaced by {1} the next time Git touches this file.", sourceEndings, targetEndings),
                    _("You can stage the file to dismiss this message; no changes will be recorded.")
                ]))

        return SpecialDiffError(message, "\n".join(details), longform="\n".join(longform))

    @staticmethod
    def fileTooLarge(size: int, threshold: int, locator: NavLocator, image: bool, legacy: bool = False):
        if image:
            prefKey = "imageFileThresholdKB"
            titleText = _("This image is very large.")
            loadText = _("[Load image anyway] (this may take a moment)")
            configText = _("[Configure image preview limit] (currently: {0})")
        else:
            prefKey = "largeFileThresholdKB"
            titleText = _("This file is very large.") if not legacy else _("This diff is very large.")
            loadText = _("[Load diff anyway] (this may take a moment)")
            configText = _("[Configure diff preview limit] (currently: {0})")

        locale = QLocale()
        humanSize = locale.formattedDataSize(size, 1) if not legacy else ""  # legacy: hide size
        humanThreshold = locale.formattedDataSize(threshold, 0)
        loadLink = locator.withExtraFlags(NavFlags.AllowLargeFiles).url()
        configLink = makeInternalLink("prefs", prefKey)

        longform = toRoomyUL([
            linkify(loadText, loadLink),
            linkify(configText.format(humanThreshold), configLink),
        ])

        return SpecialDiffError(titleText, humanSize, "SP_MessageBoxWarning", longform=longform)

    @staticmethod
    def typeChange(delta: GitDelta):
        oldText = _("Old type:")
        newText = _("New type:")
        oldMode = trtables.enum(delta.old.mode)
        newMode = trtables.enum(delta.new.mode)
        table = ("<table>"
                 f"<tr><td><del><b>{oldText}</b></del> </td><td>{oldMode}</tr>"
                 f"<tr><td><add><b>{newText}</b></add> </td><td>{newMode}</td></tr>"
                 "</table>")
        return SpecialDiffError(_("This file’s type has changed."), table)

    @staticmethod
    def binaryDiff(repo: Repo, delta: GitDelta, locator: NavLocator) -> SpecialDiffError | ImageDelta:
        locale = QLocale()
        oldSize = delta.old.sizeBallpark(repo)
        newSize = delta.new.sizeBallpark(repo)

        if isImageFormatSupported(delta.old.path) and isImageFormatSupported(delta.new.path):
            largestSize = max(oldSize, newSize)
            if locator.hasFlags(NavFlags.AllowLargeFiles):
                threshold = 0
            else:
                threshold = settings.prefs.imageFileThresholdKB * 1024
            if largestSize > threshold > 0:
                return SpecialDiffError.fileTooLarge(largestSize, threshold, locator, image=True)
            return ImageDelta(repo, delta)

        oldSizeText = locale.formattedDataSize(oldSize)
        newSizeText = locale.formattedDataSize(newSize)
        sizeText = f"{oldSizeText} &rarr; {newSizeText}"
        return SpecialDiffError(_("File appears to be binary."), sizeText)

    @staticmethod
    def missingLfsObjects(error: LfsObjectCacheMissingError) -> SpecialDiffError:
        from gitfourchette.tasks import DownloadLfsObjects

        locale = QLocale()
        numObjects = len(error.pointers)
        title = _n("Object missing from local LFS cache.",
                   "Objects missing from local LFS cache.", n=numObjects)
        specialDiff = SpecialDiffError(title, icon="SP_MessageBoxWarning")

        totalMissingSize = sum(ptr.size for ptr in error.pointers)
        humanMissingSize = locale.formattedDataSize(totalMissingSize)

        loadUrl = specialDiff.taskLink(DownloadLfsObjects, error)
        loadText = _n("Download object to display the diff",
                      "Download objects to display the diff", n=numObjects)
        specialDiff.details = f"{linkify(loadText, loadUrl)} ({humanMissingSize})"

        specialDiff.longform = "<small>" + "<br>".join(
            f"LFS oid {hquo(shortHash(p.id))}, {locale.formattedDataSize(p.size)}"
            for p in error.pointers)

        return specialDiff

    @staticmethod
    def lfsMigration(repo: Repo, delta: GitDelta):
        flagNames = [englishTitleCase(_("not LFS")), "LFS"]

        hasOldLfs = bool(delta.old.lfs)
        hasNewLfs = bool(delta.new.lfs)
        lfsSide = delta.new if hasNewLfs else delta.old
        blobSide = delta.old if hasNewLfs else delta.new
        assert hasOldLfs != hasNewLfs

        title = " ".join([
            _("Storage changed:"),
            f"<b>{flagNames[hasOldLfs]}</b> &rarr; <b>{flagNames[hasNewLfs]}</b>",
            stockIconImgTag("git-lfs-add" if hasNewLfs else "git-lfs-remove"),
        ])
        details = ""

        if not lfsSide.lfs.isTentative():
            blobSha256 = blobSide.blobSha256(repo)
            if blobSha256 == lfsSide.lfs.id:
                details = _("File contents remained identical during the conversion.")
            else:
                icon = stockIconImgTag("achtung") + " "
                details = icon + _("File contents modified during the conversion!")

        return SpecialDiffError(title, details)

    @staticmethod
    def treeDiff(repo: Repo, delta: GitDelta):
        from gitfourchette.tasks import AbsorbSubmodule

        message = _("This untracked subtree is the root of another Git repository.")
        specialDiff = SpecialDiffError(message)

        treePath = os.path.normpath(delta.new.path)
        treeName = os.path.basename(treePath)

        prompt1 = _("Open {0}", bquo(treeName))
        openLink = QUrl.fromLocalFile(repo.in_workdir(treePath)).toString()

        prompt2 = _("Absorb {0} as submodule", bquo(treeName))
        prompt2 = _("Recommended action:") + " [" + prompt2 + "]"
        absorbLink = specialDiff.taskLink(AbsorbSubmodule, path=treePath)

        specialDiff.details = linkify(prompt1, openLink)
        specialDiff.longform = toRoomyUL([linkify(prompt2, absorbLink)])
        return specialDiff

    @staticmethod
    def submoduleDiff(repo: Repo, delta: GitDelta, locator: NavLocator) -> SpecialDiffError:
        from gitfourchette.tasks import AbsorbSubmodule, DiscardFiles, RegisterSubmodule

        assert locator.context == NavContext.fromGitDeltaSource(delta.source)

        path = delta.new.path
        absPath = repo.in_workdir(path)
        stillExists = Path(absPath).is_dir()
        isAbsorbed = stillExists and Path(absPath, ".git").is_file()

        isDel = delta.status == GitStatus.Deleted
        isAdd = delta.status.isAddedOrUntracked
        oldId = delta.old.id
        newId = delta.new.id
        headDidMove = oldId != newId

        try:
            name = next(n for n, p in repo.listall_submodules_dict().items() if p == path)
            isRegistered = True
        except StopIteration:
            # Name may not be available when viewing a past commit about a deleted submodule,
            # or a tree that isn't registered as a submodule yet
            name = path  # Fallback to inner path as the name
            isRegistered = False

        if delta.source.isWorkdir():
            wasRegistered = path in repo.listall_submodules_dict_at_head()
        else:
            wasRegistered = True

        isSubmodule = isRegistered or wasRegistered
        isTree = not isSubmodule

        # Compose title.
        # Explicit permutations of "subtree"/"submodule" text so that translations
        # can be grammatically correct (in case of different genders, etc.)
        if isDel:
            title = _("Subtree {0} was [removed.]") if isTree else _("Submodule {0} was [removed.]")
            title = tagify(title, "<del><b>")
        elif isAdd:
            title = _("Subtree {0} was [added.]") if isTree else _("Submodule {0} was [added.]")
            title = tagify(title, "<add><b>")
        elif headDidMove:
            title = _("Subtree {0} was updated.") if isTree else _("Submodule {0} was updated.")
        else:
            title = _("Subtree {0} contains changes.") if isTree else _("Submodule {0} contains changes.")

        title = title.format(bquo(name))

        # Add link to open the submodule as a subtitle
        subtitle = ""
        openLink = QUrl.fromLocalFile(absPath)
        if stillExists:
            subtitle = _("Open subtree") if isTree else _("Open submodule")
            if name != path:
                subtitle += " " + _("(path: {0})", escape(path))
            subtitle = linkify(subtitle, openLink)

        # Initialize SpecialDiffError (we'll return this)
        specialDiff = SpecialDiffError(title, subtitle)
        longformParts = []

        # Create old/new table if the submodule's HEAD commit was moved
        if headDidMove and not isDel:
            targets = [shortHash(oldId), shortHash(newId)]
            messages = ["", ""]

            # Show additional details about the commits if there's still a workdir for this submo
            if stillExists:
                try:
                    with RepoContext(absPath, RepositoryOpenFlag.NO_SEARCH) as subRepo:
                        for i, h in enumerate([oldId, newId]):
                            if h == NULL_OID:
                                continue

                            # Link to specific commit
                            targets[i] = linkify(shortHash(h), f"{openLink.toString()}#{h}")

                            # Get commit summary
                            with suppress(LookupError, GitError):
                                m = subRepo[h].peel(Commit).message
                                m = messageSummary(m)[0]
                                m = elide(m, Qt.TextElideMode.ElideRight, 25)
                                m = hquo(m)
                                messages[i] = m
                except GitError:
                    # RepoContext may fail if the submodule couldn't be opened for any reason.
                    # Don't show an error for this, show the diff document anyway
                    pass

            intro = (_("The subtree’s <b>HEAD</b> has moved to another commit.") if isTree else
                     _("The submodule’s <b>HEAD</b> has moved to another commit."))
            if delta.source == GitDeltaSource.Dirty:
                intro += " " + _("You can stage this update:")
            longformParts.append(
                f"{intro}"
                "<p><table>"
                f"<tr><td><del><b>{_('Old:')}</b></del> </td><td><tt>{targets[0]}</tt> {messages[0]}</td></tr>"
                f"<tr><td><add><b>{_('New:')}</b></add> </td><td><tt>{targets[1]}</tt> {messages[1]}</td></tr>"
                "</table></p>")

        # Show additional tips if this submodule is in the workdir.
        if delta.source.isWorkdir():
            m = ""
            if isDel:
                if isRegistered:
                    m = _("To complete the removal of this submodule, <b>remove it from {gitmodules}</b>.")
                elif wasRegistered:
                    m = _("To complete the removal of this submodule, make sure to <b>commit "
                          "{gitmodules}</b> at the same time as the submodule folder itself.")

            elif isRegistered and not wasRegistered:
                m = _("To complete the addition of this submodule, make sure to <b>commit "
                      "{gitmodules}</b> at the same time as the submodule folder itself.")

            elif not isAbsorbed:
                if isTree:
                    m = _("<b>This subtree isn’t a submodule yet!</b> "
                          "You should [absorb this subtree] into the parent repository so it becomes a submodule.")
                else:
                    m = _("To complete the addition of this submodule, "
                          "you should [absorb the submodule] into the parent repository.")
                m = linkify(m, specialDiff.taskLink(AbsorbSubmodule, path=path))

            elif not isRegistered:
                m = _("To complete the addition of this submodule, [register it in {gitmodules}].")
                m = linkify(m, specialDiff.taskLink(RegisterSubmodule, path=path))

            if m:
                m = m.format(gitmodules=f"<tt>{DOT_GITMODULES}</tt>")
                m = f"{stockIconImgTag('achtung')} <b>{_('IMPORTANT')}</b> &ndash; {m}"
                longformParts.insert(0, m)

            # Tell about any uncommitted changes
            if delta.submoduleWorkdirDirty:
                discardLink = specialDiff.taskLink(DiscardFiles, [delta])

                if isTree:
                    uc1 = _("The subtree contains <b>uncommitted changes</b>. They can’t be committed from the parent repo. You can:")
                    uc2 = _("[Open] the subtree and commit the changes.")
                    uc3 = _("Or, [Reset] the subtree to a clean state.")
                else:
                    uc1 = _("The submodule has <b>uncommitted changes</b>. They can’t be committed from the parent repo. You can:")
                    uc2 = _("[Open] the submodule and commit the changes.")
                    uc3 = _("Or, [Reset] the submodule to a clean state.")

                m = f"{uc1}<ul><li>{uc2}</li><li>{uc3}</li></ul>"
                m = linkify(m, openLink, discardLink)
                longformParts.append(m)

        # Compile longform parts into an unordered list
        specialDiff.longform = toRoomyUL(longformParts)

        return specialDiff
