import json
import os
import re
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Literal

import babel

from toolforge_i18n.get_gender import get_gender_by_user_name


def language_code_to_babel(code: str) -> str:
    """Default implementation to map a MediaWiki language code to Babel.

    This implementation is conservative and only maps language codes
    where Babel has an alternative that does not lose any information
    (at least as far as toolforge_i18n is concerned).
    MediaWiki also supports many language codes that (as far as I know)
    have no lossless equivalent in Babel,
    such as (at the time of writing) sh-latn (Serbo-Croatian in Latin script).
    If your tool is translated into one of those languages,
    you will have to configure a custom language_code_to_babel implementation
    in your tool_translations_config and pick some lossy fallback
    (e.g. hr, Croatian, for sh-latn).
    Your implementation should generally delegate to this one first, for instance:

        def language_code_to_babel(code: str) -> str:
            mapped = toolforge_i18n.translations.language_code_to_babel(code)
            if mapped != code:
                return mapped
            return {
                'sh-latn': 'hr',
                # ...
            }.get(code, code.partition('-')[0])
    """

    return {
        'be-tarask': 'be_TARASK',
        'de-formal': 'de',  # the formal/informal distinction is irrelevant for I18nFormatter
        'nds-nl': 'nds_NL',
        'kk-cyrl': 'kk',  # Cyrl implied: see e.g. babel.Locale('kk').languages['en']
        'ko-kp': 'ko_KP',
        'ks-arab': 'ks_Arab',
        'ku-latn': 'ku',  # Latn implied: see e.g. babel.Locale('ku').languages['en']
        'ms-arab': 'ms_Arab',
        'nl-informal': 'nl',  # the formal/informal distinction is irrelevant for I18nFormatter
        'pt-br': 'pt_BR',
        'sr-ec': 'sr_Cyrl',
        'sr-el': 'sr_Latn',
        'tg-cyrl': 'tg',  # Cyrl implied: see e.g. babel.Locale('tg').languages['en']
        'tt-cyrl': 'tt',  # Cyrl implied: see e.g. babel.Locale('tt').languages['en']
        'ug-arab': 'ug',  # Arab implied: see e.g. babel.Locale('ug').languages['en']
        'yue-hant': 'yue_Hant',
        'zh-hans': 'zh_Hans',
        'zh-hant': 'zh_Hant',
    }.get(code, code)


@dataclass
class TranslationsConfig:
    """Configuration for loading message translations.

    To use this library, a tool should define a `tool_translation_config` module
    which exports a `config` member of this type, like so:

        # tool_translation_config.py
        import TranslationsConfig from toolforge_i18n.translations
        config = TranslationsConfig(
            # ...
        )

    The most important config to define is `variables`,
    which most tools will need (unless all your messages have no variables);
    the others may or may not be necessary depending on the tool.
    """

    directory: str = 'i18n/'
    """The path to the directory to load message files from."""

    variables: Mapping[str, Sequence[str]] = field(default_factory=dict)
    """Variable names used in messages.

    The source messages use $1, $2 etc.,
    but the Python format strings use named variables,
    whose names are specified here.
    The variable name (or its prefix) encodes the type:
    * url, url_* - hyperlink: [$1 text] => {url!h:text}
    * user_name, user_name_* - gender: {{GENDER:$1|he|she|they}} => {user_name!g:m=he:f=she:n=they}
    * num, num_* - plural: {{PLURAL:$1|one egg|$1 eggs}} => {num_eggs!p:one=one egg:other={num_eggs} eggs}
    * list, list_* - list: $1 => {list_chicken_names!l}
    * anything else - markup without further formatting: $1 => {description}
    """

    derived_messages: Mapping[str, tuple[str, Callable[[str], str]]] = field(default_factory=dict)
    """Messages that are derived from other messages.

    The key is a message key that is not expected in the JSON files,
    but that is instead generated by taking another message
    (whose key is the first element of the tuple)
    and sending it through the callable in the second element of the tuple.
    Examples for that callable include the identity function
    (to copy a message) or simple case transformations.
    """

    language_code_to_babel: Callable[[str], str] = language_code_to_babel
    """Mapping from MediaWiki to Babel language codes.

    Message files use MediaWiki language codes,
    which are not always standard language codes;
    additionally, MediaWiki supports many languages that Babel does not,
    even when the language code is standard.
    You will need to map these codes to a supported alternative
    if you have any translations for such language codes.
    """

    allowed_html_elements: dict[str, set[str]] = field(
        default_factory=lambda: {
            'abbr': {'title'},
            'kbd': set(),
            'q': set(),
        }
    )
    """HTML elements that should be allowed in messages.

    The key is an element name,
    and the value is a set of attributes that are allowed on that element.
    All other elements and attributes will cause a test failure.
    (See also allowed_global_attributes below.)

    Note that this is only checked when running the tests,
    not when loading the translations.
    You *must* run the tests to ensure that malicious translations are not used.
    """

    allowed_global_attributes: set[str] = field(
        default_factory=lambda: {
            'dir',
            'lang',
        }
    )
    """HTML attributes that should be allowed on any element in messages.

    This is similar to allowed_html_elements above,
    but the given attribute names are allowed regardless of element name.
    Again, this is only checked when the tests are run.
    """

    get_gender: Callable[[Any], Literal['m', 'f', 'n']] = get_gender_by_user_name
    """The get_gender function used by I18nFormatter.

    Defaults to a function that expects the argument to be a user name,
    and looks it up on Meta-Wiki.
    You may specify a different function,
    where the type (the value you pass as user_name= into messages)
    can be anything you want.
    """


def mw2py(mw: str, babel_language: str, variables: Sequence[str]) -> str:
    locale = babel.Locale(babel_language)

    def replace_plural(match: re.Match[str]) -> str:
        nonlocal locale, variables
        number = int(match[1])
        variable = variables[number - 1]
        args = match[2].split('|')
        plurals = []
        tag_args = []
        for arg in args:
            key, _, text = arg.partition('=')
            if key.isnumeric():
                plurals.append(arg)
            else:
                tag_args.append(arg)
        tags = [tag for tag in ['zero', 'one', 'two', 'few', 'many'] if tag in locale.plural_form.tags]
        tags = tags[: len(tag_args) - 1] + ['other']
        for tag, tag_arg in zip(tags, tag_args, strict=False):
            plurals.append(f'{tag}={tag_arg}')
        return '{' + variable + '!p:' + ':'.join(plurals) + '}'

    py = re.sub(r'\{\{PLURAL:\$([1-9][0-9]*)\|([^}]*)\}\}', replace_plural, mw)

    def replace_gender(match: re.Match[str]) -> str:
        nonlocal variables
        number = int(match[1])
        variable = variables[number - 1]
        args = match[2].split('|')
        genders = []
        for gender, arg in zip(['m', 'f', 'n'], args, strict=False):
            genders.append(f'{gender}={arg}')
        return '{' + variable + '!g:' + ':'.join(genders) + '}'

    py = re.sub(r'\{\{GENDER:\$([1-9][0-9]*)\|([^}]*)\}\}', replace_gender, py)

    def replace_hyperlink(match: re.Match[str]) -> str:
        nonlocal variables
        number = int(match[1])
        variable = variables[number - 1]
        inner_html = match[2]
        assert '{' not in inner_html and '}' not in inner_html
        return '{' + variable + '!h:' + inner_html + '}'

    py = re.sub(r'\[\$([1-9][0-9]*) ([^]]*)\]', replace_hyperlink, py)

    def replace_unconverted(match: re.Match[str]) -> str:
        nonlocal variables
        number = int(match[1])
        variable = variables[number - 1]
        if variable.startswith('list_'):
            return '{' + variable + '!l}'
        else:
            return '{' + variable + '}'

    py = re.sub(r'\$([1-9][0-9]*)', replace_unconverted, py)

    return py


def load_translations(config: TranslationsConfig) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
    translations: dict[str, dict[str, str]] = {}
    documentation: dict[str, str] = {}
    for entry in os.scandir(config.directory):
        if not entry.is_file():
            continue
        match = re.match(r'(.*)\.json$', entry.name)
        if not match:
            continue
        language = match[1]
        with open(entry.path, 'r') as f:
            data = json.load(f)
        if language == 'qqq':
            documentation = {key: value for key, value in data.items() if not key.startswith('@')}
            continue
        translations[language] = {}
        for key in data:
            if key.startswith('@'):
                continue
            msg = mw2py(data[key], config.language_code_to_babel(language), config.variables.get(key, []))
            translations[language][key] = msg
        for key in config.derived_messages:
            source_key, transformation = config.derived_messages[key]
            if source_key in translations[language]:
                translations[language][key] = transformation(translations[language][source_key])
    return translations, documentation
