# -----------------------------------------------------------------------------
# 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 os
from collections.abc import Callable, Generator, Iterable
from contextlib import suppress
from typing import Literal

from gitfourchette import settings
from gitfourchette.application import GFApplication
from gitfourchette.exttools.toolprocess import ToolProcess
from gitfourchette.exttools.usercommand import UserCommand
from gitfourchette.filelists.filelistmodel import FileListModel
from gitfourchette.forms.searchbar import SearchBar
from gitfourchette.gitdriver import GitDelta, GitDriver
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.repomodel import RepoModel
from gitfourchette.search.itemviewsearchprovider import ItemViewSearchProvider
from gitfourchette.settings import FileListClick
from gitfourchette.tasks import *
from gitfourchette.tasks.repotask import showMultiFileErrorMessage
from gitfourchette.toolbox import *
from gitfourchette.trtables import TrTables


class FileListDelegate(QStyledItemDelegate):
    """
    Item delegate for QListView that supports highlighting search terms from a SearchBar
    """

    def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex):
        widget: FileList = option.widget
        isActive = bool(option.state & QStyle.StateFlag.State_Active)
        isSelected = bool(option.state & QStyle.StateFlag.State_Selected)
        colorGroup = QPalette.ColorGroup.Active if isActive else QPalette.ColorGroup.Inactive

        # Gather data from model
        icon: QIcon = index.data(Qt.ItemDataRole.DecorationRole)
        emblem: QIcon | None = index.data(FileListModel.Role.Decoration2)
        font: QFont = index.data(Qt.ItemDataRole.FontRole)
        fullText: str = index.data(Qt.ItemDataRole.DisplayRole)
        searchTerm: str = widget.searchBar.provider.term()

        # Prepare icon and text rects
        rect = QRect(option.rect)
        rect.adjust(2, 0, -2, 0)

        # Begin painting
        painter.save()
        if font:
            painter.setFont(font)
        fontMetrics = painter.fontMetrics()

        # Draw default background
        widget.style().drawControl(QStyle.ControlElement.CE_ItemViewItem, option, painter, widget)

        # Draw icon
        for i in [icon, emblem]:
            if i is None:
                continue
            iconRect = QRect(rect)
            iconRect.setWidth(option.decorationSize.width())
            i.paint(painter, iconRect, option.decorationAlignment)
            rect.setLeft(iconRect.right() + 4)

        # Prepare elided text
        text = fontMetrics.elidedText(fullText, option.textElideMode, rect.width())

        # Split path into directory and filename for better readability
        isFileNameFirst = settings.prefs.pathDisplayStyle == PathDisplayStyle.FileNameFirst
        if isFileNameFirst:
            parts = text.split('\0', 1)
        else:
            slash = text.rfind('/')
            if slash < fullText.rfind('/'):
                # The last slash is either elided, or it's after the ellipsis.
                ellipsis = text.rfind('\u2026')
                slash = max(slash, ellipsis)
            parts = [text[:slash + 1], text[slash + 1:]]

        # Draw text parts
        textRectBackup = QRect(rect)
        for partNo, part in enumerate(parts):
            isDirectoryPart = isFileNameFirst == (partNo > 0)

            colorRole = (QPalette.ColorRole.HighlightedText if isSelected
                         else QPalette.ColorRole.PlaceholderText if isDirectoryPart
                         else QPalette.ColorRole.WindowText)

            textColor = option.palette.color(colorGroup, colorRole)
            if isSelected and isDirectoryPart:
                textColor.setAlphaF(0.7)

            painter.setPen(textColor)
            painter.drawText(rect, option.displayAlignment, part)

            # Prepare rect for next part
            partWidth = fontMetrics.horizontalAdvance(part)
            rect.setLeft(rect.left() + partWidth)

        # Restore text rect
        rect = textRectBackup

        # Highlight search term
        if searchTerm and searchTerm in fullText.lower():
            needlePos = text.lower().find(searchTerm)
            if needlePos < 0:
                needlePos = text.find("\u2026")  # unicode ellipsis character (...)
                needleLen = 1
            else:
                needleLen = len(searchTerm)

            SearchBar.highlightNeedle(painter, rect, text, needlePos, needleLen)

        # Finish painting
        painter.restore()


class FileList(QListView):
    nothingClicked = Signal()
    """ Only emitted if the widget has focus. """
    selectedCountChanged = Signal(int)
    openSubRepo = Signal(str)
    statusMessage = Signal(str)

    repoModel: RepoModel

    _selectionBackup: list[str]
    """
    Backup of selected paths before refreshing the view.
    """

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

        self.repoModel = repoModel

        self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.customContextMenuRequested.connect(self.onContextMenuRequested)

        flModel = FileListModel(self, navContext)
        self.setModel(flModel)
        self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)

        self._selectionBackup = []

        self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        iconSize = self.fontMetrics().height()
        self.setIconSize(QSize(iconSize, iconSize))
        self.setTextElideMode(Qt.TextElideMode.ElideMiddle)
        self.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)  # prevent editing text after double-clicking
        self.setUniformItemSizes(True)  # potential perf boost with many files

        searchProvider = ItemViewSearchProvider(self)
        searchProvider.dataRole = FileListModel.Role.FilePath

        self.searchBar = SearchBar(self, searchProvider)
        self.searchBar.ui.forwardButton.hide()
        self.searchBar.ui.backwardButton.hide()
        self.searchBar.hide()
        flModel.modelAboutToBeReset.connect(self.searchBar.reevaluateSearchTerm)

        # Search result highlighter
        self.setItemDelegate(FileListDelegate(self))

        GFApplication.instance().prefsChanged.connect(self.refreshPrefs)
        self.refreshPrefs()

        makeWidgetShortcut(self, self.searchBar.hideOrBeep, "Escape")
        makeWidgetShortcut(self, self.copyPaths, QKeySequence.StandardKey.Copy)

    def refreshPrefs(self):
        self.setVerticalScrollMode(settings.prefs.listViewScrollMode)
        nameFirst = settings.prefs.pathDisplayStyle == PathDisplayStyle.FileNameFirst
        self.setTextElideMode(Qt.TextElideMode.ElideRight if nameFirst else Qt.TextElideMode.ElideMiddle)

    @property
    def repo(self) -> Repo:
        return self.repoModel.repo

    @property
    def navContext(self) -> NavContext:
        return self.flModel.navContext

    @property
    def flModel(self) -> FileListModel:
        model = self.model()
        assert isinstance(model, FileListModel)
        return model

    def isEmpty(self):
        return self.model().rowCount() == 0

    def setContents(self, deltas: Iterable[GitDelta]):
        self.flModel.setContents(deltas)
        self.updateFocusPolicy()
        self.searchBar.reevaluateSearchTerm()

    def clear(self):
        self.flModel.clear()
        assert self.isEmpty()
        self.updateFocusPolicy()

    def updateFocusPolicy(self):
        focusPolicy = Qt.FocusPolicy.StrongFocus if not self.isEmpty() else Qt.FocusPolicy.ClickFocus
        self.setFocusPolicy(focusPolicy)

    # -------------------------------------------------------------------------
    # Context menu

    def makeContextMenu(self):
        deltas = list(self.selectedDeltas())
        if len(deltas) == 0:
            return None

        actions = self.contextMenuActions(deltas)
        menu = ActionDef.makeQMenu(self, actions)
        menu.setObjectName("FileListContextMenu")
        return menu

    def onContextMenuRequested(self, point: QPoint):
        menu = self.makeContextMenu()
        if menu is not None:
            menu.aboutToHide.connect(menu.deleteLater)
            menu.popup(self.mapToGlobal(point))

    def contextMenuActions(self, deltas: list[GitDelta]) -> list[ActionDef]:
        """ To be overridden """

        def pathDisplayStyleAction(pds: PathDisplayStyle):
            return ActionDef(
                englishTitleCase(TrTables.enum(pds)),
                lambda: GFApplication.applyPrefs(pathDisplayStyle=pds),
                checkState=settings.prefs.pathDisplayStyle == pds)

        n = len(deltas)

        actions = [
            ActionDef.SEPARATOR,

            ActionDef(
                _n("Open &Folder", "Open {n} &Folders", n),
                self.showInFolder,
                "SP_DirIcon",
            ),

            ActionDef(
                _n("&Copy Path", "&Copy {n} Paths", n),
                self.copyPaths,
                shortcuts=QKeySequence.StandardKey.Copy,
            ),

            ActionDef(
                englishTitleCase(_("Path display style")),
                submenu=[pathDisplayStyleAction(style) for style in PathDisplayStyle],
            ),
        ]

        actions.extend(GFApplication.instance().mainWindow.contextualUserCommands(
            UserCommand.Token.File,
            UserCommand.Token.FileDir,
            UserCommand.Token.FileAbs,
            UserCommand.Token.FileDirAbs,
        ))

        return actions

    def contextMenuActionStash(self):
        return ActionDef(
            _("Stas&h Changes…"),
            self.wantPartialStash,
            icon="git-stash-black",
            shortcuts=TaskBook.shortcuts.get(NewStash, []))

    def contextMenuActionRevertMode(self, deltas: list[GitDelta], callback: Callable, ellipsis=True) -> ActionDef:
        n = len(deltas)
        action = ActionDef(_n("Revert Mode Change", "Revert Mode Changes", n), callback, enabled=False)

        # Scan deltas for mode changes
        for delta in deltas:
            # Scan for Modified, Renamed, or Copied
            if delta.status not in "MRC":
                continue

            # Skip if mode didn't change
            if delta.old.mode == delta.new.mode:
                continue

            # It has to be a mode we can actually revert
            if delta.new.mode not in [FileMode.BLOB, FileMode.BLOB_EXECUTABLE]:
                continue

            action.enabled = True

            # Set specific caption if it's a single item
            if n != 1:
                pass
            elif delta.new.mode == FileMode.BLOB_EXECUTABLE:
                action.caption = _("Revert Mode to Non-Executable")
            elif delta.new.mode == FileMode.BLOB:
                action.caption = _("Revert Mode to Executable")

        if ellipsis:
            action.caption += "…"

        return action

    def contextMenuActionsDiff(self, deltas: list[GitDelta]) -> list[ActionDef]:
        n = len(deltas)

        return [
            ActionDef(
                _("Open Diff in {0}", settings.getDiffToolName()),
                self.wantOpenInDiffTool,
                icon="vcs-diff"),

            ActionDef(
                _n("E&xport Diff As Patch…", "E&xport Diffs As Patch…", n),
                self.savePatchAs),
        ]

    def contextMenuActionsEdit(self, deltas: list[GitDelta]) -> list[ActionDef]:
        n = len(deltas)

        return [
            ActionDef(
                _("&Edit in {tool}", tool=settings.getExternalEditorName()),
                self.openWorkdirFile,
                icon="SP_FileIcon"),

            ActionDef(
                _n("Edit &HEAD Version in {tool}", "Edit &HEAD Versions in {tool}", n=n, tool=settings.getExternalEditorName()),
                self.openHeadRevision),
        ]

    def contextMenuActionBlame(self, deltas: list[GitDelta]) -> ActionDef:
        isEnabled = False
        if len(deltas) == 1:
            delta = deltas[0]
            assert delta.context == self.navContext
            isEnabled = (not delta.context.isWorkdir()) or (delta.status not in "?A")

        return ActionDef(
            englishTitleCase(OpenBlame.name()) + "\u2026",
            self.blameFile,
            icon=TaskBook.icons[OpenBlame],
            enabled=isEnabled,
            shortcuts=TaskBook.shortcuts[OpenBlame],
        )

    # -------------------------------------------------------------------------

    def confirmBatch(self, callback: Callable[[GitDelta], None], title: str, prompt: str, threshold: int = 3):
        deltas = list(self.selectedDeltas())
        numFiles = len(deltas)

        def runBatch():
            errors = MultiFileError()

            for delta in deltas:
                try:
                    callback(delta)
                    errors.add_file_success()
                except (OSError,  # typically FileNotFoundError
                        LfsObjectCacheMissingError
                        ) as exc:
                    errors.add_file_error(delta.new.path, exc)

            if errors:
                showMultiFileErrorMessage(self, errors, title)

        if numFiles <= threshold:
            runBatch()
            return

        qmb = askConfirmation(
            self,
            title,
            prompt.format(n=numFiles),
            runBatch,
            QMessageBox.StandardButton.YesToAll | QMessageBox.StandardButton.Cancel,
            show=False)

        addULToMessageBox(qmb, [d.new.path for d in deltas])

        qmb.show()

    def openWorkdirFile(self):
        def run(delta: GitDelta):
            entryPath = self.repo.in_workdir(delta.new.path)
            ToolProcess.startTextEditor(self, entryPath)

        self.confirmBatch(run, _("Open in external editor"),
                          _("Really open <b>{n} files</b> in external editor?"))

    def wantOpenInDiffTool(self):
        self.confirmBatch(self._openInDiffTool, _("Open in external diff tool"),
                          _("Really open <b>{n} files</b> in external diff tool?"))

    def _openInDiffTool(self, delta: GitDelta):
        if delta.new.isId0():
            raise FileNotFoundError(_("Can’t open external diff tool on a deleted file."))

        if delta.old.isId0():
            raise FileNotFoundError(_("Can’t open external diff tool on a new file."))

        diffDir = qTempDir()
        repo = self.repo

        if delta.context == NavContext.UNSTAGED:
            # Unstaged: compare indexed state to workdir file
            oldPath = delta.old.dump(repo, diffDir, "[INDEXED]")
            newPath = repo.in_workdir(delta.new.path)
        elif delta.context == NavContext.STAGED:
            # Staged: compare HEAD state to indexed state
            oldPath = delta.old.dump(repo, diffDir, "[HEAD]")
            newPath = delta.new.dump(repo, diffDir, "[STAGED]")
        elif delta.context == NavContext.COMMITTED:
            # Committed: compare parent state to this commit
            oldPath = delta.old.dump(repo, diffDir, "[OLD]")
            newPath = delta.new.dump(repo, diffDir, "[NEW]")
        else:
            raise NotImplementedError(f"unsupported context {delta.context}")

        return ToolProcess.startDiffTool(self, oldPath, newPath)

    def showInFolder(self):
        def run(delta: GitDelta):
            path = self.repo.in_workdir(delta.new.path)
            path = os.path.normpath(path)  # get rid of any trailing slashes (submodules)
            if not os.path.exists(path):  # check exists, not isfile, for submodules
                raise FileNotFoundError(_("File doesn’t exist at this path anymore."))
            showInFolder(path)

        self.confirmBatch(run, _("Open paths"),
                          _("Really open <b>{n} folders</b>?"))

    def copyPaths(self):
        text = '\n'.join(self.repo.in_workdir(path) for path in self.selectedPaths())
        if not text:
            QApplication.beep()
            return

        if WINDOWS:  # Ensure backslash directory separators
            from pathlib import Path
            path = Path(text)
            text = str(path)

        QApplication.clipboard().setText(text)
        self.statusMessage.emit(clipboardStatusMessage(text))

    def selectRow(self, rowNumber=0):
        if self.model().rowCount() == 0:
            self.emitNothingClicked()
            self.clearSelection()
        else:
            self.setCurrentIndex(self.model().index(rowNumber or 0, 0))

    def emitNothingClicked(self):
        if self.hasFocus():
            self.nothingClicked.emit()

    def selectionChanged(self, justSelected: QItemSelection, justDeselected: QItemSelection):
        super().selectionChanged(justSelected, justDeselected)

        # We're the active FileList, clear counterpart.
        self._setCounterpart(-1)

        # Don't bother emitting signals if we're blocked
        if self.signalsBlocked():
            return

        selectedIndexes = self.selectedIndexes()
        numSelectedTotal = len(selectedIndexes)

        justSelectedIndexes = list(justSelected.indexes())
        if justSelectedIndexes:
            current = justSelectedIndexes[0]
        else:
            # Deselecting (e.g. with shift/ctrl) doesn't necessarily mean that the selection has been emptied.
            # Find an index that is still selected to keep the DiffView in sync with the selection.
            current = self.currentIndex()

            if current.isValid() and selectedIndexes:
                # currentIndex may be outside the selection, find the selected index that is closest to currentIndex.
                current = min(selectedIndexes, key=lambda index: abs(index.row() - current.row()))
            else:
                current = None

        self.selectedCountChanged.emit(numSelectedTotal)

        if not current or not current.isValid():
            self.emitNothingClicked()
            return

        locator: NavLocator = self.flModel.data(current, FileListModel.Role.Locator)
        locator = locator.withExtraFlags(NavFlags.BypassFileSelect)
        Jump.invoke(self, locator)

    def highlightCounterpart(self, loc: NavLocator):
        try:
            row = self.flModel.getRowForFile(loc.path)
        except KeyError:
            row = -1
        self._setCounterpart(row)

    def _setCounterpart(self, newRow: int):
        model = self.flModel
        oldRow = model.highlightedCounterpartRow

        if oldRow == newRow:
            return

        model.highlightedCounterpartRow = newRow

        if oldRow >= 0:
            oldIndex = model.index(oldRow, 0)
            self.update(oldIndex)

        if newRow >= 0:
            newIndex = model.index(newRow, 0)
            self.selectionModel().setCurrentIndex(newIndex, QItemSelectionModel.SelectionFlag.NoUpdate)
            self.update(newIndex)

    def mouseMoveEvent(self, event: QMouseEvent):
        """
        By default, ExtendedSelection lets the user select multiple items by
        holding down LMB and dragging. This event handler enforces single-item
        selection unless the user holds down Shift or Ctrl.
        """
        isLMB = bool(event.buttons() & Qt.MouseButton.LeftButton)
        isShift = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
        isCtrl = bool(event.modifiers() & Qt.KeyboardModifier.ControlModifier)

        if isLMB and not isShift and not isCtrl:
            self.mousePressEvent(event)  # re-route event as if it were a click event
            self.scrollTo(self.indexAt(event.pos()))  # mousePressEvent won't scroll to the item on its own
        else:
            super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event: QMouseEvent):
        super().mouseReleaseEvent(event)  # Let standard QListView selection occur first
        if event.button() == Qt.MouseButton.MiddleButton:
            self.onSpecialClick("middle")

    def mouseDoubleClickEvent(self, event: QMouseEvent):
        super().mouseDoubleClickEvent(event)  # Let standard QListView selection occur first
        if event.button() == Qt.MouseButton.LeftButton:
            self.onSpecialClick("double")

    def onSpecialClick(self, click: Literal["middle", "double"]):
        if click == "double":
            action = settings.prefs.doubleClickFileList
        elif click == "middle":
            action = settings.prefs.middleClickFileList
        else:
            raise NotImplementedError(f"unknown special click kind '{click}'")

        if action == FileListClick.Nothing:
            pass
        elif action == FileListClick.Folder:
            self.showInFolder()
        elif action == FileListClick.Blame:
            self.blameFile()
        elif action == FileListClick.Edit:
            self.openWorkdirFile()
        elif action == FileListClick.DiffTool:
            self.wantOpenInDiffTool()
        elif action == FileListClick.Stage:
            if self.navContext == NavContext.UNSTAGED:
                self.stage()
            elif self.navContext == NavContext.STAGED:
                self.unstage()
            else:
                QApplication.beep()
        else:
            raise NotImplementedError(f"unknown special click action '{click}'")

    def selectedDeltas(self) -> Generator[GitDelta, None, None]:
        for index in self.selectedIndexes():
            yield index.data(FileListModel.Role.Delta)

    def selectedPaths(self) -> Generator[str, None, None]:
        for index in self.selectedIndexes():
            yield index.data(FileListModel.Role.FilePath)

    def earliestSelectedRow(self):
        try:
            return list(self.selectedIndexes())[0].row()
        except IndexError:
            return -1

    def savePatchAs(self):
        deltas = list(self.selectedDeltas())
        ExportPatchCollection.invoke(self, deltas)

    def revertPaths(self):
        # TODO: Convert into a task? (So we can build the patch asynchronously)
        deltas = list(self.selectedDeltas())
        assert len(deltas) == 1
        delta = deltas[0]
        tokens = GitDriver.buildDiffCommand(delta)
        patchData = GitDriver.runSync(*tokens, directory=self.repo.workdir, strict=True)
        ApplyPatchData.invoke(self, patchData, reverse=True,
                              title=_("Revert changes in file"),
                              question=_("Do you want to revert this patch?"))

    def firstPath(self) -> str:
        index: QModelIndex = self.flModel.index(0)
        if index.isValid():
            return index.data(FileListModel.Role.FilePath)
        else:
            return ""

    def selectFile(self, file: str) -> bool:
        if not file:
            return False

        try:
            row = self.flModel.getRowForFile(file)
        except KeyError:
            return False

        if self.selectionModel().isRowSelected(row):
            # Re-selecting an already selected row may deselect it??
            return True

        self.selectRow(row)
        return True

    def deltaForFile(self, file: str) -> GitDelta:
        row = self.flModel.getRowForFile(file)
        return self.flModel.deltas[row]

    def openHeadRevision(self):
        def run(delta: GitDelta):
            tempPath = delta.old.dump(self.repo, qTempDir(), "[HEAD]")
            ToolProcess.startTextEditor(self, tempPath)

        self.confirmBatch(run, _("Open HEAD version of file"),
                          _("Really open <b>{n} files</b> in external editor?"))

    def wantPartialStash(self):
        paths = set()
        for delta in self.selectedDeltas():
            # Add both old and new paths so that both are pre-selected
            # if we're stashing a rename.
            paths.add(delta.old.path)
            paths.add(delta.new.path)
        NewStash.invoke(self, list(paths))

    def openSubmoduleTabs(self):
        for delta in self.selectedDeltas():
            if delta.isSubtreeCommitPatch():
                self.openSubRepo.emit(delta.new.path)

    def backUpSelection(self):
        oldSelected = list(self.selectedPaths())
        self._selectionBackup = oldSelected

    def clearSelectionBackup(self):
        self._selectionBackup = []

    def restoreSelectionBackup(self):
        if not self._selectionBackup:
            return False

        paths = self._selectionBackup
        self._selectionBackup = []

        currentIndex: QModelIndex = self.currentIndex()
        cPath = currentIndex.data(FileListModel.Role.FilePath)

        if cPath not in paths:
            # Don't attempt to restore if we've jumped to another file
            return False

        if len(paths) == 1 and paths[0] == cPath:
            # Don't bother if the one file that we've selected is still the current one
            return False

        flModel = self.flModel
        selectionModel = self.selectionModel()
        SF = QItemSelectionModel.SelectionFlag

        with QSignalBlockerContext(self):
            # If we directly manipulate the QItemSelectionModel by calling .select() row-by-row,
            # then shift-selection may act counter-intuitively if the selection was discontiguous.
            # Preparing a QItemSelection upfront mitigates the strange shift-select behavior.
            newItemSelection = QItemSelection()
            for path in paths:
                with suppress(KeyError):
                    row = flModel.fileRows[path]
                    index = flModel.index(row, 0)
                    newItemSelection.select(index, index)
            selectionModel.clearSelection()
            selectionModel.select(newItemSelection, SF.Rows | SF.Select)
            selectionModel.setCurrentIndex(currentIndex, SF.Rows | SF.Current)

        return True

    def blameFile(self):
        def run(delta: GitDelta):
            if delta.context.isWorkdir():
                path = delta.old.path
                commit = NULL_OID
            else:
                path = delta.new.path
                commit = delta.new.sourceCommit
            OpenBlame.invoke(self, path, commit)

        # TODO: For now, only one blame at a time is supported
        try:
            delta = next(self.selectedDeltas())
            run(delta)
        except StopIteration:
            QApplication.beep()
