"""
novelWriter – GUI Project Settings
==================================

File History:
Created:   2018-09-29 [0.0.1] GuiProjectSettings
Rewritten: 2024-01-26 [2.3b1] GuiProjectSettings

This file is a part of novelWriter
Copyright (C) 2018 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 pathlib import Path

from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QCloseEvent, QColor
from PyQt6.QtWidgets import (
    QAbstractItemView, QApplication, QColorDialog, QDialogButtonBox,
    QFileDialog, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMenu,
    QStackedWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
)

from novelwriter import CONFIG, SHARED
from novelwriter.common import formatFileFilter, qtAddAction, qtLambda, simplified
from novelwriter.constants import nwLabels, trConst
from novelwriter.core.status import CUSTOM_COL, NWStatus, StatusEntry
from novelwriter.enum import nwStandardButton, nwStatusShape
from novelwriter.extensions.configlayout import NColorLabel, NFixedPage, NScrollableForm
from novelwriter.extensions.modified import NComboBox, NDialog, NIconToolButton
from novelwriter.extensions.pagedsidebar import NPagedSideBar
from novelwriter.extensions.switch import NSwitch
from novelwriter.types import (
    QtRoleAccept, QtRoleReject, QtSizeMinimum, QtSizeMinimumExpanding,
    QtUserRole
)

logger = logging.getLogger(__name__)


class GuiProjectSettings(NDialog):
    """GUI: Project Settings DIalog."""

    PAGE_SETTINGS = 0
    PAGE_STATUS   = 1
    PAGE_IMPORT   = 2
    PAGE_REPLACE  = 3

    newProjectSettingsReady = pyqtSignal()

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

        logger.debug("Create: GuiProjectSettings")
        self.setObjectName("GuiProjectSettings")
        self.setWindowTitle(self.tr("Project Settings"))

        options = SHARED.project.options
        self.setMinimumSize(500, 400)
        self.resize(
            options.getInt("GuiProjectSettings", "winWidth", 650),
            options.getInt("GuiProjectSettings", "winHeight", 500),
        )

        # Title
        self.titleLabel = NColorLabel(
            self.tr("Project Settings"), self, color=SHARED.theme.helpText,
            scale=NColorLabel.HEADER_SCALE, indent=4,
        )

        # SideBar
        self.sidebar = NPagedSideBar(self)
        self.sidebar.setLabelColor(SHARED.theme.helpText)
        self.sidebar.setAccessibleName(self.titleLabel.text())
        self.sidebar.addButton(self.tr("Settings"), self.PAGE_SETTINGS)
        self.sidebar.addButton(self.tr("Status"), self.PAGE_STATUS)
        self.sidebar.addButton(self.tr("Importance"), self.PAGE_IMPORT)
        self.sidebar.addButton(self.tr("Auto-Replace"), self.PAGE_REPLACE)
        self.sidebar.buttonClicked.connect(self._sidebarClicked)

        # Buttons
        self.btnSave = SHARED.theme.getStandardButton(nwStandardButton.SAVE, self)
        self.btnSave.clicked.connect(self._doSave)

        self.btnCancel = SHARED.theme.getStandardButton(nwStandardButton.CANCEL, self)
        self.btnCancel.clicked.connect(self.closeDialog)

        self.btnBox = QDialogButtonBox(self)
        self.btnBox.addButton(self.btnSave, QtRoleAccept)
        self.btnBox.addButton(self.btnCancel, QtRoleReject)

        # Content
        SHARED.project.countStatus()

        self.settingsPage = _SettingsPage(self)
        self.statusPage = _StatusPage(self, True)
        self.importPage = _StatusPage(self, False)
        self.replacePage = _ReplacePage(self)

        self.mainStack = QStackedWidget(self)
        self.mainStack.addWidget(self.settingsPage)
        self.mainStack.addWidget(self.statusPage)
        self.mainStack.addWidget(self.importPage)
        self.mainStack.addWidget(self.replacePage)

        # Assemble
        self.topBox = QHBoxLayout()
        self.topBox.addWidget(self.titleLabel)
        self.topBox.addStretch(1)

        self.mainBox = QHBoxLayout()
        self.mainBox.addWidget(self.sidebar)
        self.mainBox.addWidget(self.mainStack)
        self.mainBox.setContentsMargins(0, 0, 0, 0)

        self.outerBox = QVBoxLayout()
        self.outerBox.addLayout(self.topBox)
        self.outerBox.addLayout(self.mainBox)
        self.outerBox.addWidget(self.btnBox)
        self.outerBox.setSpacing(8)

        self.setLayout(self.outerBox)
        self.setSizeGripEnabled(True)

        # Jump to Specified Page
        self.sidebar.setSelected(gotoPage)
        self._sidebarClicked(gotoPage)

        logger.debug("Ready: GuiProjectSettings")

    def __del__(self) -> None:  # pragma: no cover
        logger.debug("Delete: GuiProjectSettings")

    ##
    #  Events
    ##

    def closeEvent(self, event: QCloseEvent) -> None:
        """Capture the user closing the window and save settings."""
        self._saveSettings()
        event.accept()
        self.softDelete()

    ##
    #  Private Slots
    ##

    @pyqtSlot(int)
    def _sidebarClicked(self, pageId: int) -> None:
        """Process a user request to switch page."""
        if pageId == self.PAGE_SETTINGS:
            self.mainStack.setCurrentWidget(self.settingsPage)
        elif pageId == self.PAGE_STATUS:
            self.mainStack.setCurrentWidget(self.statusPage)
        elif pageId == self.PAGE_IMPORT:
            self.mainStack.setCurrentWidget(self.importPage)
        elif pageId == self.PAGE_REPLACE:
            self.mainStack.setCurrentWidget(self.replacePage)

    @pyqtSlot()
    def _doSave(self) -> None:
        """Save settings and close dialog."""
        project    = SHARED.project
        projName   = self.settingsPage.projName.text()
        projAuthor = self.settingsPage.projAuthor.text()
        projLang   = self.settingsPage.projLang.currentData()
        spellLang  = self.settingsPage.spellLang.currentData()
        doBackup   = not self.settingsPage.noBackup.isChecked()

        project.data.setName(projName)
        project.data.setAuthor(projAuthor)
        project.data.setDoBackup(doBackup)
        project.data.setSpellLang(spellLang)
        project.setProjectLang(projLang)

        if self.statusPage.changed:
            logger.debug("Updating status labels")
            project.updateStatus("s", self.statusPage.getNewList())

        if self.importPage.changed:
            logger.debug("Updating importance labels")
            project.updateStatus("i", self.importPage.getNewList())

        if self.replacePage.changed:
            logger.debug("Updating auto-replace settings")
            project.data.setAutoReplace(self.replacePage.getNewList())

        self.newProjectSettingsReady.emit()
        QApplication.processEvents()
        self.close()

    ##
    #  Internal Functions
    ##

    def _saveSettings(self) -> None:
        """Save GUI settings."""
        statusColW  = self.statusPage.columnWidth()
        importColW  = self.importPage.columnWidth()
        replaceColW = self.replacePage.columnWidth()

        logger.debug("Saving State: GuiProjectSettings")
        options = SHARED.project.options
        options.setValue("GuiProjectSettings", "winWidth", self.width())
        options.setValue("GuiProjectSettings", "winHeight", self.height())
        options.setValue("GuiProjectSettings", "statusColW", statusColW)
        options.setValue("GuiProjectSettings", "importColW", importColW)
        options.setValue("GuiProjectSettings", "replaceColW", replaceColW)


class _SettingsPage(NScrollableForm):

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

        data = SHARED.project.data
        self.setHelpTextStyle(SHARED.theme.helpText)
        self.setRowIndent(0)

        # Project Name
        self.projName = QLineEdit(self)
        self.projName.setMaxLength(200)
        self.projName.setMinimumWidth(200)
        self.projName.setText(data.name)
        self.addRow(
            self.tr("Project name"), self.projName,
            self.tr("Changing this will affect the backup path."),
            stretch=(3, 2)
        )

        # Project Author
        self.projAuthor = QLineEdit(self)
        self.projAuthor.setMaxLength(200)
        self.projAuthor.setMinimumWidth(200)
        self.projAuthor.setText(data.author)
        self.addRow(
            self.tr("Author(s)"), self.projAuthor,
            self.tr("Only used when building the manuscript."),
            stretch=(3, 2)
        )

        # Project Language
        projLang = data.language or CONFIG.guiLocale
        self.projLang = NComboBox(self)
        self.projLang.setMinimumWidth(200)
        for tag, language in CONFIG.listLanguages(CONFIG.LANG_PROJ):
            self.projLang.addItem(language, tag)
        self.projLang.setCurrentData(projLang, projLang)
        self.addRow(
            self.tr("Project language"), self.projLang,
            self.tr("Only used when building the manuscript."),
            stretch=(3, 2)
        )

        # Spell Check Language
        self.spellLang = NComboBox(self)
        self.spellLang.setMinimumWidth(200)
        self.spellLang.addItem(self.tr("Default"), "None")
        if CONFIG.hasEnchant:
            for tag, language in SHARED.spelling.listDictionaries():
                self.spellLang.addItem(language, tag)
        self.addRow(
            self.tr("Spell check language"), self.spellLang,
            self.tr("Overrides main preferences."),
            stretch=(3, 2)
        )
        if (idx := self.spellLang.findData(data.spellLang)) != -1:
            self.spellLang.setCurrentIndex(idx)

        # Backup on Close
        self.noBackup = NSwitch(self)
        self.noBackup.setChecked(not data.doBackup)
        self.addRow(
            self.tr("Disable backup on close"), self.noBackup,
            self.tr("Overrides main preferences.")
        )

        self.finalise()


class _StatusPage(NFixedPage):

    C_DATA  = 0
    C_LABEL = 0
    C_USAGE = 1

    D_KEY   = QtUserRole
    D_ENTRY = QtUserRole + 1

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

        if isStatus:
            self._kind = self.tr("Status")
            self._store = SHARED.project.data.itemStatus
            pageLabel = self.tr("Novel Document Status Levels")
            colSetting = "statusColW"
        else:
            self._kind = self.tr("Importance")
            self._store = SHARED.project.data.itemImport
            pageLabel = self.tr("Project Note Importance Levels")
            colSetting = "importColW"

        wCol0 = SHARED.project.options.getInt("GuiProjectSettings", colSetting, 130)

        self._changed = False
        self._color = QColor(100, 100, 100)
        self._shape = nwStatusShape.SQUARE
        self._theme = CUSTOM_COL
        self._icons = {}

        self._iPx = SHARED.theme.baseIconHeight
        iSz = SHARED.theme.baseIconSize

        iColor = self.palette().text().color()

        # Labels
        self.trCountNone = self.tr("Not in use")
        self.trCountOne  = self.tr("Used once")
        self.trCountMore = self.tr("Used by {0} items")
        self.trSelColor  = self.tr("Select Colour")

        # Title
        self.pageTitle = NColorLabel(
            pageLabel, self, color=SHARED.theme.helpText,
            scale=NColorLabel.HEADER_SCALE
        )

        # List Box
        self.listBox = QTreeWidget(self)
        self.listBox.setHeaderLabels([self.tr("Label"), self.tr("Usage")])
        self.listBox.setColumnWidth(self.C_LABEL, wCol0)
        self.listBox.setIndentation(0)
        self.listBox.setAccessibleName(pageLabel)
        self.listBox.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.listBox.itemSelectionChanged.connect(self._onSelectionChanged)

        for key, entry in self._store.iterItems():
            self._addItem(key, StatusEntry.duplicate(entry))

        # List Controls
        self.addButton = NIconToolButton(self, iSz, "add", "add")
        self.addButton.setToolTip(self.tr("Add Label"))
        self.addButton.clicked.connect(self._onItemCreate)

        self.delButton = NIconToolButton(self, iSz, "remove", "remove")
        self.delButton.setToolTip(self.tr("Delete Label"))
        self.delButton.clicked.connect(self._onItemDelete)

        self.upButton = NIconToolButton(self, iSz, "chevron_up", "action")
        self.upButton.setToolTip(self.tr("Move Up"))
        self.upButton.clicked.connect(qtLambda(self._moveItem, -1))

        self.downButton = NIconToolButton(self, iSz, "chevron_down", "action")
        self.downButton.setToolTip(self.tr("Move Down"))
        self.downButton.clicked.connect(qtLambda(self._moveItem, 1))

        self.importButton = NIconToolButton(self, iSz, "import", "apply")
        self.importButton.setToolTip(self.tr("Import Labels"))
        self.importButton.clicked.connect(self._importLabels)

        self.exportButton = NIconToolButton(self, iSz, "export", "action")
        self.exportButton.setToolTip(self.tr("Export Labels"))
        self.exportButton.clicked.connect(self._exportLabels)

        # Edit Form
        self.editName = QLineEdit(self)
        self.editName.setMaxLength(40)
        self.editName.setPlaceholderText(self.tr("Select item to edit"))
        self.editName.setEnabled(False)
        self.editName.textEdited.connect(self._onNameEdit)

        self.labelName = QLabel(self.tr("Label"), self)
        self.labelName.setBuddy(self.editName)

        # Icon Colours
        self.iconColor = NComboBox(self)
        self.iconColor.setMinimumWidth(200)
        self.iconColor.setEnabled(False)
        self.iconColor.addItem(self.tr("Custom"), CUSTOM_COL)
        for key, label in nwLabels.THEME_COLORS.items():
            self.iconColor.addItem(trConst(label), key)
        self.iconColor.currentIndexChanged.connect(self._onThemeSelect)

        self.labelColor = QLabel(self.tr("Colour"), self)
        self.labelColor.setBuddy(self.iconColor)

        buttonStyle = (
            "QToolButton {padding: 0 4px;} "
            "QToolButton::menu-indicator {image: none;}"
        )

        self.colorButton = NIconToolButton(self, iSz)
        self.colorButton.setToolTip(self.tr("Colour"))
        self.colorButton.setSizePolicy(QtSizeMinimum, QtSizeMinimumExpanding)
        self.colorButton.setStyleSheet(buttonStyle)
        self.colorButton.setEnabled(False)
        self.colorButton.clicked.connect(self._onColorSelect)

        def buildMenu(menu: QMenu | None, items: dict[nwStatusShape, str]) -> None:
            if menu is not None:
                for shape, label in items.items():
                    icon = NWStatus.createIcon(self._iPx, iColor, shape)
                    action = qtAddAction(menu, trConst(label))
                    action.setIcon(icon)
                    action.triggered.connect(qtLambda(self._selectShape, shape))
                    menu.addAction(action)
                    self._icons[shape] = icon

        self.shapeMenu = QMenu(self)
        buildMenu(self.shapeMenu, nwLabels.SHAPES_PLAIN)
        buildMenu(self.shapeMenu.addMenu(self.tr("Circles ...")), nwLabels.SHAPES_CIRCLE)
        buildMenu(self.shapeMenu.addMenu(self.tr("Bars ...")), nwLabels.SHAPES_BARS)
        buildMenu(self.shapeMenu.addMenu(self.tr("Blocks ...")), nwLabels.SHAPES_BLOCKS)

        self.shapeButton = NIconToolButton(self, iSz)
        self.shapeButton.setMenu(self.shapeMenu)
        self.shapeButton.setToolTip(self.tr("Shape"))
        self.shapeButton.setSizePolicy(QtSizeMinimum, QtSizeMinimumExpanding)
        self.shapeButton.setStyleSheet(buttonStyle)
        self.shapeButton.setEnabled(False)

        self.labelShape = QLabel(self.tr("Shape"), self)
        self.labelShape.setBuddy(self.iconColor)

        # Assemble
        self.listControls = QVBoxLayout()
        self.listControls.addWidget(self.addButton)
        self.listControls.addWidget(self.delButton)
        self.listControls.addWidget(self.upButton)
        self.listControls.addWidget(self.downButton)
        self.listControls.addStretch(1)
        self.listControls.addWidget(self.importButton)
        self.listControls.addWidget(self.exportButton)

        self.editBox = QGridLayout()
        self.editBox.addWidget(self.labelName, 0, 0)
        self.editBox.addWidget(self.editName, 0, 1, 1, 5)
        self.editBox.addWidget(self.labelColor, 1, 0)
        self.editBox.addWidget(self.iconColor, 1, 1)
        self.editBox.addWidget(self.colorButton, 1, 2)
        self.editBox.addWidget(self.labelShape, 1, 3)
        self.editBox.addWidget(self.shapeButton, 1, 4)
        self.editBox.setColumnStretch(5, 1)

        self.innerBox = QGridLayout()
        self.innerBox.addWidget(self.listBox, 0, 0)
        self.innerBox.addLayout(self.listControls, 0, 1)
        self.innerBox.addLayout(self.editBox, 1, 0)
        self.innerBox.setRowStretch(0, 1)
        self.innerBox.setColumnStretch(0, 1)

        self.outerBox = QVBoxLayout()
        self.outerBox.addWidget(self.pageTitle, 0)
        self.outerBox.addLayout(self.innerBox, 1)

        self.setCentralLayout(self.outerBox)
        self._setButtonIcons()

    @property
    def changed(self) -> bool:
        """The user changed these settings."""
        return self._changed

    ##
    #  Methods
    ##

    def getNewList(self) -> list[tuple[str | None, StatusEntry]]:
        """Return list of entries."""
        if self._changed:
            update = []
            for n in range(self.listBox.topLevelItemCount()):
                if item := self.listBox.topLevelItem(n):
                    key = item.data(self.C_DATA, self.D_KEY)
                    entry = item.data(self.C_DATA, self.D_ENTRY)
                    update.append((key, entry))
            return update
        return []

    def columnWidth(self) -> int:
        """Return the size of the header column."""
        return self.listBox.columnWidth(0)

    ##
    #  Private Slots
    ##

    @pyqtSlot(str)
    def _onNameEdit(self, text: str) -> None:
        """Update the status label text."""
        if item := self._getSelectedItem():
            name = simplified(text)
            entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY)
            entry.name = name
            item.setText(self.C_LABEL, name)
            self._changed = True

    @pyqtSlot(int)
    def _onThemeSelect(self, index: int) -> None:
        """Update the colour handling on theme selection change."""
        self._theme = str(self.iconColor.currentData())
        self._setButtonIcons()
        self._updateIcon()

    @pyqtSlot()
    def _onColorSelect(self) -> None:
        """Open a dialog to select the status icon colour."""
        if (color := QColorDialog.getColor(self._color, self, self.trSelColor)).isValid():
            self._color = color
            self._theme = CUSTOM_COL
            self._setButtonIcons()
            self._updateIcon()

    @pyqtSlot()
    def _onItemCreate(self) -> None:
        """Create a new status item."""
        color = QColor(100, 100, 100)
        shape = nwStatusShape.SQUARE
        icon = NWStatus.createIcon(self._iPx, color, shape)
        theme = str(self.iconColor.currentData())
        self._addItem(None, StatusEntry(self.tr("New Item"), color, theme, shape, icon, 0))
        self._changed = True

    @pyqtSlot()
    def _onItemDelete(self) -> None:
        """Delete a status item."""
        if item := self._getSelectedItem():
            iRow = self.listBox.indexOfTopLevelItem(item)
            entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY)
            if entry.count > 0:
                SHARED.error(self.tr("Cannot delete a status item that is in use."))
            else:
                self.listBox.takeTopLevelItem(iRow)
                self._changed = True

    @pyqtSlot()
    def _onSelectionChanged(self) -> None:
        """Extract the info of a selected item and populate the settings
        boxes and button. If no item is selected, clear the form.
        """
        if item := self._getSelectedItem():
            entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY)
            self._color = entry.color
            self._shape = entry.shape
            self._theme = entry.theme
            self._setButtonIcons()

            self.editName.setText(entry.name)
            self.editName.selectAll()
            self.editName.setFocus()

            self.editName.setEnabled(True)
            self.iconColor.setEnabled(True)
            self.colorButton.setEnabled(True)
            self.shapeButton.setEnabled(True)
        else:
            self._color = QColor(100, 100, 100)
            self._shape = nwStatusShape.SQUARE
            self._theme = CUSTOM_COL
            self._setButtonIcons()
            self.editName.setText("")

            self.editName.setEnabled(False)
            self.iconColor.setEnabled(False)
            self.colorButton.setEnabled(False)
            self.shapeButton.setEnabled(False)

    @pyqtSlot()
    def _importLabels(self) -> None:
        """Import labels from file."""
        if path := QFileDialog.getOpenFileName(
            self, self.tr("Import File"),
            str(CONFIG.homePath()), filter=formatFileFilter(["*.csv", "*"]),
        )[0]:
            try:
                with open(path, mode="r", encoding="utf-8") as fo:
                    for row in csv.reader(fo):
                        if entry := self._store.fromRaw(row):
                            self._addItem(None, entry)
                            self._changed = True
            except Exception as exc:
                SHARED.error("Could not read file.", exc=exc)
                return
        return

    @pyqtSlot()
    def _exportLabels(self) -> None:
        """Export labels to file."""
        name = f"{SHARED.project.data.fileSafeName} - {self._kind}.csv"
        if path := QFileDialog.getSaveFileName(
            self, self.tr("Export File"), str(CONFIG.homePath() / name),
        )[0]:
            try:
                path = Path(path).with_suffix(".csv")
                with open(path, mode="w", encoding="utf-8") as fo:
                    writer = csv.writer(fo)
                    for n in range(self.listBox.topLevelItemCount()):
                        if item := self.listBox.topLevelItem(n):
                            entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY)
                            writer.writerow([entry.shape.name, entry.color.name(), entry.name])
            except Exception as exc:
                SHARED.error("Could not write file.", exc=exc)

    ##
    #  Internal Functions
    ##

    def _selectShape(self, shape: nwStatusShape) -> None:
        """Set the current shape."""
        self._shape = shape
        self._setButtonIcons()
        self._updateIcon()

    def _updateIcon(self) -> None:
        """Apply changes made to a status icon."""
        if item := self._getSelectedItem():
            icon = NWStatus.createIcon(self._iPx, self._pickColor(), self._shape)
            entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY)
            entry.color = self._color
            entry.shape = self._shape
            entry.theme = self._theme
            entry.icon = icon
            item.setIcon(self.C_LABEL, icon)
            self._changed = True

    def _addItem(self, key: str | None, entry: StatusEntry) -> None:
        """Add a status item to the list."""
        item = QTreeWidgetItem()
        item.setText(self.C_LABEL, entry.name)
        item.setIcon(self.C_LABEL, entry.icon)
        item.setText(self.C_USAGE, self._usageString(entry.count))
        item.setData(self.C_DATA, self.D_KEY, key)
        item.setData(self.C_DATA, self.D_ENTRY, entry)
        self.listBox.addTopLevelItem(item)

    def _moveItem(self, step: int) -> None:
        """Move and item up or down step."""
        if item := self._getSelectedItem():
            tIdx = self.listBox.indexOfTopLevelItem(item)
            nItm = self.listBox.topLevelItemCount()
            nIdx = tIdx + step
            if (0 <= nIdx < nItm) and (cItem := self.listBox.takeTopLevelItem(tIdx)):
                self.listBox.insertTopLevelItem(nIdx, cItem)
                self.listBox.clearSelection()
                cItem.setSelected(True)
                self._changed = True

    def _getSelectedItem(self) -> QTreeWidgetItem | None:
        """Get the currently selected item."""
        if items := self.listBox.selectedItems():
            return items[0]
        return None

    def _usageString(self, count: int) -> str:
        """Generate usage string."""
        if count == 0:
            return self.trCountNone
        elif count == 1:
            return self.trCountOne
        else:
            return self.trCountMore.format(count)

    def _setButtonIcons(self) -> None:
        """Set the colour of the colour button."""
        icon = NWStatus.createIcon(self._iPx, self._pickColor(), nwStatusShape.SQUARE)
        self.iconColor.setCurrentData(self._theme, CUSTOM_COL)
        self.colorButton.setIcon(icon)
        self.shapeButton.setIcon(self._icons[self._shape])

    def _pickColor(self) -> QColor:
        """Get the correct colour value based on selections."""
        if self._theme == CUSTOM_COL:
            return self._color
        return SHARED.theme.getBaseColor(self._theme)


class _ReplacePage(NFixedPage):

    C_KEY  = 0
    C_REPL = 1

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

        self._changed = False

        iSz = SHARED.theme.baseIconSize
        wCol0 = SHARED.project.options.getInt("GuiProjectSettings", "replaceColW", 130)

        # Title
        self.pageTitle = NColorLabel(
            self.tr("Text Auto-Replace for Preview and Build"), self,
            color=SHARED.theme.helpText, scale=NColorLabel.HEADER_SCALE
        )

        # List Box
        self.listBox = QTreeWidget(self)
        self.listBox.setHeaderLabels([self.tr("Keyword"), self.tr("Replace With")])
        self.listBox.setColumnWidth(self.C_KEY, wCol0)
        self.listBox.setIndentation(0)
        self.listBox.setAccessibleName(self.pageTitle.text())
        self.listBox.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.listBox.itemSelectionChanged.connect(self._onSelectionChanged)

        for aKey, aVal in SHARED.project.data.autoReplace.items():
            newItem = QTreeWidgetItem([f"<{aKey}>", aVal])
            self.listBox.addTopLevelItem(newItem)

        self.listBox.sortByColumn(self.C_KEY, Qt.SortOrder.AscendingOrder)
        self.listBox.setSortingEnabled(True)

        # List Controls
        self.addButton = NIconToolButton(self, iSz, "add", "add")
        self.addButton.clicked.connect(self._onEntryCreated)

        self.delButton = NIconToolButton(self, iSz, "remove", "remove")
        self.delButton.clicked.connect(self._onEntryDeleted)

        # Edit Form
        self.editKey = QLineEdit(self)
        self.editKey.setPlaceholderText(self.tr("Select item to edit"))
        self.editKey.setEnabled(False)
        self.editKey.setMaxLength(40)
        self.editKey.textEdited.connect(self._onKeyEdit)

        self.editValue = QLineEdit(self)
        self.editValue.setEnabled(False)
        self.editValue.setMaxLength(250)
        self.editValue.textEdited.connect(self._onValueEdit)

        # Assemble
        self.listControls = QVBoxLayout()
        self.listControls.addWidget(self.addButton)
        self.listControls.addWidget(self.delButton)
        self.listControls.addStretch(1)

        self.editBox = QHBoxLayout()
        self.editBox.addWidget(self.editKey, 4)
        self.editBox.addWidget(self.editValue, 5)

        self.mainBox = QVBoxLayout()
        self.mainBox.addWidget(self.listBox, 1)
        self.mainBox.addLayout(self.editBox, 0)

        self.innerBox = QHBoxLayout()
        self.innerBox.addLayout(self.mainBox)
        self.innerBox.addLayout(self.listControls)

        self.outerBox = QVBoxLayout()
        self.outerBox.addWidget(self.pageTitle)
        self.outerBox.addLayout(self.innerBox)

        self.setCentralLayout(self.outerBox)

    @property
    def changed(self) -> bool:
        """The user changed these settings."""
        return self._changed

    ##
    #  Methods
    ##

    def getNewList(self) -> dict[str, str]:
        """Extract the list from the widget."""
        new = {}
        for n in range(self.listBox.topLevelItemCount()):
            if item := self.listBox.topLevelItem(n):
                if key := self._stripKey(item.text(self.C_KEY)):
                    new[key] = item.text(self.C_REPL)
        return new

    def columnWidth(self) -> int:
        """Return the size of the header column."""
        return self.listBox.columnWidth(0)

    ##
    #  Private Slots
    ##

    @pyqtSlot(str)
    def _onKeyEdit(self, text: str) -> None:
        """Update the key text."""
        if (item := self._getSelectedItem()) and (key := self._stripKey(text)):
            item.setText(self.C_KEY, f"<{key}>")
            self._changed = True

    @pyqtSlot(str)
    def _onValueEdit(self, text: str) -> None:
        """Update the value text."""
        if item := self._getSelectedItem():
            item.setText(self.C_REPL, text)
            self._changed = True

    @pyqtSlot()
    def _onSelectionChanged(self) -> None:
        """Extract the details from the selected item and populate the
        edit form.
        """
        if item := self._getSelectedItem():
            self.editKey.setText(self._stripKey(item.text(self.C_KEY)))
            self.editValue.setText(item.text(self.C_REPL))
            self.editKey.setEnabled(True)
            self.editValue.setEnabled(True)
            self.editKey.selectAll()
            self.editKey.setFocus()
        else:
            self.editKey.setText("")
            self.editValue.setText("")
            self.editKey.setEnabled(False)
            self.editValue.setEnabled(False)

    @pyqtSlot()
    def _onEntryCreated(self) -> None:
        """Add a new list entry."""
        key = f"<keyword{self.listBox.topLevelItemCount() + 1:d}>"
        self.listBox.addTopLevelItem(QTreeWidgetItem([key, ""]))

    @pyqtSlot()
    def _onEntryDeleted(self) -> None:
        """Delete the selected entry."""
        if item := self._getSelectedItem():
            self.listBox.takeTopLevelItem(self.listBox.indexOfTopLevelItem(item))
            self._changed = True

    ##
    #  Internal Functions
    ##

    def _getSelectedItem(self) -> QTreeWidgetItem | None:
        """Extract the currently selected item."""
        if items := self.listBox.selectedItems():
            return items[0]
        return None

    def _stripKey(self, key: str) -> str:
        """Clean up the replace key string."""
        return "".join(c for c in key if c.isalnum())
