#!/usr/bin/env python
# cspell:ignore Bambu Fiberon
"""Container for slicer presets.

See slicer_tools.py for more comprehensive documentation.
"""

import json

from copy import deepcopy
from pathlib import Path
from sys import platform
from typing import cast, NamedTuple, Generator

from slicer_tools.common import AllNodeSettings, NodeMetadata, PresetGroup, PresetType
from slicer_tools.common import SettingsDict
from slicer_tools.common import FROM, INHERITS, NAME, DEFAULT_ENCODING


class PresetPath(NamedTuple):
    """Provide a unique path/key for presets in ProjectPresets.

    This tuple eliminates the requirement sparse nested dictionaries or minimal classes
    emulating nested dicts (at the cost of using tuple hashes for dict keys).
    """

    # Note that this path should be guaranteed to be unique within a project (.3mf)
    # file, but is NOT guaranteed unique across files. See DiffValuePath to deal with
    # this problem.
    type: PresetType
    preset_name: str


class PresetNode:
    """Container for preset or override definition.

    A preset DataNode contains all of the information required for preset/override
    definition.

    The primary data managed by this class is "the collection of key/value pairs for any
    settings" that differ from "key/value pairs the preset inherits from its immediate
    parent". That is, a Preset effectively represents the difference in settings between
    the source preset and the parent of the source preset.

    A Preset does not address any differences further up the inheritance tree - these
    are generated by walking the inheritance tree (addressed in PresetNodes container
    class.

    A PresetNode contains settings for single preset type, and may be sourced from a
    json preset file, a preset config file in a 3mf archive, or a preset override from a
    project config file in a 3mf archive.

    Intended as a read-only source for settings key/value pairs and other identity
    parameters.
    - filename is the name of the source file, which may be a normal file or a file
    in a 3mf archive.
    - name is the name of the preset. Note that ProjectPresets (a container class for
    Presets) requires that each name in a 3mf project is unique. See ThreeMFPresets for
    a sample implementation to manage this.
    - path is the path to the source preset file if the source is a normal file, None
    if the source is a 3mf archive member.
    """

    metadata: NodeMetadata
    path: Path | None = None
    # Settings is a private variable to support lazy loading of system and user
    # preset settings.
    _settings: SettingsDict | None

    # This is a REAL hack. BambuStudio more or less treats configs in
    # base directories in user folders as more-or-less system configs.
    # We use this flag to modify these on the fly to more-or-less follow
    # the way BS behaves.
    _user_base_fix: bool = False

    def __init__(
        self,
        metadata: NodeMetadata,
        settings: SettingsDict | None = None,
        path: Path | None = None,
        user_base_fix: bool = False,
    ) -> None:
        """Create preset instance."""
        # PresetNode is designed around lazy loading system and user settings.
        # If the default value/None is provided for settings, lazy loading will
        # be assumed.
        # self._settings is initialised here, and lazy loading occurs in the data
        # getter.
        self._settings = settings
        self.path = path
        if user_base_fix:
            self._user_base_fix = user_base_fix
            # Faux system group in the base folder. Second hack below.
            self.metadata = metadata._replace(group=PresetGroup.SYSTEM)
        else:
            self.metadata = metadata

        if self._settings is None and self.path is None:
            # Shouldn't happen, but in case.
            raise ValueError(
                "Settings and path arguments cannot both be None for a PresetNode()"
            )

    @property
    def settings(self) -> SettingsDict:
        """Return the node settings dictionary."""
        # see __init__ for an explanation of _settings and the settings getter.
        if self._settings is None:
            if self.path is None:
                # I think this should be caught during initialisation. But belt and
                # braces.
                raise KeyError(
                    "Preset node contains no settings data and no path to preset file."
                )

            # Lazy load needed for settings.
            with open(self.path, encoding=DEFAULT_ENCODING) as fp:
                self._settings = json.load(fp)
                if self._user_base_fix:
                    # This is our second hack to make user/**/base/*.json look
                    # like system presets.
                    self._settings[FROM] = PresetGroup.SYSTEM

        # Relying on the caller to not modify settings.
        return self._settings


class ProjectPresets:
    """Container for a group of related Preset nodes built around a BS .3mf model.

    ProjectPresets provides access to a related group of presets and overrides, both at
    the level of nodes in the inheritance tree, and as roll ups of the inheritance
    tree for a node. As the main preset grouping I'm working with is a BambuStudio
    .3mf project file, so this class is based around a .3mf structure.

    The container manages two groups of Preset nodes:
    - Shared preset nodes available to all instances of ProjectSettings. These are:
        - BambuStudio system presets.
        - BambuStudio user presets for one user, which defaults to the last user to
        login to BambuStudio.
        - Note the shared presets will be a superset of the system/user presets used by
        any given project file, but it's convenient to grab them all at once.
    - A set of per instance project preset nodes, corresponding to:
        - Project (3mf file unit) settings for filament, machine and process presets.
        - Project overrides of any of the above presets.
    The shared settings load on the first instantiation of ProjectSettings, and can only
    be overridden on this first call.
    """

    # Both preset node dictionaries are keyed by PresetPath named tuples.
    # Should be enough for relatively efficient returns, but DOES require creation of
    # unique names for project overrides of presets.
    _project_nodes: dict[PresetPath, PresetNode]
    # Class variable, share among all instances.
    _shared_nodes: dict[PresetPath, PresetNode] = {}

    def __init__(self, appdata_path: Path | None = None, bbl_user_id: str = "") -> None:
        r"""Create node containers and load system and user preset nodes.

        appdata_path and bbl_user_id will only be used in the first instance call, and
        ignored thereafter.

        appdata_path points to the location for the Bambu Studio system snd user
        preset nodes, and is the source for _shared_settings.
        If not specified, this defaults to
            Windows: c:\users\<username>\appdata\Roaming\BambuStudio
            macos: /Users/user.name/Library/Application Support/BambuStudio
        I don't have a mac, so haven't tested the macos version.

        If specified, the folder structure must follow the form:
            appdata_path
                BambuStudio.conf (only required if bbl_user_id is not specified)
                system
                    BBL.json (must follow the same format as the default version)
                    BBL
                        filament
                        machine
                        process
                user
                    <bbl_user_id>
                        filament
                            base
                        machine
                            base
                        process
                            base
        bbl_user_id is the user id of the folder containing user presets. If it is not
        specified, a default value will be extracted from BambuStudio.conf (this will
        typically be the id of the last user to login to BS). Initialisation will fail
        dramatically if user_id is not specified and BambuStudio.conf does not exist.
        """
        if len(ProjectPresets._shared_nodes) == 0:
            # First instance of this class, shared settings don't exist.
            self._load_shared_nodes(appdata_path, bbl_user_id)

        # Create instance project dict.
        self._project_nodes = {}

    @classmethod
    def _load_shared_nodes(
        cls, appdata_path: Path | None = None, bbl_user_id: str = ""
    ) -> None:
        """Load shared nodes dictionary."""
        # Given this is the only place these paths should appear, happy enough hard
        # coding here.
        studio_path = appdata_path
        if studio_path is None:
            if platform == "win32":
                studio_path = Path.home() / "Appdata/Roaming"
            elif platform == "darwin":
                studio_path = Path.home() / "Library/Application Support"
            else:
                raise RuntimeError(
                    f"BambuScan doesn't know how to find BambuStudio"
                    f" folder on {platform}"
                )
            studio_path = studio_path / "BambuStudio"

        user_id = bbl_user_id
        if not user_id:
            try:
                with open(
                    studio_path / "BambuStudio.conf", encoding=DEFAULT_ENCODING
                ) as fp:
                    # strip out checksum comment. Hells bells.
                    raw_json = "".join(line for line in fp if not line.startswith("#"))
            except FileNotFoundError:
                print("Missing BambuStudio.conf file.")
                raise

            json_data = json.loads(raw_json)
            try:
                user_id = json_data["app"]["preset_folder"]
            except KeyError:
                print("Missing 'preset folder' key/value in BambuStudio.conf")
                raise

        # Populate system presets.
        cls._system_nodes_from_bbl_json(studio_path / "system")

        # Walk paths. Should just verify system, loads user.
        # Duplicates will get a printed warning, but no error.
        cls._nodes_from_path_walk(
            root=studio_path / "system/BBL", group=PresetGroup.SYSTEM
        )
        cls._nodes_from_path_walk(
            root=studio_path / "user" / user_id, group=PresetGroup.USER
        )

        # And just in case we didn't find anything, add a dummy entry so
        # we don't go through this on each load.
        # Likely the caller will find out the hard way.
        if len(cls._shared_nodes) == 0:
            cls._shared_nodes[PresetPath(PresetType.FILAMENT, "dummy")] = PresetNode(
                NodeMetadata(
                    name="Dummy",
                    filename="Dummy.json",
                    group=PresetGroup.SYSTEM,
                    preset_type=PresetType.PROCESS,
                ),
                settings={"name": "No standard settings found"},
            )

    @classmethod
    def _system_nodes_from_bbl_json(cls, system_path: Path) -> None:
        """Load system preset file locations from BBL.json.

        BBL.json is assumed definitive. I know there are duplicates. E.g. Fiberon.
        We will warn about these and ignore the duplicates.
        """
        # Some hard coding of strings that should only occur here.
        with open(system_path / "BBL.json", encoding=DEFAULT_ENCODING) as fp:
            data = json.load(fp)

        for preset_type in PresetType:
            # ignoring machine models data.
            for item in data[preset_type + "_list"]:
                name = item[NAME]
                path = system_path / "BBL" / item["sub_path"]

                preset_key = PresetPath(preset_type, name)
                if preset_key in cls._shared_nodes:
                    raise ValueError(
                        # Warn, but don't do anything about it.
                        f"Highly unexpected duplicate definition in BBL.json:"
                        f"   '{preset_key}'."
                    )

                # Prep for lazy load.
                cls._shared_nodes[preset_key] = PresetNode(
                    NodeMetadata(
                        name=name,
                        filename=path.name,
                        group=PresetGroup.SYSTEM,
                        preset_type=preset_type,
                    ),
                    path=path,
                )

    @classmethod
    def _nodes_from_path_walk(cls, root: Path, group: PresetGroup) -> None:
        """Load preset nodes from path walk. Really for user paths."""
        for preset_type in PresetType:
            base_path = root / preset_type
            for json_path in base_path.glob("**/*.json"):
                preset_key = PresetPath(
                    type=preset_type,
                    preset_name=json_path.stem,
                )
                if preset_key in cls._shared_nodes:
                    if json_path != cls._shared_nodes[preset_key].path:
                        # Warn but don't do anything about it.
                        print(f"Warning duplicate definition for '{preset_key}`.")

                else:
                    # Tell PresetNode to deal with user bases that are actually
                    # system types. (Handled by Preset Node as multiple fixes required.)
                    base_fix = False
                    if group == PresetGroup.USER and json_path.parent.name == "base":
                        base_fix = True

                    cls._shared_nodes[preset_key] = PresetNode(
                        NodeMetadata(
                            name=json_path.stem,
                            filename=json_path.name,
                            group=group,
                            preset_type=preset_type,
                        ),
                        path=json_path,
                        user_base_fix=base_fix,
                    )

    def add_project_node(
        self,
        metadata: NodeMetadata,
        settings: SettingsDict,
    ) -> None:
        """Add project preset node based on the data dict."""
        key = PresetPath(
            type=metadata.preset_type,
            preset_name=metadata.name,
        )

        # Quick and dirty checks on uniqueness.
        if key in self._shared_nodes:
            raise KeyError(
                f"Attempt to add project preset {key} with the same name as a system"
                f" preset."
            )
        if key in self._project_nodes:
            raise KeyError(
                f"Attempt to add project preset {key} when preset already exists."
            )
        # Arguably should check name is unique, but leaving that up to BS for now.
        # preset_type = settings[]
        self._project_nodes[key] = PresetNode(
            metadata=metadata,
            settings=settings,
        )

    def _node(self, preset_type: PresetType, name: str) -> PresetNode:
        """Return the specified preset node."""
        key = PresetPath(type=preset_type, preset_name=name)
        if key in self._shared_nodes:
            return self._shared_nodes[key]

        if key in self._project_nodes:
            return self._project_nodes[key]

        raise KeyError(f"Undefined preset '{key}'.")

    def node_settings(self, preset_type: PresetType, name: str) -> SettingsDict:
        """Return a **deep copy** of the node settings named preset node.

        The settings returned by this call are the differences between the settings
        for the named node and the settings of the parent node (i.e. the preset data
        that would appear in the json file for the preset).
        """
        # Keep the source data read only.
        return deepcopy(self._node(preset_type, name=name).settings)

    def all_node_settings(
        self,
        preset_type: PresetType,
        node_name: str,
        ref_node: str = "",
        ref_group: PresetGroup | None = None,
    ) -> AllNodeSettings:
        """Return all settings for node_name of preset_type.

        Settings may be provided as either
        - a single SettingsDict and associated NodeMetadata for node_name (default), or
        - as partitioned settings consisting of a SettingsDict generated by walking the
        inheritance from node_name up to but not including the reference node, and a
        reference settings dictionary from walking the inheritance tree from the
        reference node to the root of the tree. (i.e. reference settings and
        node_name settings that are different to the reference settings).

        The reference node is determined from either the reference node name ref_node
        or the youngest inherited ancestor of node_name that has a PresetGroup of
        ref_group. At least one of ref_value and ref_group must be empty ("").

        If called with default settings, the return structure is:
        - ret_val.metadata contains metadata for node_name.
        - ret_val.settings contains all settings for node_name.
        - ret_val.ref_metadata = None.
        - ret_val.reference = {}

        If called with with a reference group/node specified, the return structure is:
        - ret_val.metadata contains metadata for node_name.
        - ret_val.settings contains all settings different to the reference settings.
        - ret_val.ref_metadata contains the metadata for the reference node.
        - ret_val.reference contains all settings for the reference node.
        """
        if ref_group and ref_node:
            raise ValueError(
                f"Cannot specify both reference group '{ref_group}'"
                f" and reference node '{ref_node}'"
            )

        if ref_group:
            # A bit messy - the preset may not inherit from the ref_group, but if so,
            # should inherit from its parents. So we build a check set for all possible
            # reference groups.
            include = False
            ref_set = []
            for group in PresetGroup:
                if ref_group == group:
                    include = True
                if include:
                    ref_set.append(group)

        # Unfortunately because of the way python dictionary unions work (keep
        # rightmost value), we need to walk the tree first and then build the
        # return dictionaries with union assignment |= from the root back to the
        # calling node. I've opted to keep this in a a single method, as I think the
        # logic is cleaner than via recursion.
        # First up create node lists for the settings/different to reference settings
        # and the reference settings.
        settings_nodes: list[SettingsDict] = []
        reference_nodes: list[SettingsDict] = []
        # Collect settings until we find the first reference node and then
        # switch.
        target = settings_nodes

        # Current node for processing.
        this_node: PresetNode | None = self._node(preset_type, node_name)
        # metadata for this node.
        metadata: NodeMetadata = cast(PresetNode, this_node).metadata
        ref_metadata: NodeMetadata | None = None

        while this_node:
            # This next bit looks stupid, but I think it's correct.
            # The first thing we do is check if we need to switch to gathering reference
            # nodes. This may have the odd side effect that the original node may
            # be assigned directly to the reference settings if it is in the reference
            # group.
            if not ref_metadata and (
                (ref_group and this_node.metadata.group in ref_set)
                or (this_node.metadata.name == ref_node)
            ):
                # This is either the youngest ancestor in reference group, or this is
                # the reference node, so we switch to gathering reference data.
                ref_metadata = this_node.metadata
                target = reference_nodes

            # Warning - we are grabbing mutable node SettingsDicts here.
            # Caller should not alter these.
            target.append(this_node.settings)

            # Move on to parent node.
            # By rights inherits should ALWAYS be a str, but sometimes doesn't exist at
            # all.
            if INHERITS not in this_node.settings:
                inherits = ""
            else:
                inherits = cast(str, this_node.settings[INHERITS])

            if inherits:
                this_node = self._node(preset_type, inherits)
            else:
                this_node = None

        # Prepare empty settings dicts and
        # unroll the lists to populate the dicts, and finally sort them.
        working: SettingsDict = {}
        for node in reversed(settings_nodes):
            working |= node
        settings = dict(sorted(working.items()))
        working = {}
        for node in reversed(reference_nodes):
            working |= node
        reference = dict(sorted(working.items()))

        # I don't think this is possible, but no harm in a quick check.
        if len(reference) != 0 and not ref_metadata:
            raise ValueError(
                "Reference values defined, but reference metadata is not."
                "\nThis should not be possible."
            )

        return AllNodeSettings(
            metadata=metadata,
            ref_metadata=ref_metadata,
            source_subtree=settings,
            reference_subtree=reference,
        )

    def project_presets(
        self, reference_group: PresetGroup | None = None, reference_node: str = ""
    ) -> Generator[AllNodeSettings]:
        """Yield settings and metadata for project presets relative to reference group.

        The generator repeatedly calls all_node_settings to generate and return
        the setting for each project preset node in the ProjectPresets instance.

        If reference group or reference_node is specified, these settings are all
        relative to the reference node/group (see all_node_settings for more detail),
        which is also returned in AllNodeSettings.
        """
        for node in self._project_nodes.values():
            # Dom't need keys as we iterate over all elements in _project_nodes,
            # and key data is repeated in node.metadata.
            yield self.all_node_settings(
                node.metadata.preset_type,
                node_name=node.metadata.name,
                ref_group=reference_group,
                ref_node=reference_node,
            )

    def preset_names(self, preset_type: PresetType) -> tuple[str]:
        """Return the list of names of the project presets of the specified type."""
        name_list: tuple[str] = cast(tuple[str], tuple(
            # pylint: disable-next=consider-iterating-dictionary
            x.preset_name for x in self._project_nodes.keys() if x.type == preset_type
        ))

        return name_list
