"""
novelWriter – Project Index Data
================================

File History:
Created: 2022-05-28 [2.0rc1] IndexNode
Created: 2022-05-28 [2.0rc1] IndexHeading
Moved:   2025-02-22 [2.7b1]  IndexNode
Moved:   2025-02-22 [2.7b1]  IndexHeading

This file is a part of novelWriter
Copyright (C) 2025 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 logging

from typing import TYPE_CHECKING, Literal

from novelwriter import CONFIG
from novelwriter.common import checkInt, compact, isListInstance, isTitleTag
from novelwriter.constants import nwKeyWords, nwStyles

if TYPE_CHECKING:
    from collections.abc import ItemsView, Sequence

    from novelwriter.core.index import IndexCache
    from novelwriter.core.item import NWItem
    from novelwriter.enum import nwComment

logger = logging.getLogger(__name__)

T_NoteTypes = Literal["footnotes", "comments"]

TT_NONE = "T0000"  # Default title key
NOTE_TYPES: list[T_NoteTypes] = ["footnotes", "comments"]


class IndexNode:
    """Core: Single Index Item Node Class.

    This object represents the index data of a project item (NWItem).
    It holds a record of all the headings in the text, and the meta data
    associated with each heading. It also holds a pointer to the project
    item. The main heading level of the item is also held here since it
    must be reset each time the item is re-indexed.
    """

    __slots__ = ("_cache", "_count", "_handle", "_headings", "_item", "_notes")

    def __init__(self, cache: IndexCache, tHandle: str, nwItem: NWItem) -> None:
        self._cache = cache
        self._handle = tHandle
        self._item = nwItem
        self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(self._cache, TT_NONE)}
        self._notes: dict[str, set[str]] = {}
        self._count = 0

    def __repr__(self) -> str:
        return f"<IndexNode handle='{self._handle}'>"

    def __len__(self) -> int:
        return len(self._headings)

    def __getitem__(self, sTitle: str) -> IndexHeading | None:
        return self._headings.get(sTitle, None)

    def __contains__(self, sTitle: str) -> bool:
        return sTitle in self._headings

    ##
    # Properties
    ##

    @property
    def handle(self) -> str:
        """Return the item handle of the index item."""
        return self._handle

    @property
    def item(self) -> NWItem:
        """Return the project item of the index item."""
        return self._item

    ##
    #  Setters
    ##

    def addHeading(self, tHeading: IndexHeading) -> None:
        """Add a heading to the item. Also remove the placeholder entry
        if it exists.
        """
        if TT_NONE in self._headings:
            self._headings.pop(TT_NONE)
        self._headings[tHeading.key] = tHeading

    def setHeadingCounts(self, sTitle: str, cCount: int, wCount: int, pCount: int) -> None:
        """Set the character, word and paragraph count of a heading."""
        if sTitle in self._headings:
            self._headings[sTitle].setCounts([cCount, wCount, pCount])

    def setHeadingComment(self, sTitle: str, comment: nwComment, key: str, text: str) -> None:
        """Set the comment text of a heading."""
        if sTitle in self._headings:
            self._headings[sTitle].setComment(comment.name, key, text)

    def setHeadingTag(self, sTitle: str, tag: str) -> None:
        """Set the tag of a heading."""
        if sTitle in self._headings:
            self._headings[sTitle].setTag(tag)

    def addHeadingRef(self, sTitle: str, tags: list[str], keyword: str) -> None:
        """Add a reference key and all its types to a heading."""
        if sTitle in self._headings:
            for tag in tags:
                self._headings[sTitle].addReference(tag, keyword)

    def addNoteKey(self, style: T_NoteTypes, key: str) -> None:
        """Add a note key to the index."""
        if style not in self._notes:
            self._notes[style] = set()
        self._notes[style].add(key)

    ##
    #  Data Methods
    ##

    def items(self) -> ItemsView[str, IndexHeading]:
        """Return IndexHeading items."""
        return self._headings.items()

    def headings(self) -> list[str]:
        """Return heading keys in sorted order."""
        return sorted(self._headings.keys())

    def allTags(self) -> list[str]:
        """Return a list of all tags in the current item."""
        return [h.tag for h in self._headings.values() if h.tag]

    def nextHeading(self) -> str:
        """Return the next heading key to be used."""
        self._count += 1
        return f"T{self._count:04d}"

    def noteKeys(self, style: T_NoteTypes) -> set[str]:
        """Return a set of all note keys."""
        return self._notes.get(style, set())

    ##
    #  Pack/Unpack
    ##

    def packData(self) -> dict:
        """Pack the indexed item's data into a dictionary."""
        data = {}
        for sTitle, hItem in self._headings.items():
            data[sTitle] = hItem.packData()
        if self._notes:
            data["document"] = {style: list(keys) for style, keys in self._notes.items()}
        return data

    def unpackData(self, data: dict) -> None:
        """Unpack an item entry from the data."""
        for key, entry in data.items():
            if isTitleTag(key):
                heading = IndexHeading(self._cache, key)
                heading.unpackData(entry)
                self.addHeading(heading)
            elif key == "document":
                for style, keys in entry.items():
                    if style not in NOTE_TYPES:
                        raise ValueError("The notes style is invalid")
                    if not isListInstance(keys, str):
                        raise ValueError("The notes keys must be a list of strings")
                    self._notes[style] = set(keys)
            else:
                raise KeyError("Index node contains an invalid key")


class IndexHeading:
    """Core: Single Index Heading Class.

    This object represents a section of text in a project item
    associated with a single (valid) heading. It holds a separate record
    of all references made under the heading.
    """

    __slots__ = (
        "_cache",
        "_comments",
        "_counts",
        "_key",
        "_level",
        "_line",
        "_refs",
        "_tag",
        "_title",
    )

    def __init__(
        self,
        cache: IndexCache,
        key: str,
        line: int = 0,
        level: str = "H0",
        title: str = "",
    ) -> None:
        self._cache = cache
        self._key = key
        self._line = line
        self._level = level
        self._title = title
        self._counts: tuple[int, int, int] = (0, 0, 0)
        self._tag = ""
        self._refs: dict[str, set[str]] = {}
        self._comments: dict[str, str] = {}

    def __repr__(self) -> str:
        return f"<IndexHeading key='{self._key}'>"

    ##
    #  Properties
    ##

    @property
    def key(self) -> str:
        return self._key

    @property
    def line(self) -> int:
        return self._line

    @property
    def level(self) -> str:
        return self._level

    @property
    def title(self) -> str:
        return self._title

    @property
    def mainCount(self) -> int:
        return self._counts[0 if CONFIG.useCharCount else 1]

    @property
    def charCount(self) -> int:
        return self._counts[0]

    @property
    def wordCount(self) -> int:
        return self._counts[1]

    @property
    def paraCount(self) -> int:
        return self._counts[2]

    @property
    def synopsis(self) -> str:
        return self._comments.get("summary", "")

    @property
    def comments(self) -> dict[str, str]:
        return self._comments

    @property
    def tag(self) -> str:
        return self._tag

    @property
    def references(self) -> dict[str, set[str]]:
        return self._refs

    ##
    #  Setters
    ##

    def setLevel(self, level: str) -> None:
        """Set the level of the heading if it's a valid value."""
        if level in nwStyles.H_VALID:
            self._level = level

    def setLine(self, line: int) -> None:
        """Set the line number of a heading."""
        self._line = max(0, checkInt(line, 0))

    def setCounts(self, counts: Sequence[int]) -> None:
        """Set the character, word and paragraph count. Make sure the
        value is an integer and is not smaller than 0.
        """
        if len(counts) == 3:
            self._counts = (
                max(0, checkInt(counts[0], 0)),
                max(0, checkInt(counts[1], 0)),
                max(0, checkInt(counts[2], 0)),
            )

    def setComment(self, comment: str, key: str, text: str) -> None:
        """Set the text for a comment and make sure it is a string."""
        match comment.lower():
            case "short" | "synopsis" | "summary":
                self._appendCommentText("summary", text)
            case "story" if key:
                self._cache.story.add(key)
                self._appendCommentText(f"story.{key}", text)
            case "note" if key:
                self._cache.note.add(key)
                self._appendCommentText(f"note.{key}", text)

    def setTag(self, tag: str) -> None:
        """Set the tag for references, and make sure it is a string."""
        self._tag = str(tag).lower()

    def addReference(self, tag: str, keyword: str) -> None:
        """Add a record of a reference tag, and what keyword types it is
        associated with.
        """
        if keyword in nwKeyWords.VALID_KEYS:
            tag = tag.lower()
            if tag not in self._refs:
                self._refs[tag] = set()
            self._refs[tag].add(keyword)

    ##
    #  Getters
    ##

    def getReferences(self) -> dict[str, list[str]]:
        """Extract all tags and references for this heading."""
        refs = {x: [] for x in nwKeyWords.VALID_KEYS}
        for tag, types in self._refs.items():
            for keyword in types:
                if keyword in refs and (name := self._cache.tags.tagName(tag)):
                    refs[keyword].append(name)
            if name := self._cache.tags.tagName(self._tag):
                refs[nwKeyWords.TAG_KEY] = [name]
        return refs

    def getReferencesByKeyword(self, keyword: str) -> list[str]:
        """Extract all references for this heading."""
        refs = []
        if keyword == nwKeyWords.TAG_KEY:
            if name := self._cache.tags.tagName(self._tag):
                refs.append(name)
        else:
            for tag, types in self._refs.items():
                if keyword in types and (name := self._cache.tags.tagName(tag)):
                    refs.append(name)
        return refs

    ##
    #  Data Methods
    ##

    def packData(self) -> dict:
        """Pack the values into a dictionary for saving to cache."""
        data = {}
        data["meta"] = {
            "level": self._level,
            "title": self._title,
            "line": self._line,
            "tag": self._tag,
            "counts": self._counts,
        }
        if self._refs:
            data["refs"] = {k: ",".join(sorted(list(v))) for k, v in self._refs.items()}
        if self._comments:
            data.update(self._comments)
        return data

    def unpackData(self, data: dict) -> None:
        """Unpack a heading entry from a dictionary."""
        self._comments = {}  # These are accumulative and should be reset here
        for key, entry in data.items():
            if key == "meta":
                self.setLevel(entry.get("level", "H0"))
                self._title = str(entry.get("title", ""))
                self._tag = str(entry.get("tag", ""))
                self.setLine(entry.get("line", 0))
                self.setCounts(entry.get("counts", [0, 0, 0]))
            elif key == "refs":
                for tag, value in entry.items():
                    if not isinstance(tag, str):
                        raise ValueError("Heading reference key must be a string")
                    if not isinstance(value, str):
                        raise ValueError("Heading reference value must be a string")
                    for keyword in value.split(","):
                        if keyword in nwKeyWords.VALID_KEYS:
                            self.addReference(tag, keyword)
                        else:
                            raise ValueError("Heading reference contains an invalid keyword")
            elif key == "summary" or key.startswith(("story", "note")):
                comment, _, kind = str(key).partition(".")
                self.setComment(comment, compact(kind), str(entry))
            else:
                raise KeyError("Unknown key in heading entry")

    ##
    #  Internal Functions
    ##

    def _appendCommentText(self, key: str, text: str) -> None:
        """Append text to a comment."""
        if current := self._comments.get(key):
            text = f"{current:s}\n\n{text:s}"
        self._comments[key] = str(text)
