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

from __future__ import annotations

from dataclasses import dataclass
from typing import Literal

from gitfourchette import trtables
from gitfourchette.graph.graph import CommitTraits
from gitfourchette.localization import *
from gitfourchette.porcelain import *
from gitfourchette.qt import *
from gitfourchette.repomodel import UC_FAKEID, RepoModel, GpgStatus
from gitfourchette.toolbox import *


@dataclass
class CommitToolTipZone:
    left: int
    right: int
    kind: Literal['ref', 'author', 'message', 'pathspec']
    data: str = ""


class SpecialRow(enum.IntEnum):
    Invalid = 0

    # Dedicated rows in the commit log
    UncommittedChanges = enum.auto()
    Commit = enum.auto()
    TruncatedHistory = enum.auto()
    EndOfShallowHistory = enum.auto()

    # The items below are not dedicated rows in the commit log per se.
    # They are special kinds of SpecialDiffError displayed when an invalid
    # selection is made in GraphView.
    TooManyRowsSelected = enum.auto()
    CannotCompareRows = enum.auto()

    @classmethod
    def fromString(cls, s: str) -> SpecialRow:
        i = int(s)
        return cls(i)


class CommitLogModel(QAbstractListModel):
    ToolTipCacheSize = 150
    """ Number of rows to keep track of for ToolTipZones """

    class Role:
        Commit          = Qt.ItemDataRole.UserRole + 0
        Oid             = Qt.ItemDataRole.UserRole + 1
        ToolTipZones    = Qt.ItemDataRole.UserRole + 2
        AuthorColumnX   = Qt.ItemDataRole.UserRole + 3
        SpecialRow      = Qt.ItemDataRole.UserRole + 4
        BlameRevision   = Qt.ItemDataRole.UserRole + 5  # for BlameScrubber
        ComparisonSide  = Qt.ItemDataRole.UserRole + 6  # A/B commit diffs

    repoModel: RepoModel
    _extraRow: SpecialRow
    _authorColumnX: int
    _toolTipZones: dict[int, list[CommitToolTipZone]]

    def __init__(self, repoModel: RepoModel, parent: QWidget):
        super().__init__(parent)

        self.repoModel = repoModel
        self._authorColumnX = -1
        self._toolTipZones = {}
        self.commitDiffAB: tuple[Oid, Oid] | None = None

        if repoModel.truncatedHistory:
            self._extraRow = SpecialRow.TruncatedHistory
        elif repoModel.repo.is_shallow:
            self._extraRow = SpecialRow.EndOfShallowHistory
        else:
            self._extraRow = SpecialRow.Invalid

    def resetCommitSequence(self, nRemovedRows: int = -1, nAddedRows: int = 0):
        if nRemovedRows < 0:
            # Replace log wholesale
            self.beginResetModel()
            self.endResetModel()
            return

        parent = QModelIndex_default  # it's not a tree model so there's no parent

        # DON'T interleave beginRemoveRows/beginInsertRows!
        # It'll crash with QSortFilterProxyModel!
        if nRemovedRows != 0:
            self.beginRemoveRows(parent, 0, nRemovedRows)
            self.endRemoveRows()

        if nAddedRows != 0:
            self.beginInsertRows(parent, 0, nAddedRows)
            self.endInsertRows()

    def rowCount(self, *args, **kwargs) -> int:
        n = len(self.repoModel.commitSequence)
        if self._extraRow != SpecialRow.Invalid:
            n += 1
        return n

    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
        row = index.row()

        if role == Qt.ItemDataRole.DisplayRole:
            return None

        elif role == CommitLogModel.Role.Commit:
            try:
                return self.repoModel.commitSequence[row]
            except IndexError:
                return None

        elif role == CommitLogModel.Role.Oid:
            try:
                commit = self.repoModel.commitSequence[row]
                return commit.id
            except (IndexError, AttributeError):
                return None

        elif role == CommitLogModel.Role.SpecialRow:
            if row == 0:
                return SpecialRow.UncommittedChanges
            elif row < len(self.repoModel.commitSequence):
                return SpecialRow.Commit
            else:
                return self._extraRow

        elif role == CommitLogModel.Role.ComparisonSide:
            if not self.commitDiffAB:
                return ""
            try:
                commit = self.repoModel.commitSequence[row]
                i = self.commitDiffAB.index(commit.id)
                return "AB"[i]
            except (IndexError, AttributeError, ValueError):
                return ""

        elif role == Qt.ItemDataRole.ToolTipRole:
            tip = ""

            try:
                commit = self.repoModel.commitSequence[row]
                zones = self._toolTipZones[row]
            except (IndexError, KeyError):
                return tip
            if commit is None or commit.id == UC_FAKEID:
                return tip

            parentWidget = self.parent()
            assert isinstance(parentWidget, QWidget)
            x = parentWidget.mapFromGlobal(QCursor.pos()).x()

            for zone in reversed(zones):
                if not (zone.left <= x <= zone.right):
                    continue
                if zone.kind == "ref":
                    tip = zone.data
                elif zone.kind == "message":
                    tip = commitMessageTooltip(commit)
                elif zone.kind == "author":
                    tip = commitAuthorTooltip(commit, *self.repoModel.getCachedGpgStatus(commit))
                elif zone.kind == "pathspec":
                    tip = _("This commit touches a path that matches your search")
                break

            if self._authorColumnX <= 0:  # author hidden in narrow window
                tip += commitAuthorTooltip(commit, *self.repoModel.getCachedGpgStatus(commit))

            return tip

        return None

    def setData(self, index, value, role=None):
        if role == CommitLogModel.Role.AuthorColumnX:
            self._authorColumnX = value
            return True

        elif role == CommitLogModel.Role.ToolTipZones:
            row = index.row()

            self._toolTipZones.pop(row, None)

            if value:
                # Bump row to end of keys (dicts keep key insertion order since Python 3.7)
                self._toolTipZones[row] = value

                # Nuke old entries if the dict grew beyond the threshold
                trimCacheDict(self._toolTipZones, CommitLogModel.ToolTipCacheSize)

            return True

        return False


def commitAuthorTooltip(commit: CommitTraits, gpgStatus: GpgStatus, gpgKeyInfo: str) -> str:
    def formatTime(sig: Signature):
        return escape(signatureDateFormat(sig))

    def formatPerson(sig: Signature):
        return f"<b>{escape(sig.name)}</b> &lt;{escape(sig.email)}&gt;"

    author = commit.author
    committer = commit.committer

    markup = "<p style='white-space: pre'>"
    markup += formatPerson(author)

    if author == committer:
        markup += f"<small><br>{formatTime(author)}"
    elif author.name == committer.name and author.email == committer.email:
        suffixA = _p("CommitTooltip", "(authored)")
        suffixC = _p("CommitTooltip", "(committed)")
        markup += (f"<small><br>{formatTime(author)} {suffixA}"
                   f"<br>{formatTime(committer)} *{suffixC}")
    else:
        committedBy = _p("CommitTooltip", "Committed by {0}", formatPerson(committer))
        markup += (f"<small><br>{formatTime(author)}"
                   f"<br><br>* {committedBy}"
                   f"<br><small>{formatTime(committer)}")
    markup += "</p>"

    if gpgStatus == GpgStatus.Unsigned:
        pass
    elif gpgKeyInfo:
        markup += f"<p>{gpgStatus.iconHtml()} {trtables.enum(gpgStatus)}<br><small>{escape(gpgKeyInfo)}</small></p>"
    else:
        markup += f"<p>{gpgStatus.iconHtml()} {trtables.enum(gpgStatus)}</p>"

    return markup


def commitMessageTooltip(commit: CommitTraits) -> str:
    message = commit.message.rstrip()
    maxLength = max(len(line) for line in message.splitlines())
    message = escape(message)

    if maxLength <= 80:
        # Keep Qt from wrapping tooltip text when the message is made up of short lines
        return "<p style='white-space: pre'>" + message
    else:
        return "<p>" + message.replace('\n', '<br>')


def trimCacheDict(d: dict, trimToSize: int):
    maxCapacity = trimToSize * 2
    size = len(d)
    if size <= maxCapacity:
        return
    numOldKeys = size - trimToSize
    oldKeys = list(d)[:numOldKeys]
    for k in oldKeys:
        del d[k]
