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

from gitfourchette import settings
from gitfourchette.application import GFApplication
from gitfourchette.localization import *
from gitfourchette.qt import *
from gitfourchette.toolbox import stockIcon
from gitfourchette.toolbox.qtutils import CallbackAccumulator, reevaluateStyleSheet


class QTabBar2(QTabBar):
    tabMiddleClicked = Signal(int)
    tabDoubleClicked = Signal(int)
    visibilityChanged = Signal(bool)
    wheelDelta = Signal(QPoint)
    layoutChanged = Signal()
    suggestScrollToCurrentTab = Signal()

    middleClickedIndex: int
    doubleClickedIndex: int
    shadowText: list[str]

    def __init__(self, parent: QWidget):
        super().__init__(parent)
        self.middleClickedIndex = -1
        self.doubleClickedIndex = -1
        self.setObjectName("QTabBar2")
        self.shadowText = []

        self.tabMoved.connect(self._onTabMoved)

    # -------------------------------------------------------------------------
    # Layout

    def tabSizeHint(self, index: int) -> QSize:
        # Keep individual tabs from getting too wide when several tabs compete
        # for real estate. Qt may ignore this hint when the tab bar is wide
        # enough to fit all tabs.

        try:
            # Avoid tabText() here - it may return pre-elided text!
            text = self.shadowText[index]
        except IndexError:
            # May occur until tabInserted is called
           text = self.tabText(index)

        hint = super().tabSizeHint(index)

        w = 32 + self.fontMetrics().horizontalAdvance(text)

        if self.tabsClosable():
            closeWidth = self.style().pixelMetric(QStyle.PixelMetric.PM_TabCloseIndicatorWidth)
            w += 2 * closeWidth

        w = min(w, 250)

        hint.setWidth(w)
        return hint

    def tabLayoutChange(self):
        self.layoutChanged.emit()

    # -------------------------------------------------------------------------
    # Mouse

    def mousePressEvent(self, event: QMouseEvent):
        if event.button() == Qt.MouseButton.MiddleButton:
            self.middleClickedIndex = self.tabAt(event.position().toPoint())
        else:
            self.middleClickedIndex = -1
        super().mousePressEvent(event)

    def mouseDoubleClickEvent(self, event: QMouseEvent):
        if event.button() == Qt.MouseButton.LeftButton:
            self.doubleClickedIndex = self.tabAt(event.position().toPoint())
        else:
            self.doubleClickedIndex = -1
        super().mouseDoubleClickEvent(event)

    def mouseMoveEvent(self, event: QMouseEvent):
        # Block double-click signal if mouse moved before releasing button
        self.doubleClickedIndex = -1
        super().mouseMoveEvent(event)

    def wheelEvent(self, event: QWheelEvent):
        self.wheelDelta.emit(event.angleDelta())

        # DO NOT forward mouse wheel events to superclass
        # (avoid default Qt behavior that switches tabs via scroll wheel)
        event.accept()

    def mouseReleaseEvent(self, event: QMouseEvent):
        runStandardHandler = True

        if event.buttons() != Qt.MouseButton.NoButton:
            # Signals will only fire if clicking a single button at a time
            pass

        elif event.button() == Qt.MouseButton.MiddleButton:
            i = self.tabAt(event.position().toPoint())
            if i >= 0 and i == self.middleClickedIndex:
                # Bypass standard mouseReleaseEvent to avoid hardcoded behavior
                # on PySide6+GNOME where middle-clicking a tab emits
                # tabCloseRequested, if tabsClosable is enabled.
                runStandardHandler = False
                self.tabMiddleClicked.emit(i)

        elif event.button() == Qt.MouseButton.LeftButton:
            i = self.tabAt(event.position().toPoint())
            if i >= 0 and i == self.doubleClickedIndex:
                runStandardHandler = False
                self.tabDoubleClicked.emit(i)

        self.middleClickedIndex = -1
        self.doubleClickedIndex = -1

        if runStandardHandler:
            super().mouseReleaseEvent(event)

    # -------------------------------------------------------------------------
    # Misc. events

    def focusInEvent(self, event: QFocusEvent):
        """Suggest scrolling to current tab when we receive keyboard focus"""
        super().focusInEvent(event)
        self.suggestScrollToCurrentTab.emit()

    def setVisible(self, visible: bool):
        """Forward setVisible to parent scroll area (when we get hidden due to only 1 tab remaining)"""
        super().setVisible(visible)
        self.visibilityChanged.emit(visible)

    # -------------------------------------------------------------------------
    # Shadow text bookkeeping - Needed because calling tabText() inside
    # tabSizeHint() returns elided text!

    def _onTabMoved(self, i: int, j: int):
        del self.shadowText[i]
        self.shadowText.insert(j, self.tabText(j))

    def tabInserted(self, index):
        self.shadowText.insert(index, self.tabText(index))
        super().tabInserted(index)

    def tabRemoved(self, index):
        del self.shadowText[index]
        super().tabRemoved(index)

    def setTabText(self, index: int, text: str):
        currentText = self.shadowText[index]

        # Overriding tab text can be expensive, so avoid if there's no change
        if currentText != text:
            self.shadowText[index] = text
            super().setTabText(index, text)


class QTabWidget2OverflowGradient(QWidget):
    def paintEvent(self, event: QPaintEvent):
        W = 16  # gradient width
        P = 4  # opaque padding inside the gradient

        scrollArea = self.parentWidget()
        assert isinstance(scrollArea, QAbstractScrollArea)
        scrollBar = scrollArea.horizontalScrollBar()

        saw = scrollArea.width()
        sah = scrollArea.height()

        opaque = self.palette().color(QPalette.ColorRole.Window)
        transp = QColor(opaque)
        transp.setAlpha(0)

        painter = QPainter(self)

        if scrollBar.value() != 0:
            gradient = QLinearGradient(P, 0, W, 0)
            gradient.setColorAt(0, opaque)
            gradient.setColorAt(1, transp)
            painter.fillRect(QRect(0, 0, W, sah-1), gradient)

        if scrollBar.value() < scrollBar.maximum()-1:
            gradient = QLinearGradient(saw-W, 0, saw-P, 0)
            gradient.setColorAt(0, transp)
            gradient.setColorAt(1, opaque)
            painter.fillRect(QRect(saw-W, 0, W, sah-1), gradient)


class QTabWidget2(QWidget):
    currentChanged: Signal = Signal(int)
    tabCloseRequested: Signal = Signal(int)
    tabMiddleClicked: Signal = Signal(int)
    tabDoubleClicked: Signal = Signal(int)
    tabContextMenuRequested: Signal = Signal(QPoint, int)

    currentWidgetChanged = Signal()
    """Emitted when the displayed widget is actually changed (in contrast to
    currentChanged, which is emitted even when dragging the foreground tab)."""

    UrgentPropertyName = "QTabBar2_UrgentFlag"

    def __init__(self, parent):
        super().__init__(parent)
        self.setObjectName("QTabWidget2")

        self.stacked = QStackedWidget(self)
        self.stacked.setObjectName("QTW2StackedWidget")

        self.shadowCurrentWidgetRef: weakref.ref[QWidget] = weakref.ref(self)
        """ Keep a weakref instead of a real reference to not impede GC of a
        dead widget. (A weakref on self stands in for "none".) """

        self.tabScrollArea = QScrollArea(self)
        self.tabScrollArea.setWidgetResizable(True)
        self.tabScrollArea.setFrameStyle(QFrame.Shape.NoFrame)
        self.tabScrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.tabScrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.tabScrollArea.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
        self.tabScrollArea.setFocusPolicy(Qt.FocusPolicy.NoFocus)

        self.tabs = QTabBar2(self.tabScrollArea)
        self.tabs.tabMoved.connect(self.onTabMoved)
        self.tabs.currentChanged.connect(self.onCurrentChanged)
        self.tabs.tabCloseRequested.connect(self.tabCloseRequested)
        self.tabs.tabMiddleClicked.connect(self.tabMiddleClicked)
        self.tabs.tabDoubleClicked.connect(self.tabDoubleClicked)
        self.tabs.suggestScrollToCurrentTab.connect(self.ensureCurrentTabVisible)
        self.tabs.setMovable(True)
        self.tabs.setDocumentMode(True)  # dramatically improves the tabs' appearance on macOS
        self.tabs.setUsesScrollButtons(False)  # can't have those with scroll area
        self.tabs.setElideMode(Qt.TextElideMode.ElideRight)

        self.overflowButton = QToolButton(self)
        self.overflowButton.setArrowType(Qt.ArrowType.DownArrow)
        self.overflowButton.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        self.overflowButton.setToolTip(_("List all tabs"))
        self.overflowButton.clicked.connect(self.onOverflowButtonClicked)
        self.overflowButton.setCheckable(True)
        self.overflowButton.setAutoRaise(True)
        self.overflowButton.hide()  # hiding now prevents jitter on boot because maximum height is adjuster later

        self.tabScrollArea.setWidget(self.tabs)
        self.tabs.visibilityChanged.connect(self.tabScrollArea.setVisible)
        self.tabs.wheelDelta.connect(self.scrollTabs)
        self.tabs.layoutChanged.connect(self.updateOverflowDropdown)

        self.overflowGradient = QTabWidget2OverflowGradient(self.tabScrollArea)
        self.overflowGradient.setObjectName("QTW2OverflowGradient")
        self.overflowGradient.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)

        topWidget = QWidget(self)
        self.topWidget = topWidget
        topLayout = QHBoxLayout(topWidget)
        topLayout.setSpacing(2)
        topLayout.setContentsMargins(0, 0, 0, 0)
        topLayout.addWidget(self.tabScrollArea)
        topLayout.addWidget(self.overflowButton)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)
        layout.addWidget(topWidget)
        layout.addWidget(self.stacked)

        self.tabs.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
        self.tabs.customContextMenuRequested.connect(self.onCustomContextMenuRequested)

        self.overflowMenu = QMenu(self)
        self.overflowMenu.setObjectName("QTW2OverflowMenu")
        self.overflowMenu.setToolTipsVisible(True)
        self.overflowMenu.aboutToHide.connect(lambda: self.overflowButton.setChecked(False))

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

    def __len__(self):
        return self.tabs.count()

    def refreshPrefs(self):
        mustReevaluateStyleSheet = settings.prefs.expandingTabs != self.tabs.expanding()

        self.tabs.setExpanding(settings.prefs.expandingTabs)
        self.tabs.setAutoHide(settings.prefs.autoHideTabs)
        self.tabs.setTabsClosable(settings.prefs.tabCloseButton)
        self.tabs.update()
        self.syncBarSize()
        self.onResize()
        self.updateOverflowDropdown()

        if mustReevaluateStyleSheet:
            reevaluateStyleSheet(self)

    def onCustomContextMenuRequested(self, localPoint: QPoint):
        globalPoint = self.tabs.mapToGlobal(localPoint)
        index = self.tabs.tabAt(localPoint)
        self.tabContextMenuRequested.emit(globalPoint, index)

    def onTabMoved(self, fromIndex: int, toIndex: int):
        # Keep QStackedWidget in sync with QTabBar
        w = self.stacked.widget(fromIndex)
        self.stacked.removeWidget(w)
        self.stacked.insertWidget(toIndex, w)

    def onCurrentChanged(self, i: int):
        # Keep QStackedWidget in sync with QTabBar
        self.stacked.setCurrentIndex(i)
        currentWidget = self.currentWidget()

        # Make sure the tab is visible within the scrollable area
        self.ensureCurrentTabVisible()

        # Remove urgent flag if any
        if currentWidget is not None and currentWidget.property(QTabWidget2.UrgentPropertyName):
            currentWidget.setProperty(QTabWidget2.UrgentPropertyName, None)
            self.tabs.setTabIcon(i, QIcon())  # clear icon

        # See if we should emit the currentWidgetChanged signal
        currentWidgetRef = weakref.ref(currentWidget or self)  # self stands in for None
        if currentWidgetRef != self.shadowCurrentWidgetRef:
            self.shadowCurrentWidgetRef = currentWidgetRef
            self.currentWidgetChanged.emit()

        # Forward signal
        self.currentChanged.emit(i)

    def addTab(self, w: QWidget, name: str) -> int:
        return self.insertTab(self.count(), w, name)

    def insertTab(self, index: int, w: QWidget, name: str) -> int:
        i1 = self.stacked.insertWidget(index, w)
        i2 = self.tabs.insertTab(index, name)
        self.syncBarSize()
        assert i1 == i2
        return i1

    def setCurrentIndex(self, i: int):
        self.tabs.setCurrentIndex(i)
        self.stacked.setCurrentIndex(i)
        self.tabs.update()

    def setCurrentWidget(self, widget: QWidget):
        i = self.indexOf(widget)
        assert i >= 0
        self.setCurrentIndex(i)

    def indexOf(self, widget: QWidget):
        return self.stacked.indexOf(widget)

    def widget(self, i: int):
        return self.stacked.widget(i)

    def currentIndex(self) -> int:
        return self.stacked.currentIndex()

    def setTabText(self, i: int, text: str):
        self.tabs.setTabText(i, text)

    def setTabTooltip(self, i: int, toolTip: str):
        self.tabs.setTabToolTip(i, toolTip)

    def currentWidget(self) -> QWidget:
        return self.stacked.currentWidget()

    def count(self) -> int:
        return self.stacked.count()

    def removeTab(self, i: int):
        assert isinstance(i, int)
        widget = self.stacked.widget(i)
        # remove widget from stacked view _before_ removing the tab,
        # because removing the tab may send a tab change event
        self.stacked.removeWidget(widget)
        self.tabs.removeTab(i)
        self.syncBarSize()

    def swapWidget(self, i: int, newWidget: QWidget):
        currentIndex = self.currentIndex()
        oldWidget = self.stacked.widget(i)
        self.stacked.removeWidget(oldWidget)
        assert oldWidget.parentWidget() is self.stacked, "QStackedWidget isn't supposed to deparent its pages"
        self.stacked.insertWidget(i, newWidget)
        self.stacked.setCurrentIndex(currentIndex)  # Keep it stable
        if currentIndex == i:
            self.shadowCurrentWidgetRef = weakref.ref(newWidget)
            self.currentWidgetChanged.emit()
        return oldWidget

    def widgets(self):
        for i in range(self.stacked.count()):
            yield self.stacked.widget(i)

    def syncBarSize(self):
        h = self.tabs.sizeHint().height()
        if h != 0:
            self.tabScrollArea.setFixedHeight(self.tabs.sizeHint().height())
            self.overflowGradient.resize(self.tabScrollArea.size())

    # I don't like deferring ensureCurrentTabVisible to the next event loop,
    # but the new tabbar width doesn't seem to be refreshed immediately in onCurrentChanged.
    @CallbackAccumulator.deferredMethod()
    def ensureCurrentTabVisible(self):
        i = self.currentIndex()
        if i < 0:
            return
        rect = self.tabs.tabRect(i)

        vr = self.tabScrollArea.viewport().contentsRect()
        vr.translate(self.tabScrollArea.horizontalScrollBar().value(), self.tabScrollArea.verticalScrollBar().value())

        if rect.left() < vr.left():
            p = QPoint(rect.left(), rect.center().y())
        else:
            p = QPoint(rect.right(), rect.center().y())
        self.tabScrollArea.ensureVisible(p.x(), p.y())

    def scrollTabs(self, delta: QPoint):
        x = delta.x()
        y = delta.y()
        xBias = abs(x) - abs(y)

        # Reduce jitter when it's unclear which axis the user intends to scroll on
        # (for trackpads)
        if abs(xBias) < 1:
            return

        deltaValue = x if xBias > 0 else y
        scrollBar = self.tabScrollArea.horizontalScrollBar()
        scrollBar.setValue(scrollBar.value() - deltaValue)

    @CallbackAccumulator.deferredMethod()  # don't update overflow button too often
    def updateOverflowDropdown(self):
        if self.tabs.count() <= 1:  # never overflow if there's just one tab
            isOverflowing = False
        else:
            isOverflowing = self.topWidget.width() < self.tabs.width()

        self.overflowGradient.setVisible(isOverflowing)
        self.overflowButton.setVisible(isOverflowing)

        if isOverflowing:
            self.overflowButton.setMaximumHeight(self.tabs.height())

    def onOverflowButtonClicked(self):
        self.overflowMenu.clear()
        for i in range(self.count()):
            action = QAction(self.overflowMenu)
            action.setText(self.tabs.tabText(i))
            action.setToolTip(self.tabs.tabToolTip(i))
            action.triggered.connect(lambda _dummy, j=i: self.setCurrentIndex(j))
            self.overflowMenu.addAction(action)

        pos = self.mapToGlobal(self.overflowButton.pos() + self.overflowButton.rect().bottomLeft())
        self.overflowMenu.popup(pos)

    def resizeEvent(self, event: QResizeEvent):
        super().resizeEvent(event)
        self.onResize()

    def onResize(self):
        self.overflowGradient.resize(self.tabScrollArea.size())
        self.ensureCurrentTabVisible()

    def requestAttention(self, i: int):
        if i == self.currentIndex():
            return
        widget = self.widget(i)
        if widget is None:
            return
        if widget.property(QTabWidget2.UrgentPropertyName) == "true":
            return
        widget.setProperty(QTabWidget2.UrgentPropertyName, "true")
        self.tabs.setTabIcon(i, stockIcon("urgent-tab"))
