# -----------------------------------------------------------------------------
# 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 GitDelta, GitDriver, GitDeltaFile
from gitfourchette.gitdriver.lfspointer import LfsObjectCacheMissingError
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.trtables 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 = 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 in "A?"  # added or untracked
                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.context.isWorkdir() 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 delta.context == locator.context
        context = delta.context

        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 == "D"
        isAdd = delta.status in "A?"
        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 context.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 context == NavContext.UNSTAGED:
                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 context.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
