# -----------------------------------------------------------------------------
# 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
from collections.abc import Iterable
from contextlib import suppress
from typing import Any

from gitfourchette import trtables
from gitfourchette import settings
from gitfourchette.gitdriver import GitDelta, GitDeltaSource, GitStatus
from gitfourchette.gitdriver.lfspointer import LfsPointerState
from gitfourchette.localization import *
from gitfourchette.nav import NavContext, NavLocator
from gitfourchette.porcelain import *
from gitfourchette.qt import *
from gitfourchette.toolbox import *

logger = logging.getLogger(__name__)


def deltaModeText(om: FileMode, nm: FileMode) -> str:
    if nm == FileMode.UNREADABLE or nm == om:  # Deleted or unchanged mode
        return ""

    if nm == FileMode.BLOB and om == FileMode.BLOB_EXECUTABLE:
        return "-x"

    if nm == FileMode.BLOB_EXECUTABLE:
        return "+x"

    if nm == FileMode.LINK:
        return _("link")

    if nm == FileMode.TREE:
        return _("new subtree")

    if nm == FileMode.COMMIT:
        return _("commit in subtree")

    return ""


def fileTooltip(
        repo: Repo,
        delta: GitDelta,
        isCounterpart: bool = False
) -> str:
    locale = QLocale()
    of = delta.old
    nf = delta.new
    sc = delta.status

    text = "<table style='white-space: pre'>"

    def newLine(heading, caption):
        colon = _(':')
        color = mutedToolTipColorHex()
        return f"<tr><td style='color:{color}; text-align: right;'>{heading}{colon} </td><td>{caption}</td>"

    if sc == GitStatus.Renamed:
        text += newLine(_("old name"), escape(of.path))
        text += newLine(_("new name"), escape(nf.path))
    else:
        text += newLine(_("name"), escape(nf.path))

    # Status caption
    statusCaption = trtables.enum(sc)
    # Show status char except for untracked and conflict
    if sc not in [GitStatus.Untracked, GitStatus.Unmerged]:
        statusCaption += f" ({sc})"
    if sc == GitStatus.Unmerged:  # conflict sides
        assert delta.conflict is not None
        postfix = trtables.enum(delta.conflict.sides)
        statusCaption += f" ({postfix})"
    text += newLine(_("status"), statusCaption)

    # Similarity + Old name
    if sc == GitStatus.Renamed:
        text += newLine(_("similarity"), f"{delta.similarity}%")

    # File Mode
    if sc in [GitStatus.Deleted, GitStatus.Unmerged]:
        pass
    elif sc.isAddedOrUntracked:
        text += newLine(_("file mode"), trtables.enum(nf.mode))
    elif of.mode != nf.mode:
        text += newLine(_("file mode"), f"{trtables.enum(of.mode)} \u2192 {trtables.enum(nf.mode)}")

    # Get mtime & size
    mTimeNS, size = -1, -1
    sizeIsAccurate = False
    if nf.isBlob() and not nf.isId0():
        # Stat workdir file (get size on disk, modification time)
        if delta.source.isWorkdir():
            mTimeNS, size = nf.stat(repo)

        # Get accurate size in index if it's not an unstaged file
        if delta.source != GitDeltaSource.Dirty:
            assert nf.isIdValid()
            size = repo.peel_blob(nf.id).size
            sizeIsAccurate = True

    # Cache LFS pointer info
    if settings.prefs.lfsAware:
        delta.cacheLfsPointers(repo)

    # Size (if applicable)
    if size != -1:
        sizeText = locale.formattedDataSize(size, 1)
        if not sizeIsAccurate:
            sizeText = _("{size} on disk", size=sizeText)
        if delta.new.lfs.size >= 0 and not delta.new.lfs.isTentative():
            sizeText = f"{locale.formattedDataSize(delta.new.lfs.size, 1)} <b>(LFS)<b>"
        text += newLine(_("size"), sizeText)

    # Modified time
    if mTimeNS != -1:
        timeSecs = int(mTimeNS * 1e-9)  # Convert from nanoseconds
        timeQdt = QDateTime.fromSecsSinceEpoch(timeSecs)
        timeText = locale.toString(timeQdt, settings.prefs.shortTimeFormat)
        text += newLine(_("modified"), timeText)

    # Blob/Commit IDs
    if sc == GitStatus.Unmerged or nf.mode == FileMode.TREE:
        # Hide hashes for:
        # - unmerged conflicts
        # - untracked trees: those never have a valid ID
        pass
    elif of.lfs or nf.lfs:
        oldId = shortHash(of.lfs.id) if of.lfs.id else _("(not computed)")
        newId = shortHash(nf.lfs.id) if nf.lfs.id else _("(not computed)")
        text += newLine(_("LFS object hash"), f"{oldId} \u2192 {newId}")
    else:
        oldId = shortHash(of.id) if of.isIdValid() else _("(not computed)")
        newId = shortHash(nf.id) if nf.isIdValid() else _("(not computed)")
        idLegend = _("commit hash") if nf.mode == FileMode.COMMIT else _("blob hash")
        text += newLine(idLegend, f"{oldId} \u2192 {newId}")

    if isCounterpart:
        if delta.source == GitDeltaSource.Dirty:
            counterpartText = _("Currently viewing diff of staged changes in this file; "
                                "it also has <u>unstaged</u> changes.")
        else:
            counterpartText = _("Currently viewing diff of unstaged changes in this file; "
                                "it also has <u>staged</u> changes.")
        text += f"<p>{counterpartText}</p>"

    return text


class FileListModel(QAbstractListModel):
    class Role:
        Delta = Qt.ItemDataRole(Qt.ItemDataRole.UserRole + 0)
        FilePath = Qt.ItemDataRole(Qt.ItemDataRole.UserRole + 1)
        Locator = Qt.ItemDataRole(Qt.ItemDataRole.UserRole + 2)
        Decoration2 = Qt.ItemDataRole(Qt.ItemDataRole.UserRole + 3)  # LFS icons

    deltas: list[GitDelta]
    fileRows: dict[str, int]
    highlightedCounterpartRow: int

    repo: Repo

    navContext: NavContext
    """
    COMMITTED, STAGED or UNSTAGED.
    Does not change throughout the lifespan of this FileListModel.
    """

    navLocator: NavLocator
    """
    NavLocator for the commit that is currently being shown.
    Only valid (non-empty) if navContext == COMMITTED.
    Does not contain paths.
    """

    def __init__(self, parent: QWidget, repo: Repo, navContext: NavContext):
        super().__init__(parent)
        self.repo = repo
        self.navContext = navContext
        self.navLocator = NavLocator.Empty
        self.clear()

    @property
    def parentWidget(self) -> QWidget:
        parentWidget = self.parent()
        assert isinstance(parentWidget, QWidget)
        return parentWidget

    def clear(self):
        self.deltas = []
        self.fileRows = {}
        self.highlightedCounterpartRow = -1
        self.navLocator = NavLocator.Empty
        self.modelReset.emit()

    def setContents(self, deltas: Iterable[GitDelta]):
        self.beginResetModel()

        self.deltas.clear()
        self.fileRows.clear()

        sortedDeltas = sorted(deltas, key=lambda d: naturalSort(d.new.path))

        for delta in sortedDeltas:
            self.fileRows[delta.new.path] = len(self.deltas)
            self.deltas.append(delta)

        self.endResetModel()

    def rowCount(self, parent: QModelIndex = QModelIndex_default) -> int:
        return len(self.deltas)

    def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
        row = index.row()
        try:
            delta = self.deltas[row]
        except IndexError:
            delta = None

        if role == FileListModel.Role.Delta:
            return delta

        elif role == FileListModel.Role.FilePath:
            # TODO: Canonical path for submodules?
            return delta.new.path

        elif role == FileListModel.Role.Locator:
            if self.navLocator:
                return self.navLocator.replace(path=delta.new.path)
            else:
                context = NavContext.fromGitDeltaSource(delta.new.source)
                return NavLocator(context, path=delta.new.path)

        elif role == Qt.ItemDataRole.DisplayRole:
            # TODO: Canonical path for submodules?
            text = abbreviatePath(delta.new.path, settings.prefs.pathDisplayStyle, allowNul=True)

            # Show important mode info in brackets
            modeInfo = deltaModeText(delta.old.mode, delta.new.mode)
            if modeInfo:
                text = f"[{modeInfo}] {text}"

            return text

        elif role == Qt.ItemDataRole.DecorationRole:
            return stockIcon(f"status_{delta.status.lower()}")

        elif role == FileListModel.Role.Decoration2:
            if settings.prefs.lfsAware:
                # Cache LFS pointer on demand as the file scrolls into view
                if delta.new.lfs.state == LfsPointerState.Unknown:
                    delta.cacheLfsPointers(self.repo)

                if delta.old.lfs and not delta.new.lfs:
                    return stockIcon("git-lfs-remove")
                elif delta.new.lfs.state != LfsPointerState.Valid:
                    pass
                elif delta.old.lfs and delta.new.lfs:
                    return stockIcon("git-lfs")
                elif not delta.old.lfs and delta.new.lfs:
                    return stockIcon("git-lfs-add")

        elif role == Qt.ItemDataRole.ToolTipRole:
            isCounterpart = row == self.highlightedCounterpartRow
            return fileTooltip(self.repo, delta, isCounterpart)

        elif role == Qt.ItemDataRole.FontRole:  # noqa: SIM102
            if row == self.highlightedCounterpartRow:
                font = self.parentWidget.font()
                font.setUnderline(True)
                return font

        return None

    def getRowForFile(self, path: str) -> int:
        """
        Get the row number for the given path.
        Raise KeyError if the path is absent from this model.
        """
        return self.fileRows[path]

    def getFileAtRow(self, row: int) -> str:
        """
        Get the path corresponding to the given row number.
        Return an empty string if the row number is invalid.
        """
        if row < 0 or row >= self.rowCount():
            return ""
        return self.data(self.index(row), FileListModel.Role.FilePath)

    def hasFile(self, path: str) -> bool:
        """
        Return True if the given path is present in this model.
        """
        return path in self.fileRows

    def matchPathspec(self, pattern: str) -> str:
        # Try new side first
        with suppress(StopIteration):
            return next(d.new.path for d in self.deltas if d.new.matchPathspec(pattern))

        # Try old side (but return path from *new* side)
        with suppress(StopIteration):  # type: ignore[unreachable]
            return next(d.new.path for d in self.deltas if d.old.matchPathspec(pattern))

        return ""
