"""
novelWriter – Build Settings
============================

File History:
Created: 2023-02-14 [2.1b1] BuildSettings
Created: 2023-05-22 [2.1b1] BuildCollection

This file is a part of novelWriter
Copyright (C) 2023 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/>.
"""
from __future__ import annotations

import json
import logging
import uuid

from collections.abc import Iterable
from enum import Enum
from pathlib import Path

from PyQt5.QtCore import QT_TRANSLATE_NOOP, QCoreApplication

from novelwriter import CONFIG
from novelwriter.common import checkUuid, isHandle, jsonEncode
from novelwriter.constants import nwFiles, nwHeadFmt, nwStyles
from novelwriter.core.project import NWProject
from novelwriter.enum import nwBuildFmt
from novelwriter.error import logException

logger = logging.getLogger(__name__)

# The Settings Template
# =====================
# Each entry contains a tuple on the form: (type, default)

SETTINGS_TEMPLATE: dict[str, tuple[type, str | int | float | bool]] = {
    "filter.includeNovel":     (bool, True),
    "filter.includeNotes":     (bool, False),
    "filter.includeInactive":  (bool, False),
    "headings.fmtPart":        (str, nwHeadFmt.TITLE),
    "headings.fmtChapter":     (str, nwHeadFmt.TITLE),
    "headings.fmtUnnumbered":  (str, nwHeadFmt.TITLE),
    "headings.fmtScene":       (str, "* * *"),
    "headings.fmtAltScene":    (str, ""),
    "headings.fmtSection":     (str, ""),
    "headings.hidePart":       (bool, False),
    "headings.hideChapter":    (bool, False),
    "headings.hideUnnumbered": (bool, False),
    "headings.hideScene":      (bool, False),
    "headings.hideAltScene":   (bool, False),
    "headings.hideSection":    (bool, True),
    "headings.centerTitle":    (bool, True),
    "headings.centerPart":     (bool, True),
    "headings.centerChapter":  (bool, False),
    "headings.centerScene":    (bool, False),
    "headings.breakTitle":     (bool, False),
    "headings.breakPart":      (bool, True),
    "headings.breakChapter":   (bool, True),
    "headings.breakScene":     (bool, False),
    "text.includeSynopsis":    (bool, False),
    "text.includeComments":    (bool, False),
    "text.includeKeywords":    (bool, False),
    "text.includeBodyText":    (bool, True),
    "text.ignoredKeywords":    (str, ""),
    "text.addNoteHeadings":    (bool, True),
    "format.textFont":         (str, CONFIG.textFont.toString()),
    "format.lineHeight":       (float, 1.15),
    "format.justifyText":      (bool, False),
    "format.stripUnicode":     (bool, False),
    "format.replaceTabs":      (bool, False),
    "format.keepBreaks":       (bool, True),
    "format.showDialogue":     (bool, False),
    "format.firstLineIndent":  (bool, False),
    "format.firstIndentWidth": (float, 1.4),
    "format.indentFirstPar":   (bool, False),
    "format.titleMarginT":     (float, nwStyles.T_MARGIN["H0"][0]),
    "format.titleMarginB":     (float, nwStyles.T_MARGIN["H0"][1]),
    "format.h1MarginT":        (float, nwStyles.T_MARGIN["H1"][0]),
    "format.h1MarginB":        (float, nwStyles.T_MARGIN["H1"][1]),
    "format.h2MarginT":        (float, nwStyles.T_MARGIN["H2"][0]),
    "format.h2MarginB":        (float, nwStyles.T_MARGIN["H2"][1]),
    "format.h3MarginT":        (float, nwStyles.T_MARGIN["H3"][0]),
    "format.h3MarginB":        (float, nwStyles.T_MARGIN["H3"][1]),
    "format.h4MarginT":        (float, nwStyles.T_MARGIN["H4"][0]),
    "format.h4MarginB":        (float, nwStyles.T_MARGIN["H4"][1]),
    "format.textMarginT":      (float, nwStyles.T_MARGIN["TT"][0]),
    "format.textMarginB":      (float, nwStyles.T_MARGIN["TT"][1]),
    "format.sepMarginT":       (float, nwStyles.T_MARGIN["SP"][0]),
    "format.sepMarginB":       (float, nwStyles.T_MARGIN["SP"][1]),
    "format.pageUnit":         (str, "cm"),
    "format.pageSize":         (str, "A4"),
    "format.pageWidth":        (float, 21.0),
    "format.pageHeight":       (float, 29.7),
    "format.topMargin":        (float, 2.0),
    "format.bottomMargin":     (float, 2.0),
    "format.leftMargin":       (float, 2.0),
    "format.rightMargin":      (float, 2.0),
    "doc.pageHeader":          (str, nwHeadFmt.DOC_AUTO),
    "doc.pageCountOffset":     (int, 0),
    "doc.colorHeadings":       (bool, True),
    "doc.scaleHeadings":       (bool, True),
    "doc.boldHeadings":        (bool, True),
    "html.addStyles":          (bool, True),
    "html.preserveTabs":       (bool, False),
}

SETTINGS_LABELS = {
    "filter":                  QT_TRANSLATE_NOOP("Builds", "Document Filters"),
    "filter.includeNovel":     QT_TRANSLATE_NOOP("Builds", "Novel Documents"),
    "filter.includeNotes":     QT_TRANSLATE_NOOP("Builds", "Project Notes"),
    "filter.includeInactive":  QT_TRANSLATE_NOOP("Builds", "Inactive Documents"),

    "headings":                QT_TRANSLATE_NOOP("Builds", "Headings"),
    "headings.fmtPart":        QT_TRANSLATE_NOOP("Builds", "Partition Format"),
    "headings.fmtChapter":     QT_TRANSLATE_NOOP("Builds", "Chapter Format"),
    "headings.fmtUnnumbered":  QT_TRANSLATE_NOOP("Builds", "Unnumbered Format"),
    "headings.fmtScene":       QT_TRANSLATE_NOOP("Builds", "Scene Format"),
    "headings.fmtAltScene":    QT_TRANSLATE_NOOP("Builds", "Alt. Scene Format"),
    "headings.fmtSection":     QT_TRANSLATE_NOOP("Builds", "Section Format"),
    "headings.styleTitle":     QT_TRANSLATE_NOOP("Builds", "Title Styling"),
    "headings.stylePart":      QT_TRANSLATE_NOOP("Builds", "Partition Styling"),
    "headings.styleChapter":   QT_TRANSLATE_NOOP("Builds", "Chapter Styling"),
    "headings.styleScene":     QT_TRANSLATE_NOOP("Builds", "Scene Styling"),

    "text.grpContent":         QT_TRANSLATE_NOOP("Builds", "Text Content"),
    "text.includeSynopsis":    QT_TRANSLATE_NOOP("Builds", "Include Synopsis"),
    "text.includeComments":    QT_TRANSLATE_NOOP("Builds", "Include Comments"),
    "text.includeKeywords":    QT_TRANSLATE_NOOP("Builds", "Include Keywords"),
    "text.includeBodyText":    QT_TRANSLATE_NOOP("Builds", "Include Body Text"),
    "text.ignoredKeywords":    QT_TRANSLATE_NOOP("Builds", "Ignore These Keywords"),
    "text.addNoteHeadings":    QT_TRANSLATE_NOOP("Builds", "Add Titles for Notes"),

    "format.grpFormat":        QT_TRANSLATE_NOOP("Builds", "Text Format"),
    "format.textFont":         QT_TRANSLATE_NOOP("Builds", "Text Font"),
    "format.lineHeight":       QT_TRANSLATE_NOOP("Builds", "Line Height"),
    "format.justifyText":      QT_TRANSLATE_NOOP("Builds", "Justify Text Margins"),
    "format.stripUnicode":     QT_TRANSLATE_NOOP("Builds", "Replace Unicode Characters"),
    "format.replaceTabs":      QT_TRANSLATE_NOOP("Builds", "Replace Tabs with Spaces"),
    "format.keepBreaks":       QT_TRANSLATE_NOOP("Builds", "Preserve Hard Line Breaks"),
    "format.showDialogue":     QT_TRANSLATE_NOOP("Builds", "Apply Dialogue Highlighting"),

    "format.grpParIndent":     QT_TRANSLATE_NOOP("Builds", "First Line Indent"),
    "format.firstLineIndent":  QT_TRANSLATE_NOOP("Builds", "Enable Indent"),
    "format.firstIndentWidth": QT_TRANSLATE_NOOP("Builds", "Indent Width"),
    "format.indentFirstPar":   QT_TRANSLATE_NOOP("Builds", "Indent First Paragraph"),

    "format.grpMargins":       QT_TRANSLATE_NOOP("Builds", "Text Margins"),

    "format.grpPage":          QT_TRANSLATE_NOOP("Builds", "Page Layout"),
    "format.pageUnit":         QT_TRANSLATE_NOOP("Builds", "Unit"),
    "format.pageSize":         QT_TRANSLATE_NOOP("Builds", "Page Size"),
    "format.pageMargins":      QT_TRANSLATE_NOOP("Builds", "Page Margins"),

    "doc":                     QT_TRANSLATE_NOOP("Builds", "Document Style"),
    "doc.pageHeader":          QT_TRANSLATE_NOOP("Builds", "Page Header"),
    "doc.pageCountOffset":     QT_TRANSLATE_NOOP("Builds", "Page Counter Offset"),
    "doc.colorHeadings":       QT_TRANSLATE_NOOP("Builds", "Add Colours to Headings"),
    "doc.scaleHeadings":       QT_TRANSLATE_NOOP("Builds", "Increase Size of Headings"),
    "doc.boldHeadings":        QT_TRANSLATE_NOOP("Builds", "Bold Headings"),

    "html":                    QT_TRANSLATE_NOOP("Builds", "HTML Options"),
    "html.addStyles":          QT_TRANSLATE_NOOP("Builds", "Add CSS Styles"),
    "html.preserveTabs":       QT_TRANSLATE_NOOP("Builds", "Preserve Tab Characters"),
}

RENAMED = {
    "odt.addColours": "doc.addColours",
    "odt.pageHeader": "doc.pageHeader",
    "odt.pageCountOffset": "doc.pageCountOffset",
}


class FilterMode(Enum):
    """The decision reason for an item in a filtered project."""

    UNKNOWN  = 0
    FILTERED = 1
    INCLUDED = 2
    EXCLUDED = 3
    SKIPPED  = 4
    ROOT     = 5


class BuildSettings:
    """Core: Build Settings Class

    This class manages the build settings for a Manuscript build job.
    The settings can be packed/unpacked to/from a dictionary for JSON.
    """

    def __init__(self) -> None:
        self._name = ""
        self._uuid = str(uuid.uuid4())
        self._path = CONFIG.homePath()
        self._build = ""
        self._order = 0
        self._format = nwBuildFmt.ODT
        self._skipRoot = set()
        self._excluded = set()
        self._included = set()
        self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()}
        self._changed = False
        return

    @classmethod
    def fromDict(cls, data: dict) -> BuildSettings:
        """Create a build settings object from a dict."""
        cls = BuildSettings()
        cls.unpack(data)
        return cls

    ##
    #  Properties
    ##

    @property
    def name(self) -> str:
        """Return the build name."""
        return self._name

    @property
    def buildID(self) -> str:
        """Return the build ID as a UUID."""
        return self._uuid

    @property
    def order(self) -> int:
        """Return the build order."""
        return self._order

    @property
    def lastBuildPath(self) -> Path:
        """The last used build path."""
        if self._path.is_dir():
            return self._path
        return CONFIG.homePath()

    @property
    def lastBuildName(self) -> str:
        """The last used build name."""
        return self._build

    @property
    def lastFormat(self) -> nwBuildFmt:
        """The last used build format."""
        return self._format

    @property
    def changed(self) -> bool:
        """The changed status of the build."""
        return self._changed

    ##
    #  Getters
    ##

    @staticmethod
    def getLabel(key: str) -> str:
        """Extract the GUI label for a specific setting."""
        return QCoreApplication.translate("Builds", SETTINGS_LABELS.get(key, "ERROR"))

    def getStr(self, key: str) -> str:
        """Type safe value access for strings."""
        value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None))[1])
        return str(value)

    def getBool(self, key: str) -> bool:
        """Type safe value access for bools."""
        value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None))[1])
        return bool(value)

    def getInt(self, key: str) -> int:
        """Type safe value access for integers."""
        value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None))[1])
        return int(value) if isinstance(value, (int, float)) else 0

    def getFloat(self, key: str) -> float:
        """Type safe value access for floats."""
        value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None))[1])
        return float(value) if isinstance(value, (int, float)) else 0.0

    ##
    #  Setters
    ##

    def setName(self, name: str) -> None:
        """Set the build setting display name."""
        self._name = str(name)
        return

    def setBuildID(self, value: str | uuid.UUID) -> None:
        """Set a UUID build ID."""
        value = checkUuid(value, "")
        if not value:
            self._uuid = str(uuid.uuid4())
        elif value != self._uuid:
            self._uuid = value
        return

    def setOrder(self, value: int) -> None:
        """Set the build order."""
        if isinstance(value, int):
            self._order = value
        return

    def setLastBuildPath(self, path: Path | str | None) -> None:
        """Set the last used build path."""
        if isinstance(path, str):
            path = Path(path)
        if isinstance(path, Path) and path.is_dir():
            self._path = path
        else:
            self._path = CONFIG.homePath()
        self._changed = True
        return

    def setLastBuildName(self, name: str) -> None:
        """Set the last used build name."""
        self._build = str(name).strip()
        self._changed = True
        return

    def setLastFormat(self, value: nwBuildFmt) -> None:
        """Set the last used build format."""
        if isinstance(value, nwBuildFmt):
            self._format = value
            self._changed = True
        return

    def setFiltered(self, tHandle: str) -> None:
        """Set an item as filtered."""
        self._excluded.discard(tHandle)
        self._included.discard(tHandle)
        self._changed = True
        return

    def setIncluded(self, tHandle: str) -> None:
        """Set an item as explicitly included."""
        self._excluded.discard(tHandle)
        self._included.add(tHandle)
        self._changed = True
        return

    def setExcluded(self, tHandle: str) -> None:
        """Set an item as explicitly excluded."""
        self._excluded.add(tHandle)
        self._included.discard(tHandle)
        self._changed = True
        return

    def setAllowRoot(self, tHandle: str, state: bool) -> None:
        """Set a specific root folder as allowed or not."""
        if state is True:
            self._skipRoot.discard(tHandle)
            self._changed = True
        elif state is False:
            self._skipRoot.add(tHandle)
            self._changed = True
        return

    def setValue(self, key: str, value: str | int | float | bool) -> None:
        """Set a specific value for a build setting."""
        if (d := SETTINGS_TEMPLATE.get(key)) and len(d) == 2 and isinstance(value, d[0]):
            self._changed = value != self._settings[key]
            self._settings[key] = value
        return

    ##
    #  Methods
    ##

    def isRootAllowed(self, tHandle: str) -> bool:
        """Check if a root handle is allowed in the build."""
        return tHandle not in self._skipRoot

    def buildItemFilter(
        self, project: NWProject, withRoots: bool = False
    ) -> dict[str, tuple[bool, FilterMode]]:
        """Return a dictionary of item handles with filter decisions
        applied.
        """
        result: dict[str, tuple[bool, FilterMode]] = {}
        if not isinstance(project, NWProject):
            return result

        incNovel = bool(self.getBool("filter.includeNovel"))
        incNotes = bool(self.getBool("filter.includeNotes"))
        incInactive = bool(self.getBool("filter.includeInactive"))

        postponed = []

        def allowRoot(rHandle: str | None) -> None:
            if rHandle in postponed and rHandle in result and rHandle is not None:
                result[rHandle] = (True, FilterMode.ROOT)
                postponed.remove(rHandle)

        for item in project.tree:
            tHandle = item.itemHandle
            if item.isInactiveClass() or (item.itemRoot in self._skipRoot):
                result[tHandle] = (False, FilterMode.SKIPPED)
                continue
            if withRoots and item.isRootType():
                result[tHandle] = (False, FilterMode.SKIPPED)
                postponed.append(tHandle)
                continue
            if not item.isFileType():
                result[tHandle] = (False, FilterMode.SKIPPED)
                continue
            if tHandle in self._included:
                result[tHandle] = (True, FilterMode.INCLUDED)
                allowRoot(item.itemRoot)
                continue
            if tHandle in self._excluded:
                result[tHandle] = (False, FilterMode.EXCLUDED)
                continue

            isNote = item.isNoteLayout()
            isNovel = item.isDocumentLayout()
            isActive = item.isActive

            byActive = isActive or (not isActive and incInactive)
            byLayout = (isNote and incNotes) or (isNovel and incNovel)

            isAllowed = byActive and byLayout

            result[tHandle] = (isAllowed, FilterMode.FILTERED)
            if isAllowed:
                allowRoot(item.itemRoot)

        return result

    def resetChangedState(self) -> None:
        """Reset the changed status of the settings object. This must be
        called when the changes have been safely saved or passed on.
        """
        self._changed = False
        return

    def pack(self) -> dict:
        """Pack all content into a JSON compatible dictionary."""
        logger.debug("Collecting build setting for '%s'", self._name)
        return {
            "name": self._name,
            "uuid": self._uuid,
            "path": str(self._path),
            "build": self._build,
            "order": self._order,
            "format": self._format.name,
            "settings": self._settings.copy(),
            "content": {
                "included": list(self._included),
                "excluded": list(self._excluded),
                "skipRoot": list(self._skipRoot),
            }
        }

    def unpack(self, data: dict) -> None:
        """Unpack a dictionary and populate the class."""
        content = data.get("content", {})
        settings = data.get("settings", {})
        included = content.get("included", [])
        excluded = content.get("excluded", [])
        skipRoot = content.get("skipRoot", [])

        self.setName(data.get("name", ""))
        self.setBuildID(data.get("uuid", ""))
        self.setOrder(data.get("order", 0))
        self.setLastBuildPath(data.get("path", None))
        self.setLastBuildName(data.get("build", ""))

        buildFmt = str(data.get("format", ""))
        if buildFmt in nwBuildFmt.__members__:
            self.setLastFormat(nwBuildFmt[buildFmt])

        if isinstance(included, list):
            self._included = set([h for h in included if isHandle(h)])
        if isinstance(excluded, list):
            self._excluded = set([h for h in excluded if isHandle(h)])
        if isinstance(skipRoot, list):
            self._skipRoot = set([h for h in skipRoot if isHandle(h)])

        self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()}
        if isinstance(settings, dict):
            for key, value in settings.items():
                self.setValue(RENAMED.get(key, key), value)

        self._changed = False

        return

    @classmethod
    def duplicate(cls, source: BuildSettings) -> BuildSettings:
        """Make a copy of another build."""
        cls = BuildSettings()
        cls.unpack(source.pack())
        cls._uuid = str(uuid.uuid4())
        cls._name = f"{source.name} 2"
        return cls


class BuildCollection:
    """Core: Build Collection Class

    This object holds all the build setting objects defined by the given
    project. The build settings are saved as a single JSON file in the
    project folder.
    """

    def __init__(self, project: NWProject) -> None:
        self._project = project
        self._lastBuild = ""
        self._defaultBuild = ""
        self._builds: dict[str, BuildSettings] = {}
        self._loadCollection()
        return

    def __len__(self) -> int:
        """Return the number of builds."""
        return len(self._builds)

    ##
    #  Properties
    ##

    @property
    def lastBuild(self) -> str:
        """Return the last active build."""
        return self._lastBuild

    @property
    def defaultBuild(self) -> str:
        """Return the default build."""
        return self._defaultBuild

    ##
    #  Getters
    ##

    def getBuild(self, buildID: str) -> BuildSettings | None:
        """Get a specific build settings object."""
        return self._builds.get(buildID, None)

    ##
    #  Setters
    ##

    def setBuildsState(self, lastBuild: str, order: list[str]) -> None:
        """Set the last active build id."""
        for i, key in enumerate(order):
            if build := self._builds.get(key):
                build.setOrder(i)
        self._lastBuild = lastBuild
        self._saveCollection()
        return

    def setDefaultBuild(self, buildID: str) -> None:
        """Set the default build id."""
        if buildID != self._defaultBuild:
            self._defaultBuild = buildID
            self._saveCollection()
        return

    def setBuild(self, build: BuildSettings) -> None:
        """Set build settings data in the collection."""
        if isinstance(build, BuildSettings):
            self._builds[build.buildID] = build
            self._saveCollection()
        return

    ##
    #  Methods
    ##

    def removeBuild(self, buildID: str) -> None:
        """Remove a build from the collection."""
        self._builds.pop(buildID, None)
        self._saveCollection()
        return

    def builds(self) -> Iterable[tuple[str, str]]:
        """Iterate over all available builds."""
        for buildID, build in sorted(self._builds.items(), key=lambda x: x[1].order):
            yield buildID, build.name
        return

    ##
    #  Internal Functions
    ##

    def _loadCollection(self) -> bool:
        """Load build collections file."""
        buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE)
        if not isinstance(buildsFile, Path):
            return False

        data = {}
        if buildsFile.exists():
            logger.debug("Loading builds file")
            try:
                with open(buildsFile, mode="r", encoding="utf-8") as inFile:
                    data = json.load(inFile)
            except Exception:
                logger.error("Failed to load builds file")
                logException()
                return False

        if not isinstance(data, dict):
            logger.error("Builds file is not a JSON object")
            return False

        builds = data.get("novelWriter.builds", None)
        if not isinstance(builds, dict):
            logger.error("No novelWriter.builds in the builds file")
            return False

        for key, entry in builds.items():
            if key == "lastBuild":
                self._lastBuild = str(entry)
            elif key == "defaultBuild":
                self._defaultBuild = str(entry)
            elif isinstance(entry, dict):
                self._builds[key] = BuildSettings.fromDict(entry)

        return True

    def _saveCollection(self) -> bool:
        """Save build collections file."""
        buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE)
        if not isinstance(buildsFile, Path):
            return False

        logger.debug("Saving builds file")
        try:
            data: dict[str, str | dict] = {
                "lastBuild": self._lastBuild,
                "defaultBuild": self._defaultBuild,
            }
            data.update({k: b.pack() for k, b in self._builds.items()})
            with open(buildsFile, mode="w+", encoding="utf-8") as outFile:
                outFile.write(jsonEncode({"novelWriter.builds": data}, nmax=4))
        except Exception:
            logger.error("Failed to save builds file")
            logException()
            return False

        return True
