"""
novelWriter – GUI Project Outline
=================================

File History:
Created: 2022-05-15 [2.0rc1] GuiOutlineView
Created: 2022-05-22 [2.0rc1] GuiOutlineToolBar
Created: 2019-11-16 [0.4.1]  GuiOutlineTree
Created: 2019-11-16 [0.4.1]  GuiOutlineHeaderMenu
Created: 2020-06-02 [0.7]    GuiOutlineDetails

This file is a part of novelWriter
Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""  # noqa
from __future__ import annotations

import csv
import logging

from enum import Enum
from time import time
from typing import Final

from PyQt6.QtCore import QT_TRANSLATE_NOOP, Qt, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QAction, QIcon
from PyQt6.QtWidgets import (
    QAbstractItemView, QFileDialog, QFrame, QGridLayout, QGroupBox,
    QHBoxLayout, QLabel, QMenu, QScrollArea, QSplitter, QToolBar, QToolButton,
    QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
)

from novelwriter import CONFIG, SHARED
from novelwriter.common import checkInt, formatFileFilter
from novelwriter.constants import nwKeyWords, nwLabels, nwStats, nwStyles, trConst, trStats
from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
from novelwriter.error import logException
from novelwriter.extensions.configlayout import NColorLabel
from novelwriter.extensions.novelselector import NovelSelector
from novelwriter.types import (
    QtAlignLeftTop, QtAlignRight, QtAlignRightTop, QtDecoration,
    QtScrollAlwaysOff, QtScrollAsNeeded, QtSizeExpanding, QtUserRole
)

logger = logging.getLogger(__name__)


class GuiOutlineView(QWidget):
    """GUI: Project Outline Panel."""

    loadDocumentTagRequest = pyqtSignal(str, Enum)
    openDocumentRequest = pyqtSignal(str, Enum, str, bool)

    def __init__(self, parent: QWidget) -> None:
        super().__init__(parent=parent)

        # Build GUI
        self.outlineTree = GuiOutlineTree(self)
        self.outlineData = GuiOutlineDetails(self)
        self.outlineBar = GuiOutlineToolBar(self)
        self.outlineBar.setEnabled(False)

        self.splitOutline = QSplitter(Qt.Orientation.Vertical, self)
        self.splitOutline.addWidget(self.outlineTree)
        self.splitOutline.addWidget(self.outlineData)
        self.splitOutline.setOpaqueResize(False)
        self.splitOutline.setSizes(CONFIG.outlinePanePos)

        # Assemble
        self.outerBox = QVBoxLayout()
        self.outerBox.setContentsMargins(0, 0, 4, 0)
        self.outerBox.addWidget(self.outlineBar)
        self.outerBox.addWidget(self.splitOutline)

        self.setLayout(self.outerBox)

        # Connect Signals
        self.outlineTree.hiddenStateChanged.connect(self._updateMenuColumns)
        self.outlineTree.activeItemChanged.connect(self.outlineData.showItem)
        self.outlineData.itemTagClicked.connect(self._tagClicked)
        self.outlineBar.loadNovelRootRequest.connect(self._rootItemChanged)
        self.outlineBar.viewColumnToggled.connect(self.outlineTree.menuColumnToggled)
        self.outlineBar.outlineExportRequest.connect(self.outlineTree.exportOutline)

        # Function Mappings
        self.getSelectedHandle = self.outlineTree.getSelectedHandle

    ##
    #  Methods
    ##

    def updateTheme(self) -> None:
        """Update theme elements."""
        logger.debug("Theme Update: GuiOutlineView")

        self.outlineBar.updateTheme()
        self.outlineTree.updateTheme()
        self.outlineTree.refreshTree(
            rootHandle=SHARED.project.data.getLastHandle("outline"), overRide=True
        )

    def initSettings(self) -> None:
        """Initialise GUI elements that depend on specific settings."""
        self.outlineTree.initSettings()
        self.outlineData.initSettings()

    def refreshTree(self) -> None:
        """Refresh the current tree."""
        self.outlineTree.refreshTree(rootHandle=SHARED.project.data.getLastHandle("outline"))

    def clearOutline(self) -> None:
        """Clear project-related GUI content."""
        self.outlineData.clearDetails()
        self.outlineBar.setEnabled(False)

    def openProjectTasks(self) -> None:
        """Run open project tasks."""
        lastOutline = SHARED.project.data.getLastHandle("outline")
        if not lastOutline or lastOutline not in SHARED.project.tree:
            lastOutline = SHARED.project.tree.findRoot(nwItemClass.NOVEL)

        logger.debug("Setting outline tree to root item '%s'", lastOutline)

        self.clearOutline()
        self.outlineBar.populateNovelList()
        self.outlineBar.setCurrentRoot(lastOutline)
        self.outlineBar.setEnabled(True)
        self.outlineData.loadGuiSettings()

    def closeProjectTasks(self) -> None:
        """Run closing project tasks."""
        if self.outlineTree.wasRendered:
            # If the panel hasn't been drawn yet, those values are incorrect
            self.outlineData.saveGuiSettings()
        self.outlineTree.closeProjectTasks()
        self.outlineData.updateClasses()
        self.clearOutline()

    def splitSizes(self) -> list[int]:
        """Get the sizes of the splitter widget."""
        return self.splitOutline.sizes()

    def setTreeFocus(self) -> None:
        """Set the focus to the tree widget."""
        return self.outlineTree.setFocus()

    def treeHasFocus(self) -> bool:
        """Check if the outline tree has focus."""
        return self.outlineTree.hasFocus()

    ##
    #  Public Slots
    ##

    @pyqtSlot(str, Enum)
    def updateRootItem(self, tHandle: str, change: nwChange) -> None:
        """Handle tasks whenever a root folders changes."""
        self.outlineBar.populateNovelList()
        self.outlineData.updateClasses()

    ##
    #  Private Slots
    ##

    @pyqtSlot()
    def _updateMenuColumns(self) -> None:
        """Trigger an update of the toggled state of the column menu
        checkboxes whenever a signal is received that the hidden state
        of columns has changed.
        """
        self.outlineBar.setColumnHiddenState(self.outlineTree.hiddenColumns)

    @pyqtSlot(str)
    def _tagClicked(self, link: str) -> None:
        """Capture the click of a tag in the details panel."""
        if link:
            self.loadDocumentTagRequest.emit(link, nwDocMode.VIEW)

    @pyqtSlot(str)
    def _rootItemChanged(self, tHandle: str) -> None:
        """Handle root novel changed or needs to be refreshed."""
        self.outlineTree.refreshTree(rootHandle=(tHandle or None), overRide=True)


class GuiOutlineToolBar(QToolBar):
    """GUI: Project Outline Panel ToolBar."""

    loadNovelRootRequest = pyqtSignal(str)
    outlineExportRequest = pyqtSignal()
    viewColumnToggled = pyqtSignal(bool, Enum)

    def __init__(self, outlineView: GuiOutlineView) -> None:
        super().__init__(parent=outlineView)

        logger.debug("Create: GuiOutlineToolBar")

        self.setMovable(False)
        self.setIconSize(1.4*SHARED.theme.baseIconSize)
        self.setContentsMargins(0, 0, 0, 0)

        stretch = QWidget(self)
        stretch.setSizePolicy(QtSizeExpanding, QtSizeExpanding)

        # Novel Selector
        self.novelLabel = NColorLabel(
            self.tr("Outline of"), self, scale=NColorLabel.HEADER_SCALE, bold=True
        )
        self.novelLabel.setContentsMargins(0, 0, 12, 0)

        self.novelValue = NovelSelector(self)
        self.novelValue.setIncludeAll(True)
        self.novelValue.setMinimumWidth(200)
        self.novelValue.novelSelectionChanged.connect(self._novelValueChanged)

        # Actions
        self.aRefresh = QAction(self.tr("Refresh"), self)
        self.aRefresh.triggered.connect(self._refreshRequested)

        self.aExport = QAction(self.tr("Export CSV"), self)
        self.aExport.triggered.connect(self._exportRequested)

        # Column Menu
        self.mColumns = GuiOutlineHeaderMenu(self)
        self.mColumns.columnToggled.connect(
            lambda isChecked, tItem: self.viewColumnToggled.emit(isChecked, tItem)
        )

        self.tbColumns = QToolButton(self)
        self.tbColumns.setMenu(self.mColumns)
        self.tbColumns.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)

        # Assemble
        self.addWidget(self.novelLabel)
        self.addWidget(self.novelValue)
        self.addSeparator()
        self.addAction(self.aRefresh)
        self.addAction(self.aExport)
        self.addWidget(self.tbColumns)
        self.addWidget(stretch)

        self.updateTheme()

        logger.debug("Ready: GuiOutlineToolBar")

    ##
    #  Methods
    ##

    def updateTheme(self) -> None:
        """Update theme elements."""
        logger.debug("Theme Update: GuiOutlineToolBar")

        self.setStyleSheet("QToolBar {border: 0px;}")
        self.novelValue.refreshNovelList()
        self.aRefresh.setIcon(SHARED.theme.getIcon("refresh", "change"))
        self.aExport.setIcon(SHARED.theme.getIcon("export", "action"))
        self.tbColumns.setIcon(SHARED.theme.getIcon("more_vertical", "default"))
        self.tbColumns.setStyleSheet("QToolButton::menu-indicator {image: none;}")
        self.novelLabel.setTextColors(color=self.palette().windowText().color())

    def populateNovelList(self) -> None:
        """Reload the content of the novel list."""
        self.novelValue.refreshNovelList()

    def setCurrentRoot(self, rootHandle: str | None) -> None:
        """Set the current active root handle."""
        self.novelValue.setHandle(rootHandle)

    def setColumnHiddenState(self, hiddenState: dict[nwOutline, bool]) -> None:
        """Forward the change of column hidden states to the menu."""
        self.mColumns.setHiddenState(hiddenState)

    ##
    #  Private Slots
    ##

    @pyqtSlot(str)
    def _novelValueChanged(self, tHandle: str) -> None:
        """Emit a signal containing the handle of the selected item."""
        self.loadNovelRootRequest.emit(tHandle)

    @pyqtSlot()
    def _refreshRequested(self) -> None:
        """Emit a signal containing the handle of the selected item."""
        self.loadNovelRootRequest.emit(self.novelValue.handle)

    @pyqtSlot()
    def _exportRequested(self) -> None:
        """Emit a signal that an export of the outline was requested."""
        self.outlineExportRequest.emit()


class GuiOutlineTree(QTreeWidget):
    """GUI: Project Outline Panel Tree."""

    DEF_WIDTH: Final[dict[nwOutline, int]] = {
        nwOutline.TITLE:   200,
        nwOutline.LEVEL:   40,
        nwOutline.LABEL:   150,
        nwOutline.LINE:    40,
        nwOutline.STATUS:  100,
        nwOutline.CCOUNT:  50,
        nwOutline.WCOUNT:  50,
        nwOutline.PCOUNT:  50,
        nwOutline.POV:     100,
        nwOutline.FOCUS:   100,
        nwOutline.CHAR:    100,
        nwOutline.PLOT:    100,
        nwOutline.TIME:    100,
        nwOutline.WORLD:   100,
        nwOutline.OBJECT:  100,
        nwOutline.ENTITY:  100,
        nwOutline.CUSTOM:  100,
        nwOutline.STORY:   100,
        nwOutline.MENTION: 100,
        nwOutline.SYNOP:   200,
    }

    DEF_HIDDEN: Final[dict[nwOutline, bool]] = {
        nwOutline.TITLE:   False,
        nwOutline.LEVEL:   True,
        nwOutline.LABEL:   False,
        nwOutline.LINE:    True,
        nwOutline.STATUS:  True,
        nwOutline.CCOUNT:  True,
        nwOutline.WCOUNT:  False,
        nwOutline.PCOUNT:  False,
        nwOutline.POV:     False,
        nwOutline.FOCUS:   True,
        nwOutline.CHAR:    False,
        nwOutline.PLOT:    False,
        nwOutline.TIME:    True,
        nwOutline.WORLD:   False,
        nwOutline.OBJECT:  True,
        nwOutline.ENTITY:  True,
        nwOutline.CUSTOM:  True,
        nwOutline.STORY:   True,
        nwOutline.MENTION: True,
        nwOutline.SYNOP:   False,
    }

    D_HANDLE = QtUserRole
    D_TITLE  = QtUserRole + 1

    hiddenStateChanged = pyqtSignal()
    activeItemChanged = pyqtSignal(str, str)

    def __init__(self, outlineView: GuiOutlineView) -> None:
        super().__init__(parent=outlineView)

        logger.debug("Create: GuiOutlineTree")

        self.outlineView = outlineView

        self.setUniformRowHeights(True)
        self.setFrameStyle(QFrame.Shape.NoFrame)
        self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.setExpandsOnDoubleClick(False)
        self.setDragEnabled(False)
        self.itemDoubleClicked.connect(self._onItemDoubleClicked)
        self.itemSelectionChanged.connect(self._onItemSelectionChanged)

        self.setIconSize(SHARED.theme.baseIconSize)
        self.setIndentation(0)

        if header := self.header():
            header.sectionMoved.connect(self._columnMoved)

        # Pre-Generate Tree Formatting
        fH1 = self.font()
        fH1.setBold(True)
        fH1.setUnderline(True)

        fH2 = self.font()
        fH2.setBold(True)

        self._hFonts = [self.font(), fH1, fH2, self.font(), self.font()]
        self._dIcon: dict[str, QIcon] = {}

        # Internals
        self._treeOrder = []
        self._colWidth  = {}
        self._colHidden = {}
        self._colIdx    = {}
        self._treeNCols = 0
        self._firstView = True
        self._lastBuild = 0

        self.updateTheme()
        self.initSettings()
        self.clearContent()

        self.hiddenStateChanged.emit()

        logger.debug("Ready: GuiOutlineTree")

    ##
    #  Properties
    ##

    @property
    def hiddenColumns(self) -> dict[nwOutline, bool]:
        return self._colHidden

    @property
    def wasRendered(self) -> bool:
        """Returns True after the Outline has been rendered once."""
        return not self._firstView

    ##
    #  Methods
    ##

    def initSettings(self) -> None:
        """Set or update outline settings."""
        if CONFIG.hideVScroll:
            self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
        else:
            self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
        if CONFIG.hideHScroll:
            self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
        else:
            self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)

    def clearContent(self) -> None:
        """Clear the tree and header and set the default values for the
        columns arrays.
        """
        self.clear()
        self.setColumnCount(1)
        self.setHeaderLabel(trConst(nwLabels.OUTLINE_COLS[nwOutline.TITLE]))

        self._treeOrder: list[nwOutline] = []
        self._colWidth:  dict[nwOutline, int] = {}
        self._colHidden: dict[nwOutline, bool] = {}
        self._colIdx:    dict[nwOutline, int] = {}
        self._treeNCols = 0

        for hItem in nwOutline:
            self._treeOrder.append(hItem)
            self._colWidth[hItem] = self.DEF_WIDTH[hItem]
            self._colHidden[hItem] = self.DEF_HIDDEN[hItem]

        self._treeNCols = len(self._treeOrder)

    def updateTheme(self) -> None:
        """Update theme elements."""
        logger.debug("Theme Update: GuiOutlineTree")

        iType = nwItemType.FILE
        iClass = nwItemClass.NO_CLASS
        iLayout = nwItemLayout.DOCUMENT
        self._dIcon = {
            "H0": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H0"),
            "H1": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H1"),
            "H2": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H2"),
            "H3": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H3"),
            "H4": SHARED.theme.getItemIcon(iType, iClass, iLayout, "H4"),
        }

    def refreshTree(
        self, rootHandle: str | None = None,
        overRide: bool = False, novelChanged: bool = False
    ) -> None:
        """Refresh the outline tree. Called whenever the Outline tab is
        activated and controls what data to load, and if necessary,
        force a rebuild of the tree.
        """
        # If it's the first time, we always build
        if self._firstView or (self._firstView and overRide):
            self._loadHeaderState()
            self._populateTree(rootHandle)
            self._firstView = False
            return

        # If the novel index or novel tree has changed since the tree
        # was last built, we rebuild the tree from the updated index.
        indexChanged = SHARED.project.index.rootChangedSince(rootHandle, self._lastBuild)
        if not (novelChanged or indexChanged or overRide):
            logger.debug("No changes have been made to the novel index")
            return

        self._populateTree(rootHandle)
        SHARED.project.data.setLastHandle(rootHandle or None, "outline")

        return

    def closeProjectTasks(self) -> None:
        """Call before a project is closed."""
        self._saveHeaderState()
        self.clearContent()
        self._firstView = True

    def getSelectedHandle(self) -> tuple[str | None, str | None]:
        """Get the currently selected handle. If multiple items are
        selected, return the first.
        """
        if item := self.selectedItems():
            tHandle = item[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
            sTitle = item[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
            return tHandle, sTitle
        return None, None

    ##
    #  Public Slots
    ##

    @pyqtSlot(bool, Enum)
    def menuColumnToggled(self, isChecked: bool, hItem: nwOutline) -> None:
        """Receive the changes to column visibility forwarded by the
        column selection menu.
        """
        if hItem in self._colIdx:
            self.setColumnHidden(self._colIdx[hItem], not isChecked)
            self._saveHeaderState()

    @pyqtSlot()
    def exportOutline(self) -> None:
        """Export the outline as a CSV file."""
        name = CONFIG.lastPath("outline") / f"{SHARED.project.data.fileSafeName}.csv"
        if path := QFileDialog.getSaveFileName(
            self, self.tr("Save Outline As"), str(name), formatFileFilter(["*.csv", "*"])
        )[0]:
            CONFIG.setLastPath("outline", path)
            logger.info("Writing CSV file: %s", path)
            with open(path, mode="w", newline="", encoding="utf-8") as csvFile:
                writer = csv.writer(csvFile, dialect="excel", quoting=csv.QUOTE_ALL)
                writer.writerows(
                    self._dumpNovelData(self.outlineView.outlineBar.novelValue.handle)
                )

    ##
    #  Private Slots
    ##

    @pyqtSlot("QTreeWidgetItem*", int)
    def _onItemDoubleClicked(self, tItem: QTreeWidgetItem, tCol: int) -> None:
        """Extract the handle and line number of the title double-
        clicked, and send it to the main gui class for opening in the
        document editor.
        """
        tHandle, sTitle = self.getSelectedHandle()
        if tHandle:
            self.outlineView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "", True)

    @pyqtSlot()
    def _onItemSelectionChanged(self) -> None:
        """Extract the handle and line number of the currently selected
        title, and send it to the details panel.
        """
        if items := self.selectedItems():
            tHandle = items[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
            sTitle = items[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
            self.activeItemChanged.emit(tHandle, sTitle)

    @pyqtSlot(int, int, int)
    def _columnMoved(self, logIdx: int, oldVisualIdx: int, newVisualIdx: int) -> None:
        """Make sure the order array is up to date with the actual order
        of the columns.
        """
        self._treeOrder.insert(newVisualIdx, self._treeOrder.pop(oldVisualIdx))
        self._saveHeaderState()

    ##
    #  Internal Functions
    ##

    def _loadHeaderState(self) -> None:
        """Load the state of the main tree header, that is, column order
        and column width.
        """
        # Load whatever we saved last time, regardless of whether it
        # contains the correct names or number of columns.
        colState = SHARED.project.options.getValue("GuiOutline", "columnState", {})

        tmpOrder = []
        tmpHidden = {}
        tmpWidth = {}
        try:
            for name, (hidden, width) in colState.items():
                if name not in nwOutline.__members__:
                    logger.warning("Ignored unknown outline column '%s'", name)
                    continue
                tmpOrder.append(nwOutline[name])
                tmpHidden[nwOutline[name]] = hidden
                tmpWidth[nwOutline[name]] = width
        except Exception:
            logger.error("Invalid column state")
            logException()

        # Add columns that was not in the file to the treeOrder array.
        for hItem in nwOutline:
            if hItem not in tmpOrder:
                tmpOrder.append(hItem)

        self._treeOrder = tmpOrder
        self._colHidden.update(tmpHidden)
        self._colWidth.update(tmpWidth)

        self.hiddenStateChanged.emit()

    def _saveHeaderState(self) -> None:
        """Save the state of the main tree header, that is, column
        order, column width and column hidden state. We don't want to
        save the current width of hidden columns though. This preserves
        the last known width in case they're unhidden again.
        """
        if self._lastBuild > 0 and (header := self.header()):
            colState = {}
            for iCol in range(self.columnCount()):
                hItem = self._treeOrder[iCol]
                iLog = header.logicalIndex(iCol)
                logHidden = self.isColumnHidden(iLog)
                orgWidth = self._colWidth[hItem]
                logWidth = self.columnWidth(iLog)
                colState[hItem.name] = [
                    logHidden, orgWidth if logHidden and logWidth == 0 else logWidth
                ]

            logger.debug("Saving State: GuiOutline")
            pOptions = SHARED.project.options
            pOptions.setValue("GuiOutline", "columnState", colState)
            pOptions.saveSettings()

    def _populateTree(self, rootHandle: str | None) -> None:
        """Build the tree based on the project index, and the header
        based on the defined constants, default values and user selected
        width, order and hidden state. All columns are populated, even
        if they are hidden. This ensures that showing and hiding columns
        is fast and doesn't require a rebuild of the tree.
        """
        logger.debug("Rebuilding Outline tree")
        tStart = time()
        self.clear()

        if self._firstView:
            labels = []
            for i, hItem in enumerate(self._treeOrder):
                labels.append(trConst(nwLabels.OUTLINE_COLS[hItem]))
                self._colIdx[hItem] = i

            self.setHeaderLabels(labels)
            for hItem in self._treeOrder:
                self.setColumnWidth(self._colIdx[hItem], self._colWidth[hItem])
                self.setColumnHidden(self._colIdx[hItem], self._colHidden[hItem])

            # Make sure title column is always visible
            self.setColumnHidden(self._colIdx[nwOutline.TITLE], False)

            headItem = self.headerItem()
            if isinstance(headItem, QTreeWidgetItem):
                headItem.setTextAlignment(
                    self._colIdx[nwOutline.CCOUNT], QtAlignRight)
                headItem.setTextAlignment(
                    self._colIdx[nwOutline.WCOUNT], QtAlignRight)
                headItem.setTextAlignment(
                    self._colIdx[nwOutline.PCOUNT], QtAlignRight)

        novStruct = SHARED.project.index.novelStructure(rootHandle=rootHandle, activeOnly=True)
        for _, tHandle, sTitle, novIdx in novStruct:

            iLevel = nwStyles.H_LEVEL.get(novIdx.level, 0)
            nwItem = SHARED.project.tree[tHandle]
            if iLevel == 0 or nwItem is None:
                continue
            sLabel, sIcon = nwItem.getImportStatus()

            item = QTreeWidgetItem()
            hDec = SHARED.theme.getHeaderDecoration(iLevel)

            item.setData(self._colIdx[nwOutline.TITLE], QtDecoration, hDec)
            item.setText(self._colIdx[nwOutline.TITLE], novIdx.title)
            item.setData(self._colIdx[nwOutline.TITLE], self.D_HANDLE, tHandle)
            item.setData(self._colIdx[nwOutline.TITLE], self.D_TITLE, sTitle)
            item.setFont(self._colIdx[nwOutline.TITLE], self._hFonts[iLevel])
            item.setText(self._colIdx[nwOutline.LEVEL], novIdx.level)
            item.setIcon(self._colIdx[nwOutline.LABEL], self._dIcon[nwItem.mainHeading])
            item.setText(self._colIdx[nwOutline.LABEL], nwItem.itemName)
            item.setText(self._colIdx[nwOutline.LINE], f"{novIdx.line:n}")
            item.setText(self._colIdx[nwOutline.STATUS], sLabel)
            item.setIcon(self._colIdx[nwOutline.STATUS], sIcon)
            item.setText(self._colIdx[nwOutline.SYNOP], novIdx.synopsis)
            item.setText(self._colIdx[nwOutline.CCOUNT], f"{novIdx.charCount:n}")
            item.setText(self._colIdx[nwOutline.WCOUNT], f"{novIdx.wordCount:n}")
            item.setText(self._colIdx[nwOutline.PCOUNT], f"{novIdx.paraCount:n}")
            item.setTextAlignment(self._colIdx[nwOutline.CCOUNT], QtAlignRight)
            item.setTextAlignment(self._colIdx[nwOutline.WCOUNT], QtAlignRight)
            item.setTextAlignment(self._colIdx[nwOutline.PCOUNT], QtAlignRight)

            refs = SHARED.project.index.getReferences(tHandle, sTitle)
            item.setText(self._colIdx[nwOutline.POV],     ", ".join(refs[nwKeyWords.POV_KEY]))
            item.setText(self._colIdx[nwOutline.FOCUS],   ", ".join(refs[nwKeyWords.FOCUS_KEY]))
            item.setText(self._colIdx[nwOutline.CHAR],    ", ".join(refs[nwKeyWords.CHAR_KEY]))
            item.setText(self._colIdx[nwOutline.PLOT],    ", ".join(refs[nwKeyWords.PLOT_KEY]))
            item.setText(self._colIdx[nwOutline.TIME],    ", ".join(refs[nwKeyWords.TIME_KEY]))
            item.setText(self._colIdx[nwOutline.WORLD],   ", ".join(refs[nwKeyWords.WORLD_KEY]))
            item.setText(self._colIdx[nwOutline.OBJECT],  ", ".join(refs[nwKeyWords.OBJECT_KEY]))
            item.setText(self._colIdx[nwOutline.ENTITY],  ", ".join(refs[nwKeyWords.ENTITY_KEY]))
            item.setText(self._colIdx[nwOutline.CUSTOM],  ", ".join(refs[nwKeyWords.CUSTOM_KEY]))
            item.setText(self._colIdx[nwOutline.STORY],   ", ".join(refs[nwKeyWords.STORY_KEY]))
            item.setText(self._colIdx[nwOutline.MENTION], ", ".join(refs[nwKeyWords.MENTION_KEY]))

            self.addTopLevelItem(item)

        self._lastBuild = time()
        logger.debug("Project outline built in %.3f ms", 1000.0*(time() - tStart))

    def _dumpNovelData(self, rootHandle: str | None) -> list[list[str | int]]:
        """Dump all novel data into a table."""
        sLabel = SHARED.project.localLookup("Story Structure")
        nLabel = SHARED.project.localLookup("Note")
        sKeys = sorted(SHARED.project.index.getStoryKeys())
        nKeys = sorted(SHARED.project.index.getNoteKeys())
        sMatch = [f"story.{k}" for k in sKeys]
        nMatch = [f"note.{k}" for k in nKeys]
        sHeaders = [f"{sLabel} ({k})" for k in sKeys]
        nHeaders = [f"{nLabel} ({k})" for k in nKeys]

        data: list[list[str | int]] = [[
            "H",
            trConst(nwLabels.OUTLINE_COLS[nwOutline.TITLE]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.LABEL]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.LINE]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.STATUS]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.CCOUNT]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.WCOUNT]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.PCOUNT]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.POV]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.FOCUS]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.CHAR]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.PLOT]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.TIME]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.WORLD]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.OBJECT]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.ENTITY]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.CUSTOM]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.STORY]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.MENTION]),
            trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]),
            *sHeaders,
            *nHeaders,
        ]]

        for _, tHandle, sTitle, novIdx in SHARED.project.index.novelStructure(
            rootHandle=rootHandle, activeOnly=True
        ):
            if novIdx.level != "H0" and (nwItem := SHARED.project.tree[tHandle]):
                refs = SHARED.project.index.getReferences(tHandle, sTitle)
                comments = dict(novIdx.comments.items())
                story = [comments.get(k, "") for k in sMatch]
                notes = [comments.get(k, "") for k in nMatch]
                data.append([
                    novIdx.level,
                    novIdx.title,
                    nwItem.itemName,
                    novIdx.line,
                    nwItem.getImportStatus()[0],
                    novIdx.charCount,
                    novIdx.wordCount,
                    novIdx.paraCount,
                    ", ".join(refs[nwKeyWords.POV_KEY]),
                    ", ".join(refs[nwKeyWords.FOCUS_KEY]),
                    ", ".join(refs[nwKeyWords.CHAR_KEY]),
                    ", ".join(refs[nwKeyWords.PLOT_KEY]),
                    ", ".join(refs[nwKeyWords.TIME_KEY]),
                    ", ".join(refs[nwKeyWords.WORLD_KEY]),
                    ", ".join(refs[nwKeyWords.OBJECT_KEY]),
                    ", ".join(refs[nwKeyWords.ENTITY_KEY]),
                    ", ".join(refs[nwKeyWords.CUSTOM_KEY]),
                    ", ".join(refs[nwKeyWords.STORY_KEY]),
                    ", ".join(refs[nwKeyWords.MENTION_KEY]),
                    novIdx.synopsis,
                    *story,
                    *notes,
                ])

        return data


class GuiOutlineHeaderMenu(QMenu):
    """GUI: Project Outline Panel Header Selection Menu."""

    columnToggled = pyqtSignal(bool, Enum)

    def __init__(self, outlineToolBar: GuiOutlineToolBar) -> None:
        super().__init__(parent=outlineToolBar)

        self.acceptToggle = True

        mnuHead = QAction(self.tr("Select Columns"), self)
        self.addAction(mnuHead)
        self.addSeparator()

        self.actionMap = {}
        for hItem in nwOutline:
            if hItem == nwOutline.TITLE:
                continue
            self.actionMap[hItem] = QAction(trConst(nwLabels.OUTLINE_COLS[hItem]), self)
            self.actionMap[hItem].setCheckable(True)
            self.actionMap[hItem].toggled.connect(
                lambda isChecked, tItem=hItem: self.columnToggled.emit(isChecked, tItem)
            )
            self.addAction(self.actionMap[hItem])

    def setHiddenState(self, hiddenState: dict[nwOutline, bool]) -> None:
        """Overwrite the checked state of the columns as the inverse of
        the hidden state. Skip the TITLE column as it cannot be hidden.
        """
        self.acceptToggle = False

        for hItem in nwOutline:
            if hItem == nwOutline.TITLE or hItem not in hiddenState:
                continue
            self.actionMap[hItem].setChecked(not hiddenState[hItem])

        self.acceptToggle = True


class GuiOutlineDetails(QScrollArea):
    """GUI: Project Outline Panel Details View."""

    LVL_MAP: Final[dict[str, str]] = {
        "H1": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Title"),
        "H2": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Chapter"),
        "H3": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Scene"),
        "H4": QT_TRANSLATE_NOOP("GuiOutlineDetails", "Section"),
    }

    itemTagClicked = pyqtSignal(str)

    def __init__(self, outlineView: GuiOutlineView) -> None:
        super().__init__(parent=outlineView)

        logger.debug("Create: GuiOutlineDetails")

        self.outlineView = outlineView

        # Sizes
        minTitle = 30*SHARED.theme.textNWidth
        maxTitle = 40*SHARED.theme.textNWidth
        wCount = SHARED.theme.getTextWidth("999,999")

        bFont = SHARED.theme.guiFontB

        trStats1 = trStats(nwLabels.STATS_NAME[nwStats.CHARS])
        trStats2 = trStats(nwLabels.STATS_NAME[nwStats.WORDS])
        trStats3 = trStats(nwLabels.STATS_NAME[nwStats.PARAGRAPHS])

        # Details Area
        self.titleLabel = QLabel(self.tr("Title"), self)
        self.fileLabel  = QLabel(self.tr("Document"), self)
        self.itemLabel  = QLabel(self.tr("Status"), self)
        self.titleValue = QLabel("", self)
        self.fileValue  = QLabel("", self)
        self.itemValue  = QLabel("", self)

        self.titleLabel.setFont(bFont)
        self.fileLabel.setFont(bFont)
        self.itemLabel.setFont(bFont)

        self.titleValue.setMinimumWidth(minTitle)
        self.titleValue.setMaximumWidth(maxTitle)
        self.fileValue.setMinimumWidth(minTitle)
        self.fileValue.setMaximumWidth(maxTitle)
        self.itemValue.setMinimumWidth(minTitle)
        self.itemValue.setMaximumWidth(maxTitle)

        # Stats Area
        self.cCLabel = QLabel(trStats1, self)
        self.wCLabel = QLabel(trStats2, self)
        self.pCLabel = QLabel(trStats3, self)
        self.cCValue = QLabel("", self)
        self.wCValue = QLabel("", self)
        self.pCValue = QLabel("", self)

        self.cCLabel.setFont(bFont)
        self.wCLabel.setFont(bFont)
        self.pCLabel.setFont(bFont)

        self.cCValue.setMinimumWidth(wCount)
        self.wCValue.setMinimumWidth(wCount)
        self.pCValue.setMinimumWidth(wCount)
        self.cCValue.setAlignment(QtAlignRight)
        self.wCValue.setAlignment(QtAlignRight)
        self.pCValue.setAlignment(QtAlignRight)

        # Synopsis
        self.synopLabel = QLabel(self.tr("Synopsis"), self)
        self.synopLabel.setFont(bFont)

        self.synopValue = QLabel("", self)
        self.synopValue.setWordWrap(True)
        self.synopValue.setAlignment(QtAlignLeftTop)

        self.synopLWrap = QHBoxLayout()
        self.synopLWrap.addWidget(self.synopValue, 1)

        # Selected Item Details
        self.mainForm = QGridLayout()
        self.mainGroup = QGroupBox(self.tr("Title Details"), self)
        self.mainGroup.setLayout(self.mainForm)

        self.mainForm.addWidget(self.titleLabel, 0, 0, 1, 1, QtAlignLeftTop)
        self.mainForm.addWidget(self.titleValue, 0, 1, 1, 1, QtAlignLeftTop)
        self.mainForm.addWidget(self.cCLabel,    0, 2, 1, 1, QtAlignLeftTop)
        self.mainForm.addWidget(self.cCValue,    0, 3, 1, 1, QtAlignRightTop)
        self.mainForm.addWidget(self.fileLabel,  1, 0, 1, 1, QtAlignLeftTop)
        self.mainForm.addWidget(self.fileValue,  1, 1, 1, 1, QtAlignLeftTop)
        self.mainForm.addWidget(self.wCLabel,    1, 2, 1, 1, QtAlignLeftTop)
        self.mainForm.addWidget(self.wCValue,    1, 3, 1, 1, QtAlignRightTop)
        self.mainForm.addWidget(self.itemLabel,  2, 0, 1, 1, QtAlignLeftTop)
        self.mainForm.addWidget(self.itemValue,  2, 1, 1, 1, QtAlignLeftTop)
        self.mainForm.addWidget(self.pCLabel,    2, 2, 1, 1, QtAlignLeftTop)
        self.mainForm.addWidget(self.pCValue,    2, 3, 1, 1, QtAlignRightTop)
        self.mainForm.addWidget(self.synopLabel, 3, 0, 1, 4, QtAlignLeftTop)
        self.mainForm.addLayout(self.synopLWrap, 4, 0, 1, 4, QtAlignLeftTop)

        self.mainForm.setColumnStretch(1, 1)
        self.mainForm.setRowStretch(4, 1)
        self.mainForm.setHorizontalSpacing(10)
        self.mainForm.setVerticalSpacing(4)

        # Selected Item Tags
        self.tagsForm = QGridLayout()
        self.tagsGroup = QGroupBox(self.tr("Reference Tags"), self)
        self.tagsGroup.setLayout(self.tagsForm)

        self.tagValues: dict[str, tuple[QLabel, QLabel]] = {}
        for key in nwKeyWords.ALL_KEYS:
            label = QLabel(trConst(nwLabels.KEY_NAME[key]), self)
            label.setFont(bFont)
            value = QLabel("", self)
            value.setWordWrap(True)
            value.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
            layout = QHBoxLayout()
            layout.addWidget(value, 1)
            n = len(self.tagValues)
            self.tagsForm.addWidget(label, n, 0, QtAlignLeftTop)
            self.tagsForm.addLayout(layout, n, 1, QtAlignLeftTop)
            self.tagValues[key] = (label, value)

        self.tagsForm.setColumnStretch(1, 1)
        self.tagsForm.setRowStretch(len(self.tagValues), 1)
        self.tagsForm.setHorizontalSpacing(10)
        self.tagsForm.setVerticalSpacing(4)

        # Assemble
        self.mainSplit = QSplitter(Qt.Orientation.Horizontal)
        self.mainSplit.addWidget(self.mainGroup)
        self.mainSplit.addWidget(self.tagsGroup)
        self.mainSplit.setCollapsible(0, False)
        self.mainSplit.setCollapsible(1, False)

        self.setWidget(self.mainSplit)

        self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
        self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
        self.setWidgetResizable(True)
        self.setFrameStyle(QFrame.Shape.NoFrame)

        self.initSettings()

        logger.debug("Ready: GuiOutlineDetails")

    def initSettings(self) -> None:
        """Set or update outline settings."""
        if CONFIG.hideVScroll:
            self.setVerticalScrollBarPolicy(QtScrollAlwaysOff)
        else:
            self.setVerticalScrollBarPolicy(QtScrollAsNeeded)
        if CONFIG.hideHScroll:
            self.setHorizontalScrollBarPolicy(QtScrollAlwaysOff)
        else:
            self.setHorizontalScrollBarPolicy(QtScrollAsNeeded)
        self.updateClasses()

    def loadGuiSettings(self) -> None:
        """Run open project tasks."""
        parent = self.outlineView.parent()  # This widget is rendered already
        width = parent.width() if isinstance(parent, QWidget) else 1000
        pOptions = SHARED.project.options
        self.mainSplit.setSizes([
            pOptions.getInt("GuiOutlineDetails", "detailsWidth", width//3),
            pOptions.getInt("GuiOutlineDetails", "tagsWidth", 2*width//3),
        ])

    def saveGuiSettings(self) -> None:
        """Run close project tasks."""
        logger.debug("Saving State: GuiOutlineDetails")
        mainSplit = self.mainSplit.sizes()
        pOptions = SHARED.project.options
        pOptions.setValue("GuiOutlineDetails", "detailsWidth", mainSplit[0])
        pOptions.setValue("GuiOutlineDetails", "tagsWidth", mainSplit[1])

    def clearDetails(self) -> None:
        """Clear all the data labels."""
        self.titleLabel.setText(self.tr("Title"))
        self.titleValue.clear()
        self.fileValue.clear()
        self.itemValue.clear()
        self.cCValue.clear()
        self.wCValue.clear()
        self.pCValue.clear()
        self.synopValue.clear()

        for _, value in self.tagValues.values():
            value.clear()

        self.updateClasses()

    ##
    #  Slots
    ##

    @pyqtSlot(str, str)
    def showItem(self, tHandle: str, sTitle: str) -> None:
        """Update the content of the tree with the given handle and line
        number pointing to a header.
        """
        pIndex = SHARED.project.index
        nwItem = SHARED.project.tree[tHandle]
        novIdx = pIndex.getItemHeading(tHandle, sTitle)
        novRefs = pIndex.getReferences(tHandle, sTitle)
        if nwItem and novIdx:
            self.titleLabel.setText(self.tr(self.LVL_MAP.get(novIdx.level, "H1")))
            self.titleValue.setText(novIdx.title)

            itemStatus, _ = nwItem.getImportStatus()

            self.fileValue.setText(nwItem.itemName)
            self.itemValue.setText(itemStatus)

            self.cCValue.setText(f"{checkInt(novIdx.charCount, 0):n}")
            self.wCValue.setText(f"{checkInt(novIdx.wordCount, 0):n}")
            self.pCValue.setText(f"{checkInt(novIdx.paraCount, 0):n}")

            self.synopValue.setText(novIdx.synopsis)

            for key, (_, value) in self.tagValues.items():
                value.setText(self._formatTags(novRefs, key))

    @pyqtSlot()
    def updateClasses(self) -> None:
        """Update the visibility status of class details."""
        usedClasses = SHARED.project.tree.rootClasses()
        for key, itemClass in nwKeyWords.KEY_CLASS.items():
            visible = itemClass in usedClasses
            if key in self.tagValues:
                label, value = self.tagValues[key]
                label.setVisible(visible)
                value.setVisible(visible)

    @staticmethod
    def _formatTags(refs: dict[str, list[str]], key: str) -> str:
        """Convert a list of tags into a list of clickable tag links."""
        return ", ".join(
            [f"<a href='{tag}'>{tag}</a>" for tag in refs.get(key, [])]
        )
