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

__all__ = [
    'NAME_PREFIXES',
    'TRANS_DICT',
    'adjust_spaces_on_punctuation',
    'and_list',
    'capitalize_name',
    'could_be_a_name',
    'get_gender',
    'leet',
    'map_special_chars',
    'normalize_name',
    'normalize_text',
    'sort_names',
    'sort_word_list',
]

import random
import re
import unicodedata
from string import punctuation
from typing import Dict, List, Literal, Optional, Union, cast

import gender_guesser.detector as gender
import unidecode
from nameparser import HumanName

GENDER_DETECTOR = gender.Detector(case_sensitive=False)

TRANS_DICT = {
    'ä': 'ae',
    'Ä': 'Ae',
    'ö': 'oe',
    'Ö': 'Oe',
    'ü': 'ue',
    'Ü': 'Ue',
    'ß': 'ss',
    'æ': 'ae',
    'Æ': 'Ae'
}

CHAR_NB_MAP = {
    'a': ('a', '4', '@'),
    'b': ('b', '6', '8', 'I3', '13', '!3'),
    'c': ('c', '('),
    'd': ('d', ')', '!)', 'cl'),
    'e': ('e', '3', '€'),
    'f': ('f', '7', '/='),
    'g': ('g', '9', '6'),
    'h': ('h', '8', '#', '!-!'),
    'i': ('i', '1', '!'),
    'j': ('j', '1', '_)'),
    'k': ('k', '7'),
    'l': ('1', '!', '(_'),  # intentionally no 'l' — too easily confused with 'I' and '1'
    'm': ('m', 'nn', '(V)', '/VA'),
    'n': ('n', '/V'),
    'o': ('o', '0'),
    'p': ('p', '?'),
    'q': ('q', '9', '2', '()_'),
    'r': ('r', '8', '12'),
    's': ('s', '5', '$', '§'),
    't': ('t', '7', '+'),
    'u': ('u', '(_)'),
    'v': ('v',),
    'w': ('w', 'vv', 'uu'),
    'x': ('x', '><'),
    'y': ('y', '7', '?'),
    'z': ('z', '2', '7_')
}

# regex pattern
LANG_PUNCT_SPACES = {
    'fr': {
        'regex': r'([!?:;%])',
        'narrow': '\u202f\\1',
        'normal': r' \1'
    },
    'es': {
        'regex': r'([¿¡])',
        'narrow': '\\1\u202f',
        'normal': r'\1 '
    },
    'ro': {
        'regex': r'([!?:;])',
        'narrow': '\u202f\\1',
        'normal': r' \1'
    }
}

def adjust_spaces_on_punctuation(
    text: str,
    language: str,
    space: str = 'narrow'
) -> str:
    """Adjust spaces before or after punctuation marks according to the language.
    By default the narrow space (\\u202f) is used for adjustments.

    :param text: input text
    :param language: language code (e.g. ``'fr'``, ``'es'``, ``'ro'``)
    :param space: ``'narrow'`` (default, uses U+202F) or ``'normal'``
    :return: text with adjusted spacing around punctuation
    """
    # Remove existing spaces before punctuation (universal for all languages)
    text = re.sub(r'\s+([!?:;%\),.])', r'\1', text)

    lang_data = LANG_PUNCT_SPACES.get(language)

    if lang_data:
        # Add language-specific spacing (e.g. narrow space before : ; ? ! in French)
        try:
            text = re.sub(
                lang_data['regex'],
                lang_data[space],
                text
            )
        except re.error as e:
            raise ValueError(f"Error in regex for language {language}: {e}")

    return text


def and_list(
    elements: list,
    et: str = 'and'
) -> str:
    """Create a human-readable list, including "and"
    (or any similar word, i.e. in another language)

    :param elements: list of items to join
    :param et: conjunction word (default ``'and'``)
    :return: formatted string like ``'a, b and c'``
    """

    elements = [str(el) for el in elements]
    joined = ', '.join(elements)
    # Replace the last comma with the conjunction word (e.g. "and")
    last_comma = joined.rfind(', ')
    if last_comma == -1:
        return joined
    return joined[:last_comma] + f' {et} ' + joined[last_comma + 2:]


NAME_PREFIXES = (
    'van der',
    'von der',
    'de la',
    'del',
    'de',
    'du',
    'la',
    'von',
    'van',
    'der',
)


def capitalize_name(
    text: str
) -> str:
    """Capitalize name

    This is specially handy for names which otherwise
    would be capitalized wrongly with string's "title"
    method like:

       - Conan McArthur
       - Susanne Mayr-Grünwald
       - Maria de Angelis

    :param text: name string to capitalize
    :return: properly capitalized name
    """
    return normalize_name(
        name=text,
        returning='str'
    ) # type: ignore[return-value]  # guaranteed to be str when returning='str'


def could_be_a_name(
    name: Optional[str] = None,
    default: bool = True,
    prename: bool = False,
    lastname: bool = False
) -> bool:
    """
    Checks if a string is possibly a name

    name is preferably a full name, if not, either 'prename' or 'lastname' must be set to True

    Checks:

    - is alpha (only alpha characters)
    - there are no two consecutive uppercase characters
    - last character in any word is not uppercase
    - gender can be determined for prename or middlename

    :param name: the string to check
    :param default: the default boolean value returned if no check was successful
                    (neither positively nor negatively)
    :param prename: if True, a prename is acceptable (otherwise the full name is needed)
    :param lastname: if True, a last name is acceptable (otherwise the full name is needed)
    :return: True if the string could be a name
    """
    if prename and lastname:
        raise ValueError('prename and lastname can not be both True')

    if name is None:
        return False

    if not name.replace(
        ' ', ''
    ).replace(
        '-', ''
    ).isalpha():
        return False

    # 2 consecutive uppercase chars
    if re.search('[A-Z]{2}', name):
        return False

    human_name = cast(HumanName, normalize_name(
        name=name,
        lastname=lastname,
        returning='HumanName'
    ))

    first = human_name.first
    middle = human_name.middle
    last = human_name.last

    if prename and not first:
        return False
    if lastname and not last:
        return False

    for n in (first, middle, last):
        if not n:
            continue
        # last char is capital
        if n[-1].isupper():
            return False

        # more than 2 capitals per word
        n_words = len(n.split())
        if sum(1 for c in n if c.isupper()) > n_words + 1:
            return False

    for n in (first, middle):
        if not n:
            continue

        if get_gender(prename=n) != 'unknown':
            return True

        sum_upper = sum(1 for c in n if c.isupper())
        if sum_upper != len(n) and sum_upper > 1:
            return False

    return default


def get_gender(
    prename: str
) -> Literal['male', 'female', 'unknown']:
    """Get the gender from a prename

    :param prename: first name (compound names like ``'Anna-Maria'`` are supported)
    :return: ``'male'``, ``'female'`` or ``'unknown'``
    """
    prenames = prename.replace('-', ' ').split()

    for n in prenames:
        result = GENDER_DETECTOR.get_gender(n)
        if result in ('male', 'mostly_male'):
            return 'male'
        if result in ('female', 'mostly_female'):
            return 'female'

    return 'unknown'


def leet(
    text: str,
    max_length: Optional[int] = None,
    change_uppercase: int = 4,
    symbol_chance: int = 0,
    start_at_begin: bool = True
) -> str:
    """Leet a string

    Convert any string to leet speak.

    :param text: input text to convert
    :param max_length: optional maximum length of the returned string
    :param change_uppercase: 1 out of n chance that a character will be uppercased (default 4 = 25%)
    :param symbol_chance: 1 out of n chance that a random symbol will be added after a character
                          (0 = no symbols, default)
    :param start_at_begin: if True, truncate from the end; if False, pick a random substring
    :return: leet-speak version of the input text
    """

    text = unidecode.unidecode(text).lower()  # remove Umlaute

    leeted_text = ''

    for c in text:
        if not c.isalpha():
            continue  # without counting

        c = random.choice(CHAR_NB_MAP[c])

        if not random.randrange(change_uppercase):
            c = c.upper()

        leeted_text += c

        if symbol_chance and not random.randrange(symbol_chance):
            leeted_text += random.choice(punctuation)

    if max_length and len(leeted_text) > max_length:
        if start_at_begin:
            leeted_text = leeted_text[:max_length]
        else:
            text_length = len(leeted_text)
            max_start = max(1, text_length - max_length)
            start_position = random.randint(1, max_start)
            end_position = start_position + max_length
            leeted_text = leeted_text[start_position:end_position]

    return leeted_text


def sort_names(names: List[str]) -> List[str]:
    """Sort names

    Sorts by name, prename: splits only at the last space before sorting.

    Correctly sorts names

        - with special characters, like umlauts
        - combined names
        - names with prefixes (like de or von)

    examples:

        - Susanne Mayr-Grünwald
        - Maria de Angelis
        - Bea-Regina Hofstätter
        - Rafael

    :param names: list of name strings
    :return: sorted list of names
    """

    sort_pairs = []

    for name in names:
        human_name = cast(HumanName, normalize_name(
            name=name,
            returning='HumanName'
        ))

        first = human_name.first
        last = human_name.last

        if not last:  # only prename
            last = first

        split_at = ' '
        for name_prefix in NAME_PREFIXES:
            if f' {name_prefix} ' in last:
                split_at = f' {name_prefix} '
                break

        reversed_name = split_at.join(
            name.rsplit(split_at, 1)[::-1]
        )

        sort_key = unidecode.unidecode(reversed_name).lower()
        sort_pairs.append((sort_key, name))

    sort_pairs.sort(key=lambda pair: pair[0])

    return [name for _, name in sort_pairs]


def map_special_chars(
    text: str,
    sort: bool = False
) -> str:
    """ map special characters

    .. note:: Umlauts are even recognized if they do not use the appropriate characters but
              a vowel followed by a COMBINING DIAERESIS (U+0308) character instead.

    :param text: input text
    :param sort: if False (default), German umlauts are expanded (ä → ae, ö → oe, etc.)
                 before transliteration. If True, umlauts are transliterated 1:1 (ä → a)
                 to preserve character lengths (useful for sort-key generation).
    :return: ASCII-only transliterated text
    """
    text = normalize_text(text)

    if not sort:
        for key, value in TRANS_DICT.items():
            text = text.replace(key, value)

    return unidecode.unidecode(text)


def normalize_text(
    text: str
) -> str:
    """Normalize text

    Convert decomposed Unicode characters (e.g. vowel + COMBINING DIAERESIS U+0308)
    to their precomposed NFC form (e.g. ä, ö, ü).

    :param text: input text
    :return: NFC-normalized text
    """
    return unicodedata.normalize('NFC', text)



def normalize_name(
    name: str,
    lastname: bool = False,
    returning: Literal['str', 'dict', 'HumanName'] = 'str'
) -> Union[str, Dict, HumanName]:
    """Normalize name

    This function uses the nameparser package.

    :param name: name string to normalize
    :param lastname: if True, treat a single word as last name
    :param returning:
        - ``'str'``: return the name as a string
        - ``'dict'``: return a dictionary with the name elements
        - ``'HumanName'``: return the HumanName object from nameparser
    :return: normalized name as string, dict, or HumanName object
    """
    parsed_name = HumanName(name)
    parsed_name.capitalize()

    if lastname and parsed_name.last == '':
        parsed_name.last = parsed_name.first
        parsed_name.first = ''

    if 'str' in returning:
        return str(parsed_name)
    elif 'dict' in returning:
        return parsed_name.as_dict()
    else:  # return the HumanName object
        return parsed_name


def sort_word_list(
    word_list: List[str]
) -> List[str]:
    """Sort a list of words, taking into account special characters

    :param word_list: list of words to sort
    :return: sorted list of words
    """

    sort_pairs = [
        (map_special_chars(word, sort=True).lower(), word)
        for word in word_list
    ]
    sort_pairs.sort(key=lambda pair: pair[0])

    return [word for _, word in sort_pairs]
