import dataclasses
from typing import List


ONSETS = {
    "b",
    "d",
    "g",
    "gw",
    "z",
    "p",
    "t",
    "k",
    "kw",
    "c",
    "m",
    "n",
    "ng",
    "f",
    "h",
    "s",
    "l",
    "w",
    "j",
    "",
}

NUCLEI = {"aa", "a", "i", "yu", "u", "oe", "e", "eo", "o", "m", "ng"}

CODAS = {"p", "t", "k", "m", "n", "ng", "i", "u", ""}

TONES = {"1", "2", "3", "4", "5", "6"}


@dataclasses.dataclass
class Jyutping:
    """Jyutping representation of a Chinese/Cantonese character.

    Attributes
    ----------
    onset : str
        Onset
    nucleus : str
        Nucleus
    coda : str
        Coda
    tone : str
        Tone
    """

    __slots__ = ("onset", "nucleus", "coda", "tone")
    onset: str
    nucleus: str
    coda: str
    tone: str

    def __str__(self):
        """Combine onset + nucleus + coda + tone."""
        return f"{self.onset}{self.nucleus}{self.coda}{self.tone}"

    def __hash__(self):
        return hash(self.__str__())

    @property
    def final(self):
        """Return the final (= nucleus + coda)."""
        return f"{self.nucleus}{self.coda}"


def parse_jyutping(jp_str) -> List[Jyutping]:
    """Parse Jyutping romanization into onset, nucleus, code, and tone.

    Parameters
    ----------
    jp_str : str
        Jyutping romanization for one or multiple characters.

    Returns
    -------
    List[Jyutping]

    Raises
    ------
    ValueError
        If the Jyutping romanization is illegal (e.g., with unrecognized
        elements).

    Examples
    --------
    >>> parse_jyutping("gwong2dung1waa2")  # 廣東話, Cantonese
    [Jyutping(onset='gw', nucleus='o', coda='ng', tone='2'),
     Jyutping(onset='d', nucleus='u', coda='ng', tone='1'),
     Jyutping(onset='w', nucleus='aa', coda='', tone='2')]
    """
    if not jp_str:
        return []

    # check jp_str as a valid argument string
    if not isinstance(jp_str, str):
        raise ValueError("argument needs to be a string -- " + repr(jp_str))
    jp_str = jp_str.lower()

    # parse jp_str as multiple jp strings
    jp_list = []
    jp_current = ""
    for c in jp_str:
        jp_current = jp_current + c
        if c.isdigit():
            jp_list.append(jp_current)
            jp_current = ""

    if not jp_str[-1].isdigit():
        # TODO: error msg should be "no invalid tone detected" or something?
        raise ValueError("tone error -- " + repr(jp_str[-1]))

    jp_parsed_list = []

    for jp in jp_list:

        if len(jp) < 2:
            raise ValueError(
                "jyutping string has fewer than " "2 characters -- " + repr(jp)
            )

        tone = jp[-1]
        cvc = jp[:-1]

        # tone
        if tone not in TONES:
            raise ValueError("tone error -- " + repr(jp))

        # coda
        if not (cvc[-1] in "ieaouptkmng"):
            raise ValueError("coda error -- " + repr(jp))

        if cvc in ["m", "n", "ng", "i", "e", "aa", "o", "u"]:
            jp_parsed_list.append(Jyutping("", cvc, "", tone))
            continue
        elif cvc[-2:] == "ng":
            coda = "ng"
            cv = cvc[:-2]
        elif (
            (cvc[-1] in "ptkmn")
            or ((cvc[-1] == "i") and (cvc[-2] in "eaou"))
            or ((cvc[-1] == "u") and (cvc[-2] in "ieao"))
        ):
            coda = cvc[-1]
            cv = cvc[:-1]
        else:
            coda = ""
            cv = cvc

        # nucleus, and then onset
        nucleus = ""

        while cv[-1] in "ieaouy":
            nucleus = cv[-1] + nucleus
            cv = cv[:-1]
            if not cv:
                break

        if not nucleus:
            raise ValueError("nucleus error -- " + repr(jp))

        onset = cv

        if onset not in ONSETS:
            raise ValueError("onset error -- " + repr(jp))

        jp_parsed_list.append(Jyutping(onset, nucleus, coda, tone))

    return jp_parsed_list


def _parse_final(final):
    """Parse a final into its nucleus and coda.

    Parameters
    ----------
    final : str

    Returns
    -------
    tuple[str]
    """
    for i in range(1, len(final) + 1):
        possible_nucleus = final[:i]
        possible_coda = final[i:]

        if (possible_nucleus in NUCLEI) and (possible_coda in CODAS):
            return possible_nucleus, possible_coda
    return None
