import math
import re
from itertools import product
from typing import List, Set

# squash whitespace: to collapse consecutive whitespace to a single space by
#    x.sub(' ', text)
SQUASH_WS_RE = re.compile(r"\s+")


# to identify strings with any symbols by
#    x.search(text)
ALL_SYMBOLS_RE = re.compile(r"[^\w\s]+")


# camelcase LU: to split between consecutive lower and upper chars by
#    x.sub(r'\1 \2', text)
CAMELCASE_LU_RE = re.compile(r"([a-z]+)([A-Z])")


# camelcase UL: to split between consecutive upper and uppler lower chars by
#    x.sub(r'\1 \2', text)
CAMELCASE_UL_RE = re.compile(r"([A-Z]+)([A-Z][a-z])")


# non-embedded symbols: those without a word char on both sides by
#    x.sub('', text)
NON_EMBEDDED_WORD_SYMS_RE = re.compile(r"((?<!\w)[^\w\s]+)|([^\w\s]+(?!\w))")


# embedded symbols: to drop embedded symbols by
#    x.sub('', text)
EMBEDDED_SYMS_RE = re.compile(r"(?<=\w)[^\w\s]+(?=\w)")


# hyphen-slash: to split between an embedded hyphen and/or slash by
#    x.split(text)
HYPHEN_SLASH_RE = re.compile(r"(?<=\w)[\-\/ ](?=\w)")


# hyphen-only: to split between an embedded hyphen by
#    x.split(text)
HYPHEN_ONLY_RE = re.compile(r"(?<=\w)[\- ](?=\w)")


# slash-only: to split between an embedded slash by
#    x.split(text)
SLASH_ONLY_RE = re.compile(r"(?<=\w)\/(?=\w)")


# parenthetical expressions: to drop parenthetical expressions by
#    x.sub('', text)
PARENTHETICAL_RE = re.compile(r"\(.*\)")


# ampersand: to replace an ampersand with " and " by
#    x.sub(' and ', text)
AMPERSAND_RE = re.compile(r"\s*\&\s*")


def expand_camelcase_fn(text: str) -> str:
    """Expand both "lU" and "UUl" camelcasing to "l U" and "U Ul" """
    text = CAMELCASE_LU_RE.sub(r"\1 \2", text)
    return CAMELCASE_UL_RE.sub(r"\1 \2", text)


def drop_non_embedded_symbols_fn(text: str, repl: str = "") -> str:
    """Drop symbols not embedded within word characters"""
    return NON_EMBEDDED_WORD_SYMS_RE.sub(repl, text)


def drop_embedded_symbols_fn(text: str, repl: str = "") -> str:
    """Drop symbols embedded within word characters"""
    return EMBEDDED_SYMS_RE.sub(repl, text)


def get_hyphen_slash_expansions_fn(
    text: str,
    subs: List[str] = ("-", " ", ""),
    add_self: bool = True,
    do_split: bool = True,
    min_split_token_len: int = 2,
    hyphen_slash_re=HYPHEN_SLASH_RE,
) -> Set[str]:
    """Given text with words that may or may not appear as hyphenated or with a
    slash, return the set potential variations:
        - the text as-is (add_self)
        - with a hyphen between all words (if '-' in subs)
        - with a space between all words (if ' ' in subs)
        - with all words squashed together (empty string between if '' in subs)
        - with each word separately (do_split as long as min_split_token_len is
              met for all tokens)

    :param text: The hyphen-worthy snippet of text, either already
        hyphenated or with a slash or space delimited.
    :param subs: A string of characters or list of strings to insert between
        tokens
    :param add_self: True to include the text itself in the result
    :param min_split_token_len: If any of the split tokens fail
        to meet the min token length, don't add any of the splits.
    :param hyphen_slash_re: The regex to identify hyphen/slash to expand.

    Notes:
      * To add a variation with a slash, add '/' to subs.
      * To not add any variations with symbols, leave them out of subs
        * and don't add self.
    """
    variations = {text} if add_self else set()
    if subs is not None and len(subs) > 0:
        # create variant with all <s>'s
        for s in subs:
            variations.add(HYPHEN_SLASH_RE.sub(s, text))
    if do_split:
        # add each word separately
        tokens = set(hyphen_slash_re.split(text))
        if not max(map(lambda t: len(t) < min_split_token_len, tokens)):
            variations.update(tokens)
    return variations


def drop_parentheticals_fn(text: str) -> str:
    """Drop parenthetical expressions from the text."""
    return PARENTHETICAL_RE.sub("", text)


def expand_ampersand_fn(text: str) -> str:
    """Replace '&' with ' and '."""
    return AMPERSAND_RE.sub(" and ", text)


def get_lexical_variations(
    text: str,
    include_self: bool = True,
    expand_camelcase: bool = True,
    drop_non_embedded_symbols: bool = True,
    drop_embedded_symbols: bool = True,
    spacify_embedded_symbols: bool = False,
    do_hyphen_expansion: bool = True,
    hyphen_subs: List[str] = (" ", ""),
    do_hyphen_split: bool = True,
    min_hyphen_split_token_len=2,
    do_slash_expansion: bool = True,
    slash_subs: List[str] = (" ", " or "),
    do_slash_split: bool = True,
    min_slash_split_token_len: int = 1,
    drop_parentheticals: bool = True,
    expand_ampersands: bool = True,
    add_eng_plurals: bool = True,
) -> Set[str]:
    """Get all variations for the text (including the text itself)."""
    variations = {text} if include_self else set()
    if expand_camelcase:
        variations.add(expand_camelcase_fn(text))
    if drop_non_embedded_symbols:
        variations.add(drop_non_embedded_symbols_fn(text))
    if drop_embedded_symbols:
        variations.add(drop_embedded_symbols_fn(text))
    if spacify_embedded_symbols:
        variations.add(drop_embedded_symbols_fn(text, " "))
    if (
        do_hyphen_expansion and hyphen_subs is not None and len(hyphen_subs) > 0
    ) or do_hyphen_split:
        variations.update(
            get_hyphen_slash_expansions_fn(
                text,
                subs=hyphen_subs,
                add_self=False,
                do_split=do_hyphen_split,
                min_split_token_len=min_hyphen_split_token_len,
            )
        )
    if (do_slash_expansion and slash_subs is not None and len(slash_subs) > 0) or do_slash_split:
        variations.update(
            get_hyphen_slash_expansions_fn(
                text,
                subs=slash_subs,
                add_self=False,
                do_split=do_slash_split,
                min_split_token_len=min_slash_split_token_len,
            )
        )
    if drop_parentheticals:
        variations.add(drop_parentheticals_fn(text))
    if expand_ampersands:
        variations.add(expand_ampersand_fn(text))
    if add_eng_plurals:
        # TODO: Use a better pluralizer
        plurals = {f"{v}s" for v in variations}
        variations.update(plurals)
    return variations


def int_to_en(num: int) -> str:
    d = {
        0: "zero",
        1: "one",
        2: "two",
        3: "three",
        4: "four",
        5: "five",
        6: "six",
        7: "seven",
        8: "eight",
        9: "nine",
        10: "ten",
        11: "eleven",
        12: "twelve",
        13: "thirteen",
        14: "fourteen",
        15: "fifteen",
        16: "sixteen",
        17: "seventeen",
        18: "eighteen",
        19: "nineteen",
        20: "twenty",
        30: "thirty",
        40: "forty",
        50: "fifty",
        60: "sixty",
        70: "seventy",
        80: "eighty",
        90: "ninety",
    }
    k = 1000
    m = k * 1000
    b = m * 1000
    t = b * 1000

    if not isinstance(num, int):
        return num

    if num < 0:
        return "negative " + int_to_en(abs(num))

    if num < 20:
        return d[num]

    if num < 100:
        if num % 10 == 0:
            return d[num]
        else:
            return d[num // 10 * 10] + " " + d[num % 10]

    if num < k:
        if num % 100 == 0:
            return d[num // 100] + " hundred"
        else:
            return d[num // 100] + " hundred and " + int_to_en(num % 100)

    if num < m:
        if num % k == 0:
            return int_to_en(num // k) + " thousand"
        else:
            return int_to_en(num // k) + " thousand " + int_to_en(num % k)

    if num < b:
        if (num % m) == 0:
            return int_to_en(num // m) + " million"
        else:
            return int_to_en(num // m) + " million " + int_to_en(num % m)

    if num < t:
        if (num % b) == 0:
            return int_to_en(num // b) + " billion"
        else:
            return int_to_en(num // b) + " billion " + int_to_en(num % b)

    if num % t == 0:
        return int_to_en(num // t) + " trillion"
    else:
        return int_to_en(num // t) + " trillion " + int_to_en(num % t)

    # num is too large
    return str(num)


def zero_pad_variations(
    val: int,
    min_zpad_len: int,
    max_zpad_len: int,
) -> Set[str]:
    """Get (only) zero-padded variations of the given value from min (inclusive)
    to max (exclusive) zero-pad lengths.

    Examples:
      * zero_pad_variations(9, 2, 4) == {'09', '009'}
      * zero_pad_variations(90, 2, 4) == {'090'}
      * zero_pad_variations(90, 2, 3) == {}
      * zero_pad_variations(3, 0, 5) == {'03', '003', '0003'}

    :param min_zpad_len: The minimum zero-padded string length (inclusive)
    :param max_zpad_len: The maximum zero-padded string length (exclusive)
    :return: The set of all requested zero-padded number strings
    """
    return {
        f"{val:0{zpad}d}"
        for zpad in range(
            max(min_zpad_len, math.ceil(math.log10(val)) + 1 if val > 0 else 1), max_zpad_len
        )
    }


def month_day_variations_fn(
    month_or_day: int,
    do_int_to_en: bool = False,
) -> Set[str]:
    """Get the variations for a month or day number, including the number
    itself as a string, a 2-digit zero-padded form of the number, and
    (optionally) english word for the number.

    :param month_or_day: The month or day for which to get variations
    :param do_int_to_en: Optionally include the english word for the number
    :return: The set of variations for the value
    """
    result = zero_pad_variations(month_or_day, 2, 3)
    result.add(str(month_or_day))
    if do_int_to_en:
        result.add(int_to_en(month_or_day))
    return result


def year_variations_fn(
    year: int,
    min_year: int = 0,
    max_year: int = 9999,
    do_int_to_en_below_100: bool = False,
    numeric_only: bool = False,
) -> Set[str]:
    """* "1999"
    * Convert a year to english text:
      * Long text: one thousand, nine hundred and ninety nine
      * Short text: nineteen [hundred and] ninety nine
    """
    variations = {str(year)}

    if year < min_year or year > max_year:
        return variations

    # one thousand, nine hundred and ninety nine
    if not numeric_only and (do_int_to_en_below_100 or year >= 100):
        variations.add(int_to_en(year))

    # nineteen ninety five
    century = year // 100
    remainder = year % 100
    remainder_text = int_to_en(remainder)

    variations.update(zero_pad_variations(remainder, 2, 3))

    if century > 0:
        remainder_texts = list()
        if remainder > 0:
            if remainder < 10:
                if not numeric_only:
                    remainder_texts.append(f" oh {remainder_text}")
                remainder_texts.append(f" 0{remainder}")
            else:
                if not numeric_only:
                    remainder_texts.append(f" {remainder_text}")
                remainder_texts.append(f" {remainder}")
            if not numeric_only:
                remainder_texts.append(f" and {remainder_text}")

        century_text = int_to_en(century)
        scales = ["", century_text]
        if century % 10 == 0:
            mil_text = int_to_en(century // 10)
            scales.append(f"{mil_text} thousand")
        else:
            scales.append(f"{century_text} hundred")

        def clean_up(s):
            s = s.strip()
            if s.startswith("and "):
                s = s[4:]
            return s

        variations.update({clean_up("".join(v)) for v in product(scales, remainder_texts)})

    return variations


def replace_smart_quotes_fn(text: str) -> str:
    """Replace "smart" quotes with their ascii version."""
    return (
        text.replace(
            "\u201c",
            '"',  # left double quote U+201C
        )
        .replace(
            "\u201d",
            '"',  # right double quote U+201D
        )
        .replace(
            "\u2018",
            "'",  # left single quote U+2018
        )
        .replace(
            "\u2019",
            "'",  # right single quote U+2019
        )
    )


def basic_normalization_fn(
    text: str,
    lowercase: bool = True,
    expand_camelcase: bool = True,
    simplify_quote_chars: bool = True,
    drop_non_embedded_symbols: bool = False,
    spacify_embedded_symbols: bool = False,
    drop_embedded_symbols: bool = False,
    squash_whitespace: bool = False,
    do_all: bool = False,
) -> str:
    """Basic normalization functions include:
    * lowercasing [default]
    * expanding camelcase [default]
    * replacing "smart" quotes and apostrophes with ascii verisons [default]
    * dropping non_embedded symbols [optional]
    * replacing embedded symbols with a space [takes precedence over dropping unless do_all]
    * or dropping embedded symbols [optional]
    * collapsing multiple spaces and stripping spaces from ends [optional]
    """
    # NOTE: do this before changing case
    if expand_camelcase or do_all:
        text = expand_camelcase_fn(text)

    if lowercase or do_all:
        text = text.lower()
    if (drop_non_embedded_symbols and drop_embedded_symbols) or do_all:
        text = re.sub(r"[^\w\s]+", "", text)
    elif drop_non_embedded_symbols:
        text = drop_non_embedded_symbols_fn(text)
    elif spacify_embedded_symbols:
        text = drop_embedded_symbols_fn(text, " ")
    elif drop_embedded_symbols:
        text = drop_embedded_symbols_fn(text)

    # NOTE: do this after dropping (only some) symbols
    if simplify_quote_chars and (not drop_non_embedded_symbols or not drop_embedded_symbols):
        # NOTE: It only makes sense to do this if we're keeping symbols
        text = replace_smart_quotes_fn(text)

    # NOTE: do this last
    if squash_whitespace or do_all:
        text = re.sub(r"\s+", " ", text).strip()
    return text
