# SPDX-License-Identifier: MIT

from __future__ import annotations

import logging
import os
import warnings
from abc import ABC, abstractmethod
from collections import defaultdict
from pathlib import Path
from typing import (
    TYPE_CHECKING,
    Any,
    DefaultDict,
    Dict,
    Generic,
    Literal,
    Optional,
    Set,
    TypeVar,
    Union,
    overload,
)

from . import utils
from .custom_warnings import LocalizationWarning
from .enums import Locale
from .errors import LocalizationKeyError

if TYPE_CHECKING:
    from typing_extensions import Self

    LocalizedRequired = Union[str, "Localized[str]"]
    LocalizedOptional = Union[Optional[str], "Localized[Optional[str]]"]


__all__ = (
    "Localized",
    "Localised",
    "LocalizationValue",
    "LocalizationProtocol",
    "LocalizationStore",
)

MISSING = utils.MISSING

_log = logging.getLogger(__name__)


LocalizationsDict = Union[Dict[Locale, str], Dict[str, str]]
Localizations = Union[str, LocalizationsDict]

StringT = TypeVar("StringT", str, Optional[str], covariant=True)


# This is generic over `string`, as some localized strings can be optional, e.g. option descriptions.
# The basic idea for parameters is this:
#     abc: LocalizedRequired
#     xyz: LocalizedOptional = None
#
# With that, one may use `abc="somename"` and `abc=Localized("somename", ...)`,
# but not `abc=Localized(None, ...)`. All three work fine for `xyz` though.


class Localized(Generic[StringT]):
    """A container type used for localized parameters.

    Exactly one of ``key`` or ``data`` must be provided.

    There is an alias for this called ``Localised``.

    .. versionadded:: 2.5

    Parameters
    ----------
    string: Optional[:class:`str`]
        The default (non-localized) value of the string.
        Whether this is optional or not depends on the localized parameter type.
    key: :class:`str`
        A localization key used for lookups.
        Incompatible with ``data``.
    data: Union[Dict[:class:`.Locale`, :class:`str`], Dict[:class:`str`, :class:`str`]]
        A mapping of locales to localized values.
        Incompatible with ``key``.
    """

    __slots__ = ("string", "localizations")

    @overload
    def __init__(self: Localized[StringT], string: StringT, *, key: str) -> None:
        ...

    @overload
    def __init__(self: Localized[Optional[str]], *, key: str) -> None:
        ...

    @overload
    def __init__(
        self: Localized[StringT],
        string: StringT,
        *,
        data: Union[Optional[LocalizationsDict], LocalizationValue],
    ) -> None:
        ...

    @overload
    def __init__(
        self: Localized[Optional[str]],
        *,
        data: Union[Optional[LocalizationsDict], LocalizationValue],
    ) -> None:
        ...

    # note: `data` accepting `LocalizationValue` is intentionally undocumented,
    # as it's only meant to be used internally
    def __init__(
        self,
        string: StringT = None,
        *,
        key: str = MISSING,
        data: Union[Optional[LocalizationsDict], LocalizationValue] = MISSING,
    ) -> None:
        self.string: StringT = string

        if not (key is MISSING) ^ (data is MISSING):
            raise TypeError("Exactly one of `key` or `data` must be provided")
        if isinstance(data, LocalizationValue):
            self.localizations = data
        else:
            self.localizations = LocalizationValue(key if key is not MISSING else data)

    @overload
    @classmethod
    def _cast(cls, string: LocalizedOptional, required: Literal[False]) -> Localized[Optional[str]]:
        ...

    @overload
    @classmethod
    def _cast(cls, string: LocalizedRequired, required: Literal[True]) -> Localized[str]:
        ...

    @classmethod
    def _cast(cls, string: Union[Optional[str], Localized[Any]], required: bool) -> Localized[Any]:
        if not isinstance(string, Localized):
            string = cls(string, data=None)

        # enforce the `StringT` type at runtime
        if required and string.string is None:
            raise ValueError("`string` parameter must be provided")
        return string

    @overload
    def _upgrade(self, *, key: Optional[str]) -> Self:
        ...

    @overload
    def _upgrade(self, string: str, *, key: Optional[str] = None) -> Localized[str]:
        ...

    def _upgrade(
        self: Localized[Any], string: Optional[str] = None, *, key: Optional[str] = None
    ) -> Localized[Any]:
        # update key if provided and not already set
        self.localizations._upgrade(key)

        # Only overwrite if not already set (`Localized()` parameter value takes precedence over function names etc.)
        # Note: not checking whether `string` is an empty string, to keep generic typing correct
        if not self.string and string is not None:
            self.string = string

        # this is safe, see above
        return self


Localised = Localized


class LocalizationValue:
    """Container type for (pending) localization data.

    .. versionadded:: 2.5
    """

    __slots__ = ("_key", "_data")

    def __init__(self, localizations: Optional[Localizations]) -> None:
        self._key: Optional[str]
        self._data: Optional[Dict[str, str]]

        if localizations is None:
            # no localization
            self._key = None
            self._data = None
        elif isinstance(localizations, str):
            # got localization key
            self._key = localizations
            self._data = MISSING  # not localized yet
        elif isinstance(localizations, dict):
            # got localization data
            self._key = None
            self._data = {str(k): v for k, v in localizations.items()}
        else:
            raise TypeError(f"Invalid localizations type: {type(localizations).__name__}")

    def _upgrade(self, key: Optional[str]) -> None:
        if not key:
            return

        # if empty, use new key
        if self._key is None and self._data is None:
            self._key = key
            self._data = MISSING
            return

        # if key is the same, ignore
        if self._key == key:
            return

        # at this point, the keys don't match, which either means that they're different strings,
        # or that there is no existing `_key` but `_data` is set
        raise ValueError("Can't specify multiple localization keys or dicts")

    def _link(self, store: LocalizationProtocol) -> None:
        """Loads localizations from the specified store if this object has a key."""
        if self._key is not None:
            self._data = store.get(self._key)

    def _copy(self) -> LocalizationValue:
        cls = self.__class__
        ins = cls.__new__(cls)
        ins._key = self._key
        ins._data = self._data
        return ins

    @property
    def data(self) -> Optional[Dict[str, str]]:
        """Optional[Dict[:class:`str`, :class:`str`]]: A dict with a locale -> localization mapping, if available."""
        if self._data is MISSING:
            # This will happen when `_link(store)` hasn't been called yet, which *shouldn't* occur under normal circumstances.
            warnings.warn(
                f"Localization value ('{self._key}') was never linked to bot; this may be a library bug.",
                LocalizationWarning,
                stacklevel=2,
            )
            return None
        return self._data

    def __eq__(self, other) -> bool:
        # if both are pending, compare keys instead
        if self._data is MISSING and other._data is MISSING:
            return self._key == other._key

        d1 = self.data
        d2 = other.data
        # consider values equal if they're both falsy, or actually equal
        # (it doesn't matter if localizations are `None` or `{}`)
        return (not d1 and not d2) or d1 == d2


class LocalizationProtocol(ABC):
    """Manages a key-value mapping of localizations.

    This is an abstract class, a concrete implementation is provided as :class:`LocalizationStore`.

    .. versionadded:: 2.5
    """

    @abstractmethod
    def get(self, key: str) -> Optional[Dict[str, str]]:
        """Returns localizations for the specified key.

        Parameters
        ----------
        key: :class:`str`
            The lookup key.

        Raises
        ------
        LocalizationKeyError
            May be raised if no localizations for the provided key were found,
            depending on the implementation.

        Returns
        -------
        Optional[Dict[:class:`str`, :class:`str`]]
            The localizations for the provided key.
            May return ``None`` if no localizations could be found.
        """
        raise NotImplementedError

    # subtypes don't have to implement this
    def load(self, path: Union[str, os.PathLike]) -> None:
        """Adds localizations from the provided path.

        Parameters
        ----------
        path: Union[:class:`str`, :class:`os.PathLike`]
            The path to the file/directory to load.

        Raises
        ------
        RuntimeError
            The provided path is invalid or couldn't be loaded
        """
        raise NotImplementedError

    # subtypes don't have to implement this
    def reload(self) -> None:
        """Clears localizations and reloads all previously loaded sources again.
        If an exception occurs, the previous data gets restored and the exception is re-raised.
        """
        pass


class LocalizationStore(LocalizationProtocol):
    """Manages a key-value mapping of localizations using ``.json`` files.

    .. versionadded:: 2.5

    Attributes
    ----------
    strict: :class:`bool`
        Specifies whether :meth:`.get` raises an exception if localizations for a provided key couldn't be found.
    """

    def __init__(self, *, strict: bool) -> None:
        self.strict = strict

        self._loc: DefaultDict[str, Dict[str, str]] = defaultdict(dict)
        self._paths: Set[Path] = set()

    def get(self, key: str) -> Optional[Dict[str, str]]:
        """Returns localizations for the specified key.

        Parameters
        ----------
        key: :class:`str`
            The lookup key.

        Raises
        ------
        LocalizationKeyError
            No localizations for the provided key were found.
            Raised only if :attr:`strict` is enabled, returns ``None`` otherwise.

        Returns
        -------
        Optional[Dict[:class:`str`, :class:`str`]]
            The localizations for the provided key.
            Returns ``None`` if no localizations could be found and :attr:`strict` is disabled.
        """
        data = self._loc.get(key)
        if data is None and self.strict:
            raise LocalizationKeyError(key)
        return data

    def load(self, path: Union[str, os.PathLike]) -> None:
        """Adds localizations from the provided path to the store.
        If the path points to a file, the file gets loaded.
        If it's a directory, all ``.json`` files in that directory get loaded (non-recursive).

        Parameters
        ----------
        path: Union[:class:`str`, :class:`os.PathLike`]
            The path to the file/directory to load.

        Raises
        ------
        RuntimeError
            The provided path is invalid or couldn't be loaded
        """
        path = Path(path)

        if path.is_file():
            self._load_file(path)
        elif path.is_dir():
            for file in path.glob("*.json"):
                if not file.is_file():
                    continue
                self._load_file(file)
        else:
            raise RuntimeError(f"Path '{path}' does not exist or is not a directory/file")

        self._paths.add(path)

    def reload(self) -> None:
        """Clears localizations and reloads all previously loaded files/directories again.
        If an exception occurs, the previous data gets restored and the exception is re-raised.
        See :func:`~LocalizationStore.load` for possible raised exceptions.
        """
        old = self._loc
        try:
            self._loc = defaultdict(dict)
            for path in self._paths:
                self.load(path)
        except Exception:
            # restore in case of error
            self._loc = old
            raise

    def _load_file(self, path: Path) -> None:
        try:
            if path.suffix != ".json":
                raise ValueError("not a .json file")
            locale = path.stem

            if not (api_locale := utils.as_valid_locale(locale)):
                raise ValueError(f"invalid locale '{locale}'")
            locale = api_locale

            data = utils._from_json(path.read_text("utf-8"))
            self._load_dict(data, locale)
            _log.debug(f"Loaded localizations from '{path}'")
        except Exception as e:
            raise RuntimeError(f"Unable to load '{path}': {e}") from e

    def _load_dict(self, data: Dict[str, Optional[str]], locale: str) -> None:
        if not isinstance(data, dict) or not all(
            o is None or isinstance(o, str) for o in data.values()
        ):
            raise TypeError("data must be a flat dict with string/null values")
        for key, value in data.items():
            d = self._loc[key]  # always create dict, regardless of value
            if value is not None:
                d[locale] = value
