import re
from functools import cache
from typing import Literal, Optional, Sequence, Tuple, Union, overload


def parse_interval(interval_string: str) -> Tuple[int, int, int]:
    """
    Parse a string representing a spelled interval. Returns a tuple (sign, octave, fifths), where
    sign is +1 or -1 and indicates the sign given in the string (no sign means positive), octave indicates the
    number of full octave steps (in positive or negative direction), and fifths
    indicates the steps taken along the line of fifths (i.e. not actual fifth steps that would add to the octaves).
    """
    if not isinstance(interval_string, str):
        raise TypeError(f"expected string as input, got {type(interval_string).__name__}")
    match = re.match(
        "^(?P<sign>[-+])?("
        "(?P<quality0>P)(?P<generic0>[145])|"  # perfect intervals
        "(?P<quality1>|([Mm]))(?P<generic1>[2367])|"  # imperfect intervals
        "(?P<quality2>(a+|d+))(?P<generic2>[1-7])"  # augmeted/diminished intervals
        ")(?P<octave>(:-?[0-9]+)?)$",
        interval_string,
    )
    if match is None:
        raise ValueError(f"{interval_string!r} is not a valid interval string.")
    # get quality and generic interval (first corresponding group that is not None)
    for i in range(3):
        g = match[f"generic{i}"]
        q = match[f"quality{i}"]
        if g is not None and q is not None:
            generic = int(g)
            quality = q
            break
    else:
        raise RuntimeError(
            f"Could not match generic interval and quality, this is a bug in the regex ("
            f"{[match[f'generic{i}'] for i in range(3)]}, "
            f"{[match[f'quality{i}'] for i in range(3)]}"
            f")"
        )
    # initialise value with generic interval classes
    fifths = (2 * generic - 1) % 7 - 1
    # add modifiers
    if quality in ["P", "M"]:
        pass
    elif quality == "m":
        fifths -= 7
    elif "a" in quality:
        fifths += 7 * len(quality)
    elif "d" in quality:
        if generic in [4, 1, 5]:
            fifths -= 7 * len(quality)
        else:
            fifths -= 7 * (len(quality) + 1)
    else:
        raise RuntimeError(
            f"Initialization from string failed: "
            f"Unexpected interval quality '{quality}'. This is a bug and "
            f"means that either the used regex is bad or the handling code."
        )
    # get octave
    if match["octave"][1:] == "":
        octave = 0
    else:
        octave = int(match["octave"][1:])
    if match["sign"] == "-":
        sign = -1
    else:
        sign = 1
    return sign, fifths, octave


@overload
def split_note_name(note_name: str, count: Literal[False]) -> Tuple[str, str]: ...


@overload
def split_note_name(note_name: str, count: Literal[True]) -> Tuple[int, str]: ...


def split_note_name(note_name: str, count: bool = False) -> Union[Tuple[int, str], Tuple[str, str]]:
    """Splits a note name such as 'Ab' into accidentals and name.

    Args:
        note_name: Note name.
        count: Pass True to get the accidentals as integer rather than as string.
    """
    assert isinstance(note_name, str), f"'{note_name}' is not an accepted note name."
    m = re.match(r"^([A-G]|[a-g])(#*|b*)$", note_name)
    assert m is not None, f"{note_name} is not a valid note name."
    note_name, accidentals = m.group(1), m.group(2)
    if count:
        accidentals = accidentals.count("#") - accidentals.count("b")
    return accidentals, note_name


def split_pitch_name(pitch_name: str) -> Tuple[str, int]:
    """Splits a pitch name such as 'Ab4' into the pitch class name and octave."""
    pitch_name = pitch_name.replace("\u266d", "b")
    pitch_name = pitch_name.replace("\u266f", "#")
    match = re.match(r"(?P<class>[A-Ga-g](?:b*|#*))(?P<octave>-?\d+)", pitch_name)
    if match is None:
        raise ValueError(f"Invalid pitch string: {pitch_name}")
    pitch_class, octave = match["class"], int(match["octave"])
    return pitch_class, octave


def _fifths2str(fifths: int, steps: Sequence[str], inverted: bool = False) -> str:
    """Boilerplate used by fifths2-functions.

    Args:
        fifths: Fifth to be converted to a string.
        steps: List of seven strings.
        inverted: By default, the accidentals are appended. Pass True to prepend them instead.
    """
    fifths += 1
    acc = abs(fifths // 7) * "b" if fifths < 0 else fifths // 7 * "#"
    if inverted:
        return steps[fifths % 7] + acc
    return acc + steps[fifths % 7]


@cache
def fifths2note_name(fifths: int) -> str:
    """Return note name of a stack of fifths such that
       0 = C, -1 = F, -2 = Bb, 1 = G etc.
       Uses: _fifths2str()

    Args:
        fifths: Fifths to be turned into a note name.
    """
    note_names = ["F", "C", "G", "D", "A", "E", "B"]
    name = _fifths2str(fifths, note_names, inverted=True)
    return name


@cache
def steps2note_name(steps: int) -> str:
    """Return note name for a step value such that
       0 = C, 1 = D, 2 = E, etc.

    Args:
        steps: Step [0-6] to be turned into a note name.
    """
    note_names = ["C", "D", "E", "F", "G", "A", "B"]
    return note_names[steps]


def fifths2interval_name(
    fifths: int,
    smallest: bool = False,
    perfect: str = "P",
    major: str = "M",
    minor: str = "m",
    augmented: str = "a",
    diminished: str = "d",
) -> str:
    """Return interval name of a stack of fifths such that 0 = 'P1', -1 = 'P4', -2 = 'm7', 4 = 'M3' etc. If you pass
    ``smallest=True``, intervals of a fifth or greater will be inverted (e.g. 'm6' => '-M3' and 'D5' => '-A4').


    Args:
        fifths: Number of fifths representing the interval
        smallest: Pass True if you want to wrap intervals of a fifths and larger to the downward counterpart.
        perfect: String representing the perfect interval quality, defaults to 'P'.
        major: String representing the major interval quality, defaults to 'M'.
        minor: String representing the minor interval quality, defaults to 'm'.
        augmented: String representing the augmented interval quality, defaults to 'a'.
        diminished: String representing the diminished interval quality, defaults to 'd'.

    Returns:
        Name of the interval as a string.
    """
    fifths_plus_one = fifths + 1  # making 0 = fourth, 1 = unison, 2 = fifth etc.
    int_num = ["4", "1", "5", "2", "6", "3", "7"][fifths_plus_one % 7]
    sharp_wise_quality = augmented
    flat_wise_quality = diminished
    qualities = (
        minor,
        minor,
        minor,
        minor,
        perfect,
        perfect,
        perfect,
        major,
        major,
        major,
        major,
    )
    quality = ""
    if smallest and int(int_num) > 4:
        int_num = str(9 - int(int_num))
        sharp_wise_quality, flat_wise_quality = flat_wise_quality, sharp_wise_quality
        qualities = tuple(reversed(qualities))
        quality = "-"
    if -5 <= fifths <= 5:
        quality += qualities[fifths + 5]
    elif fifths > 5:
        quality += sharp_wise_quality * (fifths_plus_one // 7)
    else:
        quality += flat_wise_quality * ((-fifths + 1) // 7)
    return quality + int_num


def fifths2semitones(fifths: int) -> int:
    """Return the number of semitones in a stack of fifths such that
       C (0) -> 0, G (1) -> 7, D (2) -> 2 etc.

    Args:
        fifths: Fifths to be turned into a number of semitones.
    """
    return fifths * 7 % 12


def fifths2steps(fifths: int) -> int:
    """Return the number of steps in a stack of fifths such that
       C (0) -> 0, D (2) -> 1, E (4) -> 2 etc.

    Args:
        fifths: Fifths to be turned into a number of steps.
    """
    return fifths * 4 % 7


def note_name2fifths(nn):
    """Turn a note name such as `Ab` into a tonal pitch class, such that -1=F, 0=C, 1=G etc.
    Uses: split_note_name()
    """
    name_tpcs = {"C": 0, "D": 2, "E": 4, "F": -1, "G": 1, "A": 3, "B": 5}
    accidentals, note_name = split_note_name(nn, count=True)
    if note_name is None:
        return None
    step_tpc = name_tpcs[note_name.upper()]
    return step_tpc + 7 * accidentals


def note_name2step(note_name) -> Optional[int]:
    """Turn a note name such as `A` into a step, such that C=0, D=1, E=2 etc."""
    name_steps = {"C": 0, "D": 1, "E": 2, "F": 3, "G": 4, "A": 5, "B": 6}
    _, step_name = split_note_name(note_name, count=False)
    if step_name is None:
        return None
    return name_steps[step_name.upper()]


def parse_generic_pitch(pitch_string: str) -> Tuple[int, int]:
    try:
        pitch_class, octave = split_pitch_name(pitch_string)
    except ValueError:
        if pitch_string.startswith("g"):
            pitch_class, octave = split_pitch_name(pitch_string[1:])
        else:
            raise
    step = note_name2step(pitch_class)
    if step is None:
        raise ValueError(f"Invalid pitch string: {pitch_string}")
    return step, octave


def name2semitones(name: str) -> int:
    """Turns an interval or note name into a number of semitones.

    Raises:
        ValueError
    """
    note_name_match = re.match(r"^[A-Ga-g][#b]*$", name)
    interval_name_match = re.match(r"^(P|M|m|a+|d+)(\d+)$", name)
    midi_value_match = re.match(r"^\d+$", name)

    if interval_name_match and not note_name_match and not midi_value_match:
        quality, number = interval_name_match.groups()
        number = int(number)

        semitones_map = {1: 0, 2: 2, 3: 4, 4: 5, 5: 7, 6: 9, 7: 11, 8: 12}

        base_semitones = semitones_map[number]

        if "m" in quality:  # Minor
            base_semitones -= 1
        elif "a" in quality:  # Augmented
            base_semitones += quality.count("a")
        elif "d" in quality:  # Diminished
            base_semitones -= quality.count("d")

        return base_semitones
    elif note_name_match and not interval_name_match and not midi_value_match:
        note_name_tpcs = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
        accidentals, note_name = split_note_name(name, count=True)
        step_tpc = note_name_tpcs[note_name.upper()]

        return (step_tpc + accidentals) % 12
    elif midi_value_match and not note_name_match and not interval_name_match:
        return int(name) % 12
    else:
        raise ValueError(f"'{name}' does not match known patterns for notes or intervals.")


def name2fifths(name: str) -> int:
    """Turns an interval or note name into a number of fifths.

    Raises:
        ValueError
    """
    note_name_match = re.match(r"^[A-Ga-g][#b]*$", name)
    interval_name_match = re.match(r"^(P|M|m|a+|d+)(\d+)$", name)
    int_string_match = re.match(r"^\d+$", name)

    if interval_name_match and not note_name_match and not int_string_match:
        quality, int_num = interval_name_match.group(1), int(interval_name_match.group(2))
        assert int_num > 0, f"'{name}': the interval needs to be non-zero."
        fifths_base = (2 * int_num - 1) % 7 - 1  # base interval class, i.e. P or M version, as fifths
        if quality == "P":
            assert fifths_base in (
                -1,
                0,
                1,
            ), f"'{name}': Only 4ths, 5ths and unisons can be P"
        if quality == "M":
            assert fifths_base in (
                2,
                3,
                4,
                5,
            ), f"'{name}': Only 2nds, 3rds, 6ths, and 7ths can be M"
        elif quality == "m":
            assert fifths_base in (
                2,
                3,
                4,
                5,
            ), f"'{name}': Only 2nds, 3rds, 6ths, and 7ths can be m"
            fifths_base -= 7
        elif "a" in quality:
            fifths_base += 7 * len(quality)
        elif "d" in quality:
            if fifths_base in (-1, 0, 1):
                fifths_base -= 7 * (len(quality))
            else:
                fifths_base -= 7 * (len(quality) + 1)
        return fifths_base
    elif note_name_match and not interval_name_match and not int_string_match:
        note_name_tpcs = {"C": 0, "D": 2, "E": 4, "F": -1, "G": 1, "A": 3, "B": 5}
        accidentals, note_name = split_note_name(name, count=True)
        step_tpc = note_name_tpcs[note_name.upper()]

        return step_tpc + 7 * accidentals
    elif int_string_match and not note_name_match and not interval_name_match:
        return int(name)
    else:
        raise ValueError(f"'{name}' does not match known patterns for notes or intervals.")


def name2steps(name: str) -> int:
    if name.isdigit():
        return int(name) % 7

    note_name_match = re.match(r"^[A-Ga-g][#b]*$", name)
    interval_name_match = re.match(r"^(P|M|m|a+|d+)(\d+)$", name)

    note_name_tpcs = {"C": 0, "D": 1, "E": 2, "F": 3, "G": 4, "A": 5, "B": 6}
    interval_steps = {1: 0, 2: 2, 3: 4, 4: 5, 5: 7, 6: 9, 7: 11}

    if note_name_match:
        note_name = re.sub(r"[#b]+", "", name)
        step_tpc = note_name_tpcs[note_name.upper()]
        return step_tpc
    elif interval_name_match:
        quality, number = interval_name_match.groups()
        number = int(number)

        if number not in interval_steps:
            raise ValueError(f"Interval number '{number}' is not valid.")

        steps = interval_steps[number] % 7

        return steps
    else:
        raise ValueError(f"'{name}' does not match known patterns for notes, intervals, or direct step values.")


def semitone_difference(s1, octave1, s2, octave2) -> int:
    """
    Returns the semitone difference between 2 objects

    Args:
        s1: Semitones of the first object.
        octave1: Octave of the first object.
        s2: Semitones of the second object.
        octave2: Octave of the second object.
    """
    sd = s1 - s2 + (octave1 - octave2) * 12
    return sd


def step_difference(s1, octave1, s2, octave2) -> int:
    """
    Returns the step difference between 2 objects

    Args:
        s1: Steps of the first object.
        octave1: Octave of the first object.
        s2: Steps of the second object.
        octave2: Octave of the second object.
    """
    sd = s1 - s2 + (octave1 - octave2) * 7
    return sd


def interval_tuple_from_sd(sd, factor) -> Tuple[int, int]:
    """
    Returns an interval tuple from steps or semitones in the subtraction operation

    Args:
        sd: Steps or semitones of the interval.
        factor: Factor to be used in the modulo operation (7 for steps, 12 for semitones).
    """
    return sd % factor, sd // factor


def general_tuple(s1, octave1, s2, octave2, factor, sign) -> Tuple[int, int]:
    """
    Returns a pitch tuple from steps or semitones

    Args:
        s1: Steps or semitones of the first object.
        octave1: Octave of the first object.
        s2: Steps or semitones of the second object.
        octave2: Octave of the second object.
        factor: Factor to be used in the modulo operation.
        sign: Sign to be used in the octave calculation (addition or subtraction).
    """
    return (s1 + sign * s2) % factor, octave1 + sign * octave2 + (s1 + sign * s2) // factor


def specific_tuple(fifths1, s1, octave1, fifths2, s2, octave2, sign) -> Tuple[int, int]:
    """
    Returns a tuple from specific types

    Args:
        fifths1: Fifths of the first object.
        s1: Steps or semitones of the first object.
        octave1: Octave of the first object.
        fifths2: Fifths of the second object.
        s2: Steps or semitones of the second object.
        octave2: Octave of the second object.
        sign: Sign to be used in the octave calculation (addition or subtraction).
    """
    return fifths1 + sign * fifths2, octave1 + sign * octave2 + (s1 + sign * s2) // 12
