"""Control the settings of the project.

Allows reading from a settings file and writing to it.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any, ClassVar, cast

import rtoml
from typing_extensions import Self

from simple_toml_settings.exceptions import (
    SettingsMutuallyExclusiveError,
    SettingsNotFoundError,
    SettingsSchemaError,
)
from simple_toml_settings.xdg_config import xdg_config_home


@dataclass
class TOMLSettings:
    """The main settings class.

    The only required argument is the app_name, which is used to create the
    settings folder. The settings_folder and settings_file_name are optional and
    will default to the app_name preceded by a '.' and config.toml
    respectively.
    """

    app_name: str
    settings_file_name: str = "config.toml"
    settings_path: str | Path | None = None
    auto_create: bool = True
    local_file: bool = False
    flat_config: bool = False
    xdg_config: bool = False
    allow_missing_file: bool = False
    strict_get: bool = False

    # the schema_version is used to track changes to the settings file.
    schema_version: str = "none"

    _instances: ClassVar[
        dict[tuple[type[TOMLSettings], str], TOMLSettings]
    ] = {}

    _ignored_attrs: ClassVar[frozenset[str]] = frozenset(
        {
            "_instances",
            "app_name",
            "settings_folder",
            "settings_file_name",
            "settings_path",
            "auto_create",
            "local_file",
            "flat_config",
            "xdg_config",
            "allow_missing_file",
            "strict_get",
        }
    )

    _mutually_exclusive: ClassVar[frozenset[str]] = frozenset(
        {
            "settings_path",
            "local_file",
            "flat_config",
            "xdg_config",
        }
    )

    def _validate_app_name(self) -> None:
        r"""Validate app_name doesn't contain path traversal characters.

        Note: We check for '..' even though it's harmless without '/' or '\\'
        because it's a well-known path traversal pattern with no legitimate
        use case in an app name. This strict approach provides clearer
        validation and defense in depth.

        Raises:
            ValueError: If app_name contains dangerous characters.
        """
        dangerous_chars = {"..", "/", "\\"}
        for char in dangerous_chars:
            if char in self.app_name:
                msg = (
                    f"app_name cannot contain '{char}' for security reasons. "
                    "This prevents path traversal attacks."
                )
                raise ValueError(msg)

    def __post_init__(self) -> None:
        """Create the settings folder if it doesn't exist."""
        self._validate_app_name()

        # ensure only one of the mutually exclusive options is set
        check_exclusive = self._selected_location_options()
        if len(check_exclusive) > 1:
            raise SettingsMutuallyExclusiveError(attrs=check_exclusive)

        self.settings_folder = self.get_settings_folder()

        # if we allow a missing file, we don't want to auto-create it
        if self.allow_missing_file:
            self.auto_create = False

        # load (or create) the settings file
        self.load()

    def _selected_location_options(self) -> set[str]:
        """Return the set of enabled location-selection options."""
        selected: set[str] = set()
        for attr in self._mutually_exclusive:
            value = getattr(self, attr)
            if attr == "settings_path":
                if value is not None:
                    selected.add(attr)
                continue
            if value:
                selected.add(attr)
        return selected

    def get_settings_folder(self) -> Path:
        """Return the settings folder. If it doesn't exist, create it.

        Take into account any custom location options that were provided.
        """
        if self.settings_path is not None:
            settings_folder = Path(self.settings_path).expanduser().resolve()
            settings_folder.mkdir(parents=True, exist_ok=True)
            return settings_folder

        if self.local_file:
            return Path.cwd()

        if self.flat_config:
            return Path.home()

        settings_folder = Path.home() / f".{self.app_name}"

        if self.xdg_config:
            settings_folder = xdg_config_home() / f"{self.app_name}"
        settings_folder.mkdir(parents=True, exist_ok=True)

        return settings_folder

    def __post_create_hook__(self) -> None:
        """Allow further customization after a new settings file is created.

        It is provided so that you can add any additional settings that you
        might need, or get information from the user. The subclass should
        override this method, by default it does nothing.

        The save() method IS called after we run this automatically, it should
        never be called manually.
        """

    @classmethod
    def get_instance(
        cls,
        app_name: str,
        *args: Any,  # noqa: ANN401
        **kwargs: Any,  # noqa: ANN401
    ) -> Self:
        """Class method to get or create the Settings instance.

        This is optional (and experimental), and is provided to allow for a
        singleton pattern in derived classes. It is not required to use this
        class.
        """
        key = (cls, app_name)
        if key not in cls._instances:
            cls._instances[key] = cls(app_name, *args, **kwargs)
        return cast("Self", cls._instances[key])

    def get_attrs(self, *, include_none: bool = False) -> dict[str, Any]:
        """Return a dictionary of our setting values.

        Values that are None are EXCLUDED by default, but can be included by
        setting 'include_none' to True.
        """
        return {
            a: value
            for a in dir(self)
            if not a.startswith("_")
            and a not in self._ignored_attrs
            and not callable(value := getattr(self, a))
            and (include_none or value is not None)
        }

    def save(self) -> None:
        """Save the settings to the settings file."""
        rtoml.dump(
            {self.app_name: self.get_attrs()},
            self.settings_folder / self.settings_file_name,
        )

    def load(self) -> None:
        """Load the settings from the settings file."""
        try:
            settings = rtoml.load(
                self.settings_folder / self.settings_file_name
            )
        except FileNotFoundError as exc:
            if self.auto_create:
                self.__post_create_hook__()
                self.save()
            elif self.allow_missing_file:
                return
            else:
                message = "Can't find a Config File, please create one."
                raise SettingsNotFoundError(message) from exc
            return

        # Check if the app_name section exists in the config file
        if self.app_name not in settings:
            msg = f"Config file missing required [{self.app_name}] section"
            raise SettingsNotFoundError(msg)

        # Check if 'schema_version' is present and matches the required one
        file_schema_version = str(
            settings[self.app_name].get("schema_version", None)
        )
        if file_schema_version.lower() not in {
            self.schema_version.lower(),
            "none",
        }:
            raise SettingsSchemaError(
                expected=self.schema_version, found=file_schema_version
            )
        for key, value in settings[self.app_name].items():
            setattr(self, key, value)

    def get(
        self,
        key: str,
        default: Any = None,  # noqa: ANN401
    ) -> Any:  # noqa: ANN401
        """Get a setting by key.

        Args:
            key: The name of the setting to get.
            default: Value to return if key doesn't exist. Defaults to None.

        Returns:
            The setting value, or default if the key doesn't exist.

        Raises:
            KeyError: If strict_get=True, key missing, and default=None.
        """
        if hasattr(self, key):
            return getattr(self, key)
        if self.strict_get and default is None:
            msg = f"Setting '{key}' not found"
            raise KeyError(msg)
        return default

    def set(
        self,
        key: str,
        value: Any,  # noqa: ANN401
        *,
        autosave: bool = True,
    ) -> None:
        """Set a setting by key and value.

        If autosave is True (the default), the settings will be saved to the
        settings file each time it is called.

        Raises:
            ValueError: If trying to set a protected or ignored attribute.
        """
        if key in self._ignored_attrs or key.startswith("_"):
            msg = f"Cannot set protected attribute: {key}"
            raise ValueError(msg)
        setattr(self, key, value)
        if autosave:
            self.save()

    def delete(
        self,
        key: str,
        *,
        autosave: bool = True,
    ) -> None:
        """Delete a setting by key.

        Args:
            key: The name of the setting to delete.
            autosave: If True (the default), save after deleting.

        Raises:
            ValueError: If trying to delete a protected or ignored attribute.
            KeyError: If the setting doesn't exist.
        """
        if key in self._ignored_attrs or key.startswith("_"):
            msg = f"Cannot delete protected attribute: {key}"
            raise ValueError(msg)
        if not hasattr(self, key):
            msg = f"Setting '{key}' not found"
            raise KeyError(msg)
        delattr(self, key)
        if autosave:
            self.save()

    def list_settings(self) -> dict[str, Any]:
        """Return a dictionary of settings."""
        return self.get_attrs()
