# -*- coding: utf-8 -*-
"""
itb_locales
===========

.. note:: Several functions use ``get_locale()`` in their default parameter
   values. These defaults are evaluated once at import time and will not
   reflect locale changes at runtime.
"""

__all__ = [
    'DEFAULT_COUNTRY',
    'DEFAULT_LANGUAGE',
    'fetch_all_countries',
    'fetch_holidays',
    'format_price',
    'get_country',
    'get_language_name',
    'get_locale',
    'is_holiday',
]

import datetime
import gettext
import locale
import math
import re
from collections.abc import Mapping
from functools import lru_cache
from types import MappingProxyType
from typing import List, Literal, Optional, Sequence, Tuple, Union

import DateTime
import holidays
import pycountry
from babel import Locale
from babel.numbers import format_currency, get_territory_currencies
from .itb_date_time import convert_datetime
from .itb_text_name import map_special_chars

DEFAULT_LANGUAGE = 'de'
DEFAULT_COUNTRY = 'AT'
DEFAULT_KEYS = ['alpha_2', 'name']

countries = pycountry.countries
KEYS_TO_TRANSLATE = frozenset(('name', 'official_name'))
VALID_COUNTRY_KEYS = frozenset(('alpha_2', 'alpha_3', 'name', 'official_name', 'numeric'))


@lru_cache
def _get_locale(
    loc: Optional[Tuple[Optional[str], ...]] = None
) -> MappingProxyType:
    loc_dict = {}

    if not loc:
        loc = locale.getlocale()
        loc_dict['locale'] = loc

    locale_str = loc[0]
    loc_dict['locale_str'] = locale_str

    if locale_str is None:
        # e.g. in Docker containers, CI environments, or programs like FileMaker
        language = DEFAULT_LANGUAGE
        country = DEFAULT_COUNTRY
    else:
        language, *rest = locale_str.split('_')
        country = rest[0] if rest else ''

    loc_dict['language'] = language
    loc_dict['country'] = country

    if country:
        currencies = get_territory_currencies(country)
        loc_dict['currency'] = currencies[0] if currencies else ''
    else:
        loc_dict['currency'] = ''

    return MappingProxyType(loc_dict)


def get_locale(
    loc: Optional[Tuple[Optional[str], ...]] = None
) -> dict:
    """Get system locale or transform locale provided as argument into a dictionary

    :param loc: optional locale tuple (e.g. ``('de_AT', 'UTF-8')``); if None, uses system locale
    :return: dict with keys ``locale``, ``locale_str``, ``language``, ``country``, ``currency``
    """
    return dict(_get_locale(loc))


def _translation_needed(
    keys: Sequence[str],
    language: str
) -> bool:
    """Check if translation is needed

    :param keys: keys that may need translation
    :param language: translate if language is not en
    """
    if not language or language == 'en':
        return False

    return any(key in KEYS_TO_TRANSLATE for key in keys)


def fetch_all_countries(
    keys: Sequence[str] = DEFAULT_KEYS,
    returning: Literal['list', 'tuple', 'dict', 'dictdict'] = 'list',
    language: str = DEFAULT_LANGUAGE,
    sort_key: str = 'name'
) -> Union[list, tuple, dict]:
    """Fetch all countries

    :param keys: default is ['alpha_2', 'name'], possible values are:

        - alpha_2
        - alpha_3
        - name
        - official_name
        - numeric

    :param returning: list, tuple, dict, dictdict (dictionary of dictionaries) are possible
    :param language: default is de
    :param sort_key: name of the key

    :note: For list/tuple returning with multiple keys, each entry tuple contains an
        additional last element: the sort-key value normalized via ``map_special_chars``
        (e.g. ``('AT', 'Österreich', 'osterreich')``). This allows the caller to use
        or discard the sort value as needed.

    :raises ValueError: if sort_key is not in keys or invalid parameters
    :return: countries as list, tuple, dict, or dict of dicts (depending on ``returning``)
    """
    # Input validation
    if not isinstance(keys, (list, tuple)) or not keys:
        raise ValueError('keys must be a non-empty list or tuple of strings')
    invalid_keys = set(keys) - VALID_COUNTRY_KEYS
    if invalid_keys:
        raise ValueError(f'Invalid keys: {invalid_keys}. Valid keys are: {VALID_COUNTRY_KEYS}')
    elif not isinstance(language, str) or not language:
        raise ValueError('language must be a non-empty string')
    elif sort_key not in keys:
        raise ValueError(f'sort_key "{sort_key}" must be in keys')

    get_translation = _translation_needed(
        keys=keys,
        language=language
    )

    if get_translation:
        language_trans = gettext.translation(
            'iso3166-1',
            pycountry.LOCALES_DIR,
            languages=[language]
        )
        _gettext = language_trans.gettext

        def translate(text: str, key: str) -> str:
            if key not in KEYS_TO_TRANSLATE or not text:
                return text
            return _gettext(text)
    else:
        def translate(text: str, key: str) -> str:
            return text

    if returning in ('list', 'tuple'):
        # makes only sense for lists and tuples
        key = keys[0] if len(keys) == 1 else None

        if key:
            base_list = [
                translate(
                    getattr(country, key, ''), key
                ) for country in countries
            ]

        else:
            base_list = [tuple(
                [translate(
                    getattr(country, key, ''),
                    key
                ) for key in keys]
                + [map_special_chars(
                    translate(
                        getattr(country, sort_key, ''),
                        sort_key
                    ),
                    sort=True
                )]) for country in countries]

        if key:
            # single-key: flat list of strings, sort by normalized value
            base_list.sort(key=lambda s: map_special_chars(s, sort=True))
        else:
            # multi-key: tuples with sort value as last element
            base_list.sort(key=lambda tup: tup[-1])

        if returning.startswith('tuple'):
            all_countries = tuple(base_list)
        else:
            all_countries = base_list

    elif returning == 'dict':
        all_countries = {
            getattr(
                country,
                keys[0]
            ): tuple(
                [
                    translate(
                        getattr(country, key, ''),
                        key
                    ) for key in keys[1:]
                ]) for country in countries
        }

    elif returning == 'dictdict':
        all_countries = {
            getattr(
                country,
                keys[0]
            ): {
                key: translate(
                    getattr(country, key, ''),
                    key
                ) for key in keys[1:]
            } for country in countries}

    return all_countries


def format_price(
    price: Union[float, int, str],
    loc: Optional[Union[str, Mapping]] = None,
    round_mode: Literal['round', 'ceil', 'up', 'floor', 'down'] = 'round',
    decimals: int = 2
) -> str:
    """
    :param price: price to be formatted
    :param loc: locale — can be:

        - ``None``: use system locale
        - a string like ``'de_AT'`` or ``'AT'`` (country code)
        - a Mapping (dict or MappingProxyType), e.g. from ``get_locale()``

    :param round_mode:
        - round: round half to even
        - ceil or up: round up
        - floor or down: round down
    :param decimals: number of decimal places
    :raises ValueError: for invalid inputs
    :return: formatted price string (e.g. ``'€ 12,50'``)
    """
    # Input validation
    try:
        price = float(price)
    except (ValueError, TypeError):
        raise ValueError('price must be convertible to float')
    if not isinstance(decimals, int) or decimals < 0:
        raise ValueError('decimals must be a non-negative integer')

    # Process loc
    if loc is None:
        loc_dict = get_locale()
    elif isinstance(loc, str):
        if '_' in loc:
            loc_dict = get_locale((loc, None))
        else:
            # country code like 'AT' or 'at'
            loc_dict = get_locale((f'{DEFAULT_LANGUAGE}_{loc.upper()}', None))
    elif isinstance(loc, Mapping):
        loc_dict = loc
    else:
        raise ValueError('loc must be None, a string, or a Mapping')

    if round_mode == 'round':
        price = round(price, decimals)
    elif round_mode in ('ceil', 'up'):
        price = math.ceil(price * 10**decimals) / 10**decimals
    elif round_mode in ('floor', 'down'):
        price = math.floor(price * 10**decimals) / 10**decimals

    locale_str = loc_dict.get('locale_str')
    try:
        # Build format pattern with the requested number of decimal places
        # based on the locale's default currency format
        pattern = Locale.parse(locale_str).currency_formats['standard'].pattern
        if decimals > 0:
            decimal_part = '.' + '0' * decimals
            pattern = re.sub(r'\.0+', decimal_part, pattern)
        else:
            pattern = re.sub(r'\.0+', '', pattern)

        price_str = format_currency(
            price,
            loc_dict.get('currency', ''),
            format=pattern,
            locale=locale_str,
            currency_digits=False
        )
    except Exception:
        raise ValueError('Failed to format currency, check locale data')

    # Replace trailing zeros in decimal part with dash (e.g. "5,00" → "5,-")
    # Works regardless of currency symbol position (before or after the number)
    if decimals > 0:
        zeros = '0' * decimals
        price_str = re.sub(rf'{zeros}(?=\D*$)', '-', price_str)

    return price_str.replace('\xa0', ' ')


@lru_cache(maxsize=5)
def _get_country(
    country: str,
    key: str,
    language: str
) -> MappingProxyType:
    if key in ('alpha_2', 'alpha_3'):
        country = country.upper()

    my_country = countries.get(**{key: country})
    if my_country is None:
        raise LookupError(f'Country not found: {key}={country}')

    alpha_2 = getattr(my_country, 'alpha_2', '')

    country_dict = {}

    try:
        country_dict['name'] = Locale(language).territories[alpha_2]
    except KeyError:
        country_dict['name'] = getattr(my_country, 'name', '')

    for k in (
        'alpha_2',
        'alpha_3',
        'numeric',
        'official_name'
    ):
        country_dict[k] = getattr(my_country, k, '')

    return MappingProxyType(country_dict)


def get_country(
    country: str = get_locale().get('country') or '',
    key: str = 'alpha_2',
    language: str = get_locale().get('language') or 'en'
) -> dict:
    """Get country

    Return all the data for a country

    :param country: country identifier (code or name, depending on key)
    :param key: field to search by (alpha_2, alpha_3, name, etc.)
    :param language: language for the translated country name
    :raises LookupError: if country is not found
    :return: dict with keys ``name``, ``alpha_2``, ``alpha_3``, ``numeric``, ``official_name``
    """
    return dict(_get_country(country, key, language))


@lru_cache(maxsize=5)
def get_language_name(
    code: str = get_locale().get('language') or 'en',
    language: str = get_locale().get('language') or 'en'
) -> str:
    """Get language name

    :param code: ISO language code (e.g. ``'de'``, ``'en'``)
    :param language: language in which the language name is returned
    :return: language name string (e.g. ``'Deutsch'``)
    """

    return Locale(code).get_language_name(language)


def fetch_holidays(
    country: str = get_locale().get('country') or '',
    state: Optional[Union[str, int]] = None,
    subdiv: Optional[Union[str, int]] = None,
    years: Optional[Union[List[int], int]] = None,
    length: int = 0,
    daterange: Optional[List[Union[str, datetime.date, datetime.datetime, DateTime.DateTime]]] = None,
    fmt: Optional[str] = None,
    datatype: str = 'date',
    returning: Literal['dict', 'dictionary', 'holidays', 'days', 'dates', 'names', 'names_sorted', 'tuples'] = 'dictionary'
) -> Union[List, dict]:
    """Fetch holidays

    :param country: ISO 3166-1 alpha-2 country code (default from system locale)
    :param state: convenience alias for ``subdiv``
    :param subdiv: country subdivision (state/province) code
    :param years: year or list of years to fetch holidays for
    :param length: maximum number of holidays to return (0 = unlimited)
    :param daterange: list of two dates defining start and end of the range
    :param fmt: strptime format string for parsing date strings (default: ``%d.%m.%Y``)
    :param datatype: output date type (``'date'``, ``'datetime'``, ``'DateTime'``, ``'pendulum'``)
    :param returning: values

        - dict, dictionary (default): keys as specified in argument datatype
        - holidays: return the holidays data object
        - days, dates: return only the dates, as specified in argument datatype
        - names: only the names of the holidays, sorted chronologically
        - names_sorted: only the names of the holidays, sorted alphabetically
        - tuples

    :raises ValueError: for unknown value for argument "returning"
    :raises ValueError: for datatype "DateTime" and returning "holidays"
    :return: holidays in the format specified by ``returning``
    """

    country = country.upper()

    if not subdiv and state:
        subdiv = state

    if not years and not daterange:
        years = datetime.datetime.today().year

    if years:
        holi_days = holidays.country_holidays(
            country,
            subdiv=subdiv,
            years=years
        )

    elif daterange:
        daterange = [
            convert_datetime(daterange[0], 'date', fmt=fmt),
            convert_datetime(daterange[1], 'date', fmt=fmt)
        ]

        holi_days = holidays.country_holidays(
            country,
            subdiv=subdiv,
            years=list(range(
                daterange[0].year,
                daterange[1].year + 1
            ))
        )

        # remove dates outside of daterange
        for datum in list(holi_days):
            if datum < daterange[0] or datum > daterange[1]:
                holi_days.pop(datum)

    # sort by date
    holi_days = {date: holi_days[date] for date in sorted(holi_days)}

    # apply length limit (after sorting so we keep the earliest dates)
    if length and length < len(holi_days):
        dates_to_keep = set(list(holi_days)[:length])
        for datum in list(holi_days):
            if datum not in dates_to_keep:
                holi_days.pop(datum)

    if returning == 'holidays':
        if datatype == 'DateTime':
            raise ValueError('Cannot use datatype DateTime with holidays data object.')
        return holi_days

    if returning in ('dict', 'dictionary'):
        if datatype == 'date':
            return dict(holi_days)
        else:
            return {
                convert_datetime(date, datatype): name
                for date, name in holi_days.items()
            }

    elif returning in ('days', 'dates'):
        if datatype == 'date':
            return list(holi_days)
        else:
            return [convert_datetime(day, datatype) for day in holi_days]

    elif returning == 'names':
        # unique names, preserving chronological order
        seen = set()
        return [name for name in holi_days.values()
                if name not in seen and not seen.add(name)]

    elif returning == 'names_sorted':
        # unique names, sorted alphabetically
        return sorted(set(holi_days.values()))

    elif returning == 'tuples':
        # sorted by date
        return [
            (convert_datetime(date, datatype), name)
            for date, name in holi_days.items()
        ]

    else:
        raise ValueError(
            f'unknown value "{returning}" for argument "returning"'
        )


def is_holiday(
    datum: Optional[Union[
        str,
        datetime.date,
        datetime.datetime,
        DateTime.DateTime,
     ]] = None,
    country: str = get_locale().get('country') or '',
    fmt: Optional[str] = None,
    state: Optional[Union[str, int]] = None,
    subdiv: Optional[Union[str, int]] = None,
) -> bool:
    """Check if a given date is a public holiday

    :param datum: the date to check — if ``None``, today's date is used
    :param country: ISO 3166-1 alpha-2 country code (default from system locale)
    :param fmt: optional strptime format string for parsing ``datum`` when given as string
    :param state: convenience alias for ``subdiv``
    :param subdiv: country subdivision (state/province) code
    :return: ``True`` if the date is a public holiday, ``False`` otherwise
    """

    if not datum:
        datum = datetime.date.today()

    country = country.upper()

    if state and not subdiv:
        subdiv = state

    datum = convert_datetime(datum, 'date', fmt=fmt)
    year = datum.year

    holi_days = holidays.country_holidays(
        country,
        subdiv=subdiv,
        years=year
    )

    return datum in holi_days
