#!/usr/bin/python3
# SPDX-FileCopyrightText: 2006-2026 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only

"""Internationalization (i18n) utilities."""

import gettext
import re
import weakref
from locale import LC_MESSAGES, Error, getlocale


class I18N_Error(Exception):
    """Error in Internationalization."""


class Locale:
    """
    Represents a locale specification and provides simple access to
    language, territory, codeset and modifier.

    :param locale: The locale string `language[_territory][.codeset][@modifier]`.
    :type locale: str or None

    >>> Locale("deu_GER")
    >>> str(Locale("ca_ES@valencia"))
    >>> str(Locale(""))
    """

    REGEX = re.compile(
        r'^'
        r'(?P<language>([a-z]{2}|C|POSIX))'
        r'(?:_(?P<territory>[A-Z]{2}))?'
        r'(?:\.(?P<codeset>[a-zA-Z-0-9]+))?'
        r'(?:@(?P<modifier>.+))?'
        r'$')

    def __init__(self, locale: str | None = None) -> None:
        self.__reset()
        if locale is not None:
            self.parse(locale)

    def __reset(self) -> None:
        self.language = ""
        self.territory = ""
        self.codeset = ""
        self.modifier = ""

    def parse(self, locale: str) -> None:
        """
        Parse locale string.

        :param str locale: The locale string `language[_territory][.codeset][@modifier]`.
        :raises TypeError: if `locale` is not a string.
        :raises I18N_Error: if `locale` does not match the format.
        """
        if not isinstance(locale, str):
            raise TypeError('locale must be of type string')
        self.__reset()
        regex = Locale.REGEX.match(locale)
        if not regex:
            raise I18N_Error('attribute does not match locale specification language[_territory][.codeset][@modifier]')

        self.codeset = 'UTF-8'  # default encoding
        for key, value in regex.groupdict().items():
            if value is None:
                continue
            setattr(self, key, value)

    def __bool__(self) -> bool:
        return bool(self.language)
    __nonzero__ = __bool__

    def __str__(self) -> str:
        text = self.language or ''
        if self.language not in ('C', 'POSIX') and self.territory:
            text += '_%s' % self.territory
        if self.codeset:
            text += '.%s' % self.codeset
        if self.modifier:
            text += '@%s' % self.modifier
        return text


class NullTranslation:
    """
    Dummy translation.

    :param str namespace: The name of the translation domain.
    :param str locale_spec: The selected locale.
    :param str localedir: The name of the directory containing the translation files.
    """

    def __init__(self, namespace: str, locale_spec: str | None = None, localedir: str | None = None) -> None:
        self._set_domain(namespace)
        self._translation: gettext.NullTranslations | None = None
        self._localedir: str | None = localedir
        self._localespec: Locale | None = None
        self._locale: str | None = locale_spec
        if not self._locale:
            self.set_language()

    def _set_domain(self, namespace: str) -> None:
        """
        Select translation domain.

        :param str namespace: The name of the translation domain.
        """
        if namespace is not None:
            self._domain = namespace.replace('/', '-').replace('.', '-')
        else:
            self._domain = None

    domain = property(fset=_set_domain)

    def set_language(self, language: str = "") -> None:
        """
        Select language.

        :param str language: The language code.
        """

    def _get_locale(self) -> Locale | None:
        """
        Return currently selected locale.

        :returns: The currently selected locale.
        :rtype: Locale
        """
        return self._localespec

    def _set_locale(self, locale_spec: str | None = None) -> None:
        """
        Select new locale.

        :param str locale_spec: The new locale specification.
        """
        if locale_spec is None:
            return
        self._localespec = Locale(locale_spec)

    locale = property(fget=_get_locale, fset=_set_locale)

    def translate(self, message: str) -> str:
        """
        Translate message.

        :param str message: The message to translate.
        :returns: The localized message.
        :rtype: str
        """
        if self._translation is None:
            return message

        return self._translation.gettext(message)

    _ = translate


class Translation(NullTranslation):
    """Translation."""

    _instances: list[weakref.ReferenceType['Translation']] = []
    locale: Locale = Locale()  # type: ignore

    def set_language(self, language: str = "") -> None:
        """
        Select language.

        :param str language: The language code.
        :raises I18N_Error: if the given locale is not valid.
        """
        if language:
            Translation.locale.parse(language)

        if not Translation.locale:
            try:
                lang = getlocale(LC_MESSAGES)
                language = lang[0] or "C"
                Translation.locale.parse(language)
            except Error as exc:
                raise I18N_Error('The given locale is not valid: %s' % (exc,))

        if not self._domain:
            return

        try:
            self._translation = gettext.translation(self._domain, languages=(Translation.locale.language, ), localedir=self._localedir)
        except OSError:
            try:
                self._translation = gettext.translation(self._domain, languages=('%s_%s' % (Translation.locale.language, Translation.locale.territory), ), localedir=self._localedir)
            except OSError:
                self._translation = None

    def __new__(cls, *args, **kwargs):
        self = object.__new__(cls)
        cls._instances.append(weakref.ref(self))
        cls._instances = [ref for ref in cls._instances if ref() is not None]
        return self

    @classmethod
    def set_all_languages(cls, language: str) -> None:
        """
        Set the language of all existing :class:`Translation` instances.
        This is required when instances are created during import time but later on the language should be changed.
        """
        for ref in cls._instances:
            instance = ref()
            if instance is not None:
                instance.set_language(language)
