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

import logging

from gitfourchette import settings
from gitfourchette.application import GFApplication
from gitfourchette.codeview.codegutter import CodeGutter
from gitfourchette.codeview.codehighlighter import CodeHighlighter
from gitfourchette.codeview.coderubberband import CodeRubberBand
from gitfourchette.codeview.codesearch import CodeSearch
from gitfourchette.diffview.diffdocument import DiffTextFormats
from gitfourchette.forms.searchbar import SearchBar
from gitfourchette.globalshortcuts import GlobalShortcuts
from gitfourchette.localization import *
from gitfourchette.nav import NavLocator
from gitfourchette.qt import *
from gitfourchette.syntax import ColorScheme
from gitfourchette.toolbox import *

logger = logging.getLogger(__name__)


class CodeView(QPlainTextEdit):
    contextualHelp = Signal(str)
    selectionActionable = Signal(bool)
    visibilityChanged = Signal(bool)
    sizeChanged = Signal()

    highlighter: CodeHighlighter
    gutter: CodeGutter
    currentLocator: NavLocator
    isDetachedWindow: bool

    FormattingMarkFlags = QTextOption.Flag.ShowTabsAndSpaces

    def __init__(
            self,
            gutterClass: type[CodeGutter] = CodeGutter,
            highlighterClass: type[CodeHighlighter] = CodeHighlighter,
            parent=None
    ):
        super().__init__(parent)

        self.setReadOnly(True)
        self.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse | Qt.TextInteractionFlag.TextSelectableByKeyboard)

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

        self.currentLocator = NavLocator()
        self.isDetachedWindow = False

        # Highlighter for search terms
        self.highlighter = highlighterClass(self)
        self.highlighter.setDocument(self.document())
        self.visibilityChanged.connect(self.highlighter.onParentVisibilityChanged)

        self.gutter = gutterClass(self)
        self.gutter.customContextMenuRequested.connect(self.onContextMenuRequestedFromGutter)
        self.updateRequest.connect(self.gutter.onParentUpdateRequest)
        # self.blockCountChanged.connect(self.updateGutterWidth)
        self.syncViewportMarginsWithGutter()

        self.cursorPositionChanged.connect(self.updateRubberBand)
        self.selectionChanged.connect(self.updateRubberBand)

        searchProvider = CodeSearch(self)

        self.searchBar = SearchBar(self, searchProvider)
        self.searchBar.hide()

        self.rubberBand = CodeRubberBand(self.viewport())
        self.rubberBand.hide()

        self.rubberBandButtonGroup = QWidget(parent=self.viewport())
        rubberBandButtonLayout = QHBoxLayout(self.rubberBandButtonGroup)
        rubberBandButtonLayout.setSpacing(0)
        rubberBandButtonLayout.setContentsMargins(0, 0, 0, 0)
        self.rubberBandButtonGroup.hide()

        self.verticalScrollBar().valueChanged.connect(self.updateRubberBand)
        self.horizontalScrollBar().valueChanged.connect(self.updateRubberBand)

        # Initialize font & styling
        GFApplication.instance().restyle.connect(self.refreshPrefs)
        GFApplication.instance().prefsChanged.connect(self.refreshPrefs)
        self.refreshPrefs()

        makeWidgetShortcut(self, self.onEscapeKey, "Escape")

    def setUpAsDetachedWindow(self):
        # In a detached window, we can't rely on the main window's menu bar to
        # dispatch shortcuts to us (except on macOS, which has a global main menu).

        self.isDetachedWindow = True

        bar = self.searchBar
        makeWidgetShortcut(self, lambda: bar.popUp(SearchBar.Op.Start), *GlobalShortcuts.find)
        makeWidgetShortcut(self, lambda: bar.popUp(SearchBar.Op.Next), *GlobalShortcuts.findNext)
        makeWidgetShortcut(self, lambda: bar.popUp(SearchBar.Op.Previous), *GlobalShortcuts.findPrevious)

        makeWidgetShortcut(self, lambda: self.window().close(), QKeySequence.StandardKey.Close,
                           context=Qt.ShortcutContext.WindowShortcut)

    # ---------------------------------------------
    # Qt events

    def resizeEvent(self, event: QResizeEvent):
        super().resizeEvent(event)
        self.resizeGutter()
        self.updateRubberBand()
        self.sizeChanged.emit()

    def wheelEvent(self, event: QWheelEvent):
        # Drop-in replacement for QPlainTextEdit::wheelEvent which scales text
        # on ctrl+wheel. The vanilla version doesn't emit a signal, but we need
        # to percolate the new font to the gutter & rubberband.
        # See https://github.com/qt/qtbase/blob/6.7.2/src/widgets/widgets/qplaintextedit.cpp#L2327
        if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
            self.wheelZoom(event.angleDelta().y())
            return

        super().wheelEvent(event)

    def wheelZoom(self, delta: int):
        delta //= 120
        font = self.font()
        oldSize = font.pointSizeF()
        newSize = oldSize + delta
        newSize = max(4, newSize)
        if newSize == oldSize:
            return
        font.setPointSizeF(newSize)
        self.setFont(font)
        self.gutter.syncFont(font)
        self.syncViewportMarginsWithGutter()
        self.updateRubberBand()

    def focusInEvent(self, event: QFocusEvent):
        self.rubberBand.repaint()
        super().focusInEvent(event)

    def focusOutEvent(self, event: QFocusEvent):
        self.rubberBand.repaint()
        super().focusOutEvent(event)

    def showEvent(self, event: QShowEvent):
        super().showEvent(event)
        self.visibilityChanged.emit(True)

    def hideEvent(self, event: QHideEvent):
        super().hideEvent(event)
        self.visibilityChanged.emit(False)

    def onEscapeKey(self):
        if self.isDetachedWindow and not self.searchBar.isVisible():
            self.window().close()
        else:
            self.searchBar.hideOrBeep()

    # ---------------------------------------------
    # Document replacement

    def clear(self):  # override
        # Clear info about the current patch - necessary for document reuse detection to be correct when the user
        # clears the selection in a FileList and then reselects the last-displayed document.
        self.currentLocator = NavLocator()

        # Clear the actual contents
        super().clear()

    # ---------------------------------------------
    # Restore position

    def restorePosition(self, locator: NavLocator):
        # Get position at start/end of line
        block: QTextBlock = self.document().findBlockByNumber(locator.cursorLine)
        sol = block.position()
        eol = block.position() + block.length()

        # If cursor position still falls within the same line, keep that position.
        # Otherwise, snap cursor position to start of line.
        pos = locator.cursorChar
        if not (sol <= pos < eol):
            pos = sol

        # Move text cursor
        newTextCursor = self.textCursor()
        newTextCursor.setPosition(pos)
        self.setTextCursor(newTextCursor)

        # Restore the scrollbar
        self.restoreScrollPosition(locator.scrollChar)

    def restoreScrollPosition(self, topCharacter: int) -> int:
        scrollValue = self._findScrollPosition(topCharacter)
        self.verticalScrollBar().setValue(scrollValue)
        return scrollValue

    def _findScrollPosition(self, topCharacter: int) -> int:
        """
        Return a value to be set on the vertical scrollbar so that `topCharacter`
        is part of the first visible line in the viewport.
        """
        if topCharacter == 0:
            return 0

        topCursor = self.textCursor()
        topCursor.setPosition(topCharacter)

        scrollBar = self.verticalScrollBar()

        # If line wrapping is OFF, QScrollBar.value() perfectly matches up with
        # line numbers.
        if self.lineWrapMode() == QPlainTextEdit.LineWrapMode.NoWrap:
            return topCursor.blockNumber()

        # If line wrapping is ON, we can't simply cache and restore QScrollBar.value().
        # It seems that QPlainTextEdit doesn't wrap lines that aren't visible yet,
        # so the scrollbar's range isn't reliable until the desired line is in view.

        # First, center the viewport on the desired top character.
        backupCursor = self.textCursor()
        self.setTextCursor(topCursor)
        self.centerCursor()

        # Scroll down one notch at a time until topCharacter becomes part of
        # the top visible line in the viewport.
        cornerPixel = self.topLeftCornerPixel()
        scrollValue = 0
        i = 0
        while i < 500 and self.cursorForPosition(cornerPixel).position() < topCharacter:
            i += 1
            scrollValue = scrollBar.value() + 1
            scrollBar.setValue(scrollValue)
            if scrollBar.value() < scrollValue:  # Can't scroll past end of document
                break

        # logger.debug(f"Stabilized in {i} iterations - final scroll {scrollValue} - "
        #              f"char pos {self.cursorForPosition(cornerPixel).position()} vs {topCharacter}")

        # Restore backup cursor
        self.setTextCursor(backupCursor)

        return scrollValue

    def topLeftCornerPixel(self) -> QPoint:
        return QPoint(0, self.fontMetrics().height() // 2)

    def topLeftCornerCursor(self) -> QTextCursor:
        cornerPixel = self.topLeftCornerPixel()
        cornerCursor = self.cursorForPosition(cornerPixel)
        return cornerCursor

    def topLeftCornerCharacter(self) -> int:
        cornerCursor = self.topLeftCornerCursor()
        return cornerCursor.position()

    def preciseLocator(self) -> NavLocator:
        scrollChar = self.topLeftCornerCharacter()
        textCursor = self.textCursor()

        locator = self.currentLocator.coarse()
        locator = locator.replace(
            cursorChar=textCursor.position(),
            cursorLine=textCursor.blockNumber(),
            scrollChar=scrollChar)

        return locator

    # ---------------------------------------------
    # Prefs

    def refreshPrefs(self, changeColorScheme=True):
        monoFont = settings.prefs.monoFont()
        self.setFont(monoFont)

        currentDocument = self.document()
        if currentDocument:
            currentDocument.setDefaultFont(monoFont)

        tabWidth = settings.prefs.tabSpaces
        self.setTabStopDistance(QFontMetricsF(monoFont).horizontalAdvance(' ' * tabWidth))
        self.refreshWordWrap()
        self.refreshShowWhitespace()
        self.setCursorWidth(2)

        self.gutter.syncFont(monoFont)
        self.syncViewportMarginsWithGutter()

        if changeColorScheme:
            scheme = settings.prefs.syntaxHighlightingScheme()
            self.setColorScheme(scheme)

    def setColorScheme(self, scheme: ColorScheme):
        DiffTextFormats.refresh(scheme, settings.prefs.colorblind)

        self.highlighter.setColorScheme(scheme)
        self.highlighter.rehighlight()

        # See selection-background-color in .qss asset.
        dark = scheme.isDark() if scheme else isDarkTheme()
        self.setProperty("dark", "true" if dark else "false")

        # Had better luck setting colors with a stylesheet than via setPalette().
        styleSheet = scheme.basicQss(self)
        self.setStyleSheet(styleSheet)

    def refreshWordWrap(self):
        if settings.prefs.wordWrap:
            wrapMode = QPlainTextEdit.LineWrapMode.WidgetWidth
        else:
            wrapMode = QPlainTextEdit.LineWrapMode.NoWrap

        # Bail if no-op
        if self.lineWrapMode() == wrapMode:
            return

        # Changing the wrap mode will trash our scroll position, so remember where we are.
        topCharacter = self.topLeftCornerCharacter()

        self.setLineWrapMode(wrapMode)
        self.restoreScrollPosition(topCharacter)

    def refreshShowWhitespace(self):
        doc = self.document()
        if doc is None:
            return
        opt = QTextOption(doc.defaultTextOption())
        flags = opt.flags()
        if settings.prefs.showWhitespace:
            flags |= self.FormattingMarkFlags
        else:
            flags &= ~self.FormattingMarkFlags
        opt.setFlags(flags)
        doc.setDefaultTextOption(opt)

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

    def contextMenuActions(self, clickedCursor: QTextCursor) -> list[ActionDef]:
        if type(self) is not CodeView:  # pragma: no cover
            raise NotImplementedError("CodeView subclasses must override this function")

        return []

    def onContextMenuRequested(self, point: QPoint):
        # Don't show the context menu if we're empty
        if self.document().isEmpty():
            return

        # Get standard context menu (copy, select all, etc.)
        bottomMenu: QMenu = self.createStandardContextMenu()

        # Get position of click in document
        clickedCursor = self.cursorForPosition(point)

        # Get actions from concrete class
        specializedActions = self.contextMenuActions(clickedCursor)

        # Append common CodeView actions
        actions: list[ActionDef | QAction] = [
            *specializedActions,
            ActionDef.SEPARATOR,
            *[a for a in bottomMenu.actions() if not a.isSeparator()],
            ActionDef.SEPARATOR,
            ActionDef(_("Configure Appearance…"), lambda: GFApplication.instance().openPrefsDialog("font"), icon="configure"),
        ]

        # Create QMenu
        menu = ActionDef.makeQMenu(self, actions)
        menu.setObjectName("CodeViewContextMenu")
        menu.aboutToHide.connect(menu.deleteLater)

        # The master menu doesn't take ownership of bottomMenu's actions,
        # so bottomMenu must be kept alive until the master menu is closed.
        menu.destroyed.connect(bottomMenu.deleteLater)

        # Show QMenu
        menu.popup(self.viewport().mapToGlobal(point))

    def onContextMenuRequestedFromGutter(self, point: QPoint):
        point = self.gutter.mapToGlobal(point)
        point = self.viewport().mapFromGlobal(point)
        self.onContextMenuRequested(point)

    # ---------------------------------------------
    # Gutter

    def resizeGutter(self):
        cr: QRect = self.contentsRect()
        cr.setWidth(self.gutter.calcWidth())
        self.gutter.setGeometry(cr)

    def syncViewportMarginsWithGutter(self):
        self.gutter.refreshMetrics()
        gutterWidth = self.gutter.calcWidth()

        # Prevent Qt freeze if margin width exceeds widget width, e.g. when window is very narrow
        # (especially prevalent with word wrap?)
        self.setMinimumWidth(gutterWidth * 2)

        self.setViewportMargins(gutterWidth, 0, 0, 0)

    # ---------------------------------------------
    # Rubberband

    def updateRubberBand(self):
        pass

    # ---------------------------------------------
    # Cursor/selection

    def getSelectedLineExtents(self) -> tuple[int, int]:
        """ Return block numbers of the first and last blocks encompassing the current selection """

        cursor: QTextCursor = self.textCursor()
        posStart = cursor.selectionStart()
        posEnd = cursor.selectionEnd()

        assert posStart >= 0
        assert posEnd >= 0

        document: QTextDocument = self.document()
        startBlock = document.findBlock(posStart).blockNumber()
        endBlock = document.findBlock(posEnd).blockNumber()

        return startBlock, endBlock

    def getMaxPosition(self) -> int:
        lastBlock = self.document().lastBlock()
        return lastBlock.position() + max(0, lastBlock.length() - 1)

    def getAnchorHomeLinePosition(self) -> int:
        cursor: QTextCursor = self.textCursor()

        # Snap anchor to start of home line
        cursor.setPosition(cursor.anchor(), QTextCursor.MoveMode.MoveAnchor)
        cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock, QTextCursor.MoveMode.MoveAnchor)

        return cursor.anchor()

    def getStartOfLineAt(self, point: QPoint) -> int:
        clickedCursor: QTextCursor = self.cursorForPosition(point)
        clickedCursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
        return clickedCursor.position()

    def replaceCursor(self, cursor: QTextCursor):
        """Replace the cursor without moving the horizontal scroll bar"""
        with QScrollBackupContext(self.horizontalScrollBar()):
            self.setTextCursor(cursor)

    def selectWholeLineAt(self, point: QPoint):
        clickedPosition = self.getStartOfLineAt(point)

        cursor: QTextCursor = self.textCursor()
        cursor.setPosition(clickedPosition)
        cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)

        self.replaceCursor(cursor)

    def selectWholeLinesTo(self, point: QPoint):
        homeLinePosition = self.getAnchorHomeLinePosition()
        clickedPosition = self.getStartOfLineAt(point)

        cursor: QTextCursor = self.textCursor()

        if homeLinePosition <= clickedPosition:
            # Move anchor to START of home line
            cursor.setPosition(homeLinePosition, QTextCursor.MoveMode.MoveAnchor)
            # Move cursor to END of clicked line
            cursor.setPosition(clickedPosition, QTextCursor.MoveMode.KeepAnchor)
            cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
        else:
            # Move anchor to END of home line
            cursor.setPosition(homeLinePosition, QTextCursor.MoveMode.MoveAnchor)
            cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.MoveAnchor)
            # Move cursor to START of clicked line
            cursor.setPosition(clickedPosition, QTextCursor.MoveMode.KeepAnchor)
            cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock, QTextCursor.MoveMode.KeepAnchor)

        self.replaceCursor(cursor)

    @staticmethod
    def currentDetachedCodeView() -> CodeView:
        activeWindow = QApplication.activeWindow()
        if not activeWindow:
            raise KeyError("no active window")
        detachedCodeView: CodeView = activeWindow.findChild(CodeView)
        if detachedCodeView is None:
            raise KeyError("no detached code view")
        assert detachedCodeView.isDetachedWindow
        return detachedCodeView

    def idealDetachedSize(self) -> QSize:
        windowHeight = int(QApplication.primaryScreen().availableSize().height() * .8)
        windowWidth = (self.gutter.calcWidth()
                       + self.fontMetrics().horizontalAdvance("M" * 81)
                       + self.verticalScrollBar().width())
        return QSize(windowWidth, windowHeight)

