from __future__ import annotations

from typing import Optional, Union

import numeral

from .systems import SYSTEMS, System
from .tones import Tone


class Scale:
    def __init__(self, *, tones: tuple[Tone, ...], degrees: Optional[tuple[str, ...]] = None, system: Union[str, System] = 'western') -> None:
        """Initialize a Scale from a sequence of Tones.

        Args:
            tones: The tones that make up the scale.
            degrees: Optional names for each scale degree (must match length of *tones*).
            system: A tone system name or :class:`System` instance.

        Raises:
            ValueError: If *degrees* is provided but its length differs from *tones*.
        """
        self.tones = tones
        self.degrees = degrees

        if isinstance(system, str):
            self.system_name = system
            self._system = None
        else:
            self.system_name = None
            self._system = system

        if self.degrees:
            if not len(self.tones) == len(self.degrees):
                raise ValueError("The number of tones and degrees must be equal!")

    @property
    def system(self) -> Optional[System]:
        """Return the tone system for this scale.

        Resolves a system name to a :class:`System` object on first access.
        """
        if self._system:
            return self._system

        if self.system_name:
            return SYSTEMS[self.system_name]

    def __repr__(self) -> str:
        r = []
        for (i, tone) in enumerate(self.tones):
            degree = numeral.int2roman(i + 1, only_ascii=True)
            r += [f"{degree}={tone.full_name}"]

        r = " ".join(r)
        return f"<Scale {r}>"

    def __iter__(self):
        """Iterate over the tones in this scale."""
        return iter(self.tones)

    def __len__(self) -> int:
        """Return the number of tones in this scale (including the octave)."""
        return len(self.tones)

    def __contains__(self, item: Union[str, Tone]) -> bool:
        """Check whether a tone or note name belongs to this scale."""
        if isinstance(item, str):
            return any(item == t.name for t in self.tones)
        return item in self.tones

    @property
    def note_names(self) -> list[str]:
        """List of note names in this scale."""
        return [t.name for t in self.tones]

    def chord(self, *degrees: int) -> Chord:
        """Build a Chord from scale degrees (0-indexed).

        Wraps around if degrees exceed the scale length, transposing
        up by an octave as needed.

        Example: scale.chord(0, 2, 4) builds a triad from the 1st, 3rd, 5th.
        """
        from .chords import Chord
        # Scale has N tones where the last is the octave of the first,
        # so the unique tones are tones[:-1] (length N-1).
        unique = len(self.tones) - 1
        result = []
        for d in degrees:
            idx = d % unique
            octaves_up = d // unique
            tone = self.tones[idx]
            if octaves_up > 0:
                tone = tone.add(12 * octaves_up)
            result.append(tone)
        return Chord(tones=result)

    def transpose(self, semitones: int) -> Scale:
        """Return a new Scale transposed by the given number of semitones.

        Every tone is shifted by the same interval, preserving the
        scale's interval pattern.

        Example::

            >>> c_major = TonedScale(tonic="C4")["major"]
            >>> d_major = c_major.transpose(2)
            >>> d_major.note_names
            ['D', 'E', 'F#', 'G', 'A', 'B', 'C#', 'D']
        """
        from .chords import Chord
        new_tones = tuple(t.add(semitones) for t in self.tones)
        return Scale(tones=new_tones)

    def triad(self, root: int = 0) -> Chord:
        """Build a triad starting from the given scale degree (0-indexed).

        Returns a chord with the root, 3rd, and 5th above it.
        """
        return self.chord(root, root + 2, root + 4)

    def seventh(self, root: int = 0) -> Chord:
        """Build a seventh chord from the given scale degree (0-indexed).

        Returns a chord with the root, 3rd, 5th, and 7th.
        """
        return self.chord(root, root + 2, root + 4, root + 6)

    def progression(self, *numerals: str) -> list[Chord]:
        """Build a chord progression from Roman numeral strings.

        Accepts Roman numerals like ``"I"``, ``"IV"``, ``"V"``,
        ``"ii"``, ``"vi"``. Lowercase = minor triad, uppercase = major
        triad. Add ``"7"`` suffix for seventh chords.

        Example::

            >>> scale.progression("I", "IV", "V", "I")
            [<Chord (C,E,G)>, <Chord (F,A,C)>, <Chord (G,B,D)>, <Chord (C,E,G)>]
        """
        import numeral as numeral_mod
        chords = []
        for num in numerals:
            is_seventh = num.endswith("7")
            clean = num.rstrip("7")
            degree = numeral_mod.roman2int(clean.upper()) - 1
            if is_seventh:
                chords.append(self.seventh(degree))
            else:
                chords.append(self.triad(degree))
        return chords

    def nashville(self, *numbers: Union[int, str]) -> list[Chord]:
        """Build a chord progression using Nashville number system.

        The `Nashville number system <https://en.wikipedia.org/wiki/Nashville_Number_System>`_
        uses Arabic numerals instead of Roman numerals.
        It's the standard chart system in Nashville recording studios.

        Numbers 1-7 build diatonic triads. Suffix ``"7"`` for seventh
        chords, ``"m"`` to force minor.

        Example::

            >>> scale.nashville(1, 4, 5, 1)
            [<Chord C major>, <Chord F major>, <Chord G major>, <Chord C major>]
        """
        from .chords import Chord
        chords = []
        for num in numbers:
            s = str(num)
            is_seventh = s.endswith("7")
            clean = s.rstrip("7m")
            degree = int(clean) - 1
            if is_seventh:
                chords.append(self.seventh(degree))
            else:
                chords.append(self.triad(degree))
        return chords

    @staticmethod
    def detect(*note_names: str) -> Optional[tuple[str, str, int]]:
        """Detect the most likely scale from a set of note names.

        Tries all scales in the Western system and returns the best
        match as a ``(tonic, scale_name, match_count)`` tuple.

        Example::

            >>> Scale.detect("C", "D", "E", "F", "G", "A", "B")
            ('C', 'major', 7)
            >>> Scale.detect("C", "D", "Eb", "F", "G", "Ab", "Bb")
            ('C', 'minor', 7)
        """
        if not note_names:
            return None

        notes = set(note_names)
        best = None

        chromatic = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
        scale_names = ["major", "minor", "harmonic minor",
                       "dorian", "phrygian", "lydian", "mixolydian",
                       "aeolian", "locrian"]

        for tonic in chromatic:
            ts = TonedScale(tonic=f"{tonic}4")
            for scale_name in ts.scales:
                try:
                    scale = ts[scale_name]
                    scale_notes = set(scale.note_names)
                    match = len(notes & scale_notes)
                    score = (match, 1 if scale_name == "major" else 0)
                    if best is None or score > best[0]:
                        best = (score, tonic, scale_name, match)
                except (KeyError, ValueError):
                    continue

        if best:
            return (best[1], best[2], best[3])
        return None

    def harmonize(self) -> list[Chord]:
        """Build diatonic triads on every scale degree.

        Returns a list of Chords — one triad for each degree of the
        scale. In a major scale this produces: I, ii, iii, IV, V, vi, vii°.

        Example::

            >>> [c.identify() for c in TonedScale(tonic="C4")["major"].harmonize()]
            ['C major', 'D minor', 'E minor', 'F major', 'G major', 'A minor', 'B diminished']
        """
        unique = len(self.tones) - 1
        return [self.triad(i) for i in range(unique)]

    def degree(self, item: Union[str, int, slice], major: Optional[bool] = None, minor: bool = False) -> Optional[Union[Tone, tuple[Tone, ...]]]:

        # Ensure that both major and minor aren't passed.
        if all((major, minor)):
            raise ValueError("You can only specify one of the following: major, minor.")

        # Roman–style degree reference (e.g. "IV").
        if isinstance(item, str):
            degrees = []
            for (i, tone) in enumerate(self.tones):
                degrees.append(numeral.int2roman(i + 1, only_ascii=True))

            if item in degrees:
                item = degrees.index(item)

            # If we're still a string, attempt to grab degree name.
            if isinstance(item, str):
                for i, _degree in enumerate(self.system.degrees):
                    # Match degree name (e.g. "tonic", "dominant")
                    if item == _degree[0]:
                        item = i
                        break

                    # Also match mode name (e.g. "ionian", "dorian")
                    if item in _degree[1]:
                        item = i
                        break

        # List/Tuple–style reference.
        if isinstance(item, int) or isinstance(item, slice):
            return self.tones[item]

    def __getitem__(self, item: Union[str, int, slice]) -> Union[Tone, tuple[Tone, ...]]:
        """Retrieve a tone by scale degree (integer, Roman numeral, or degree name).

        Raises:
            KeyError: If the given degree is not found in this scale.
        """
        result = self.degree(item)
        if result is None:
            raise KeyError(item)
        return result


PROGRESSIONS = {
    # Rock / Pop / Folk
    "I-IV-V-I": ("I", "IV", "V", "I"),
    "I-V-vi-IV": ("I", "V", "vi", "IV"),
    "I-vi-IV-V": ("I", "vi", "IV", "V"),
    "I-IV-vi-V": ("I", "IV", "vi", "V"),
    "vi-IV-I-V": ("vi", "IV", "I", "V"),
    # Blues
    "12-bar blues": ("I", "I", "I", "I", "IV", "IV", "I", "I", "V", "IV", "I", "V"),
    # Jazz
    "ii-V-I": ("ii", "V7", "I"),
    "I-vi-ii-V": ("I", "vi", "ii", "V"),  # rhythm changes A section
    "iii-vi-ii-V": ("iii", "vi", "ii", "V"),  # jazz turnaround
    # Classical / Film
    "i-bVI-bIII-bVII": ("i", "VI", "III", "VII"),
    "Pachelbel": ("I", "V", "vi", "iii", "IV", "I", "IV", "V"),
    # Flamenco / Spanish
    "Andalusian": ("i", "VII", "VI", "V"),
    # Modal
    "Dorian vamp": ("i", "IV"),
    "Mixolydian vamp": ("I", "VII"),
}
"""Common chord progressions as Roman numeral tuples.

Use with :meth:`Scale.progression` or :meth:`Key.progression`::

    Key("C", "major").progression(*PROGRESSIONS["I-V-vi-IV"])
"""


class Key:
    """A musical key — a convenient entry point for scales and harmony.

    A Key represents a tonic note and a mode. It provides quick access
    to the scale, diatonic chords, and common progressions.

    Example::

        >>> key = Key("C", "major")
        >>> key.scale.note_names
        ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
        >>> key.chords
        ['C major', 'D minor', 'E minor', 'F major', ...]
        >>> key.progression("I", "V", "vi", "IV")
        [<Chord (C,E,G)>, <Chord (G,B,D)>, ...]
    """

    def __init__(self, tonic: str, mode: str = "major", system: Optional[Union[str, System]] = None) -> None:
        if system is None:
            system = SYSTEMS["western"]
        elif isinstance(system, str):
            system = SYSTEMS[system]
        self.tonic_name = tonic
        self.mode = mode
        self._system = system
        self._toned_scale = TonedScale(tonic=f"{tonic}4", system=system)
        self._scale = self._toned_scale[mode]

    @classmethod
    def detect(cls, *note_names: str) -> Optional[Key]:
        """Detect the most likely key from a set of note names.

        Tries every possible major and minor key and returns the one
        whose scale contains the most of the given notes.

        Example::

            >>> Key.detect("C", "D", "E", "F", "G", "A", "B")
            <Key C major>
            >>> Key.detect("A", "B", "C", "D", "E", "F", "G")
            <Key C major>
            >>> Key.detect("A", "C", "E")
            <Key C major>

        Returns:
            The best-matching Key, or None if no notes given.
        """
        if not note_names:
            return None

        notes = set(note_names)
        best_key = None
        best_score = (-1, 0)

        chromatic = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
        for tonic in chromatic:
            for mode in ("major", "minor"):
                try:
                    k = cls(tonic, mode)
                    scale_notes = set(k.note_names)
                    match = len(notes & scale_notes)
                    # Tiebreak: prefer major over minor
                    score = (match, 1 if mode == "major" else 0)
                    if score > best_score:
                        best_score = score
                        best_key = k
                except (KeyError, ValueError):
                    continue

        return best_key

    def __repr__(self) -> str:
        return f"<Key {self.tonic_name} {self.mode}>"

    def __str__(self) -> str:
        return f"{self.tonic_name} {self.mode}"

    @property
    def scale(self) -> Scale:
        """The scale for this key."""
        return self._scale

    @property
    def note_names(self) -> list[str]:
        """Note names in this key's scale."""
        return self._scale.note_names

    @property
    def chords(self) -> list[str]:
        """Names of all diatonic triads in this key."""
        return [c.identify() for c in self._scale.harmonize()]

    @property
    def seventh_chords(self) -> list[str]:
        """Names of all diatonic seventh chords in this key."""
        unique = len(self._scale.tones) - 1
        return [self._scale.seventh(i).identify() for i in range(unique)]

    def triad(self, degree: int) -> Chord:
        """Build a diatonic triad on the given degree (0-indexed)."""
        return self._scale.triad(degree)

    def seventh(self, degree: int) -> Chord:
        """Build a diatonic seventh chord on the given degree (0-indexed)."""
        return self._scale.seventh(degree)

    def progression(self, *numerals: str) -> list[Chord]:
        """Build a chord progression from Roman numerals.

        Example::

            >>> Key("G", "major").progression("I", "IV", "V7", "I")
        """
        return self._scale.progression(*numerals)

    def nashville(self, *numbers: Union[int, str]) -> list[Chord]:
        """Build a chord progression using Nashville numbers.

        Example::

            >>> Key("G", "major").nashville(1, 4, 5, 1)
        """
        return self._scale.nashville(*numbers)

    def secondary_dominant(self, degree: int) -> Chord:
        """Build a secondary dominant (V/x) for the given scale degree.

        A secondary dominant is the dominant chord of a non-tonic
        degree. For example, in C major, V/V is D major (the V chord
        of G). Secondary dominants create momentary tonicizations
        that add color and forward motion.

        Common secondary dominants:

        - V/V (e.g. D7 in C major) — approaches the dominant
        - V/ii (e.g. A7 in C major) — approaches the supertonic
        - V/vi (e.g. E7 in C major) — approaches the relative minor

        Args:
            degree: Scale degree to target (1-indexed). ``5`` means
                "build the V of the 5th degree."

        Returns:
            A dominant 7th Chord that resolves to the given degree.

        Example::

            >>> Key("C", "major").secondary_dominant(5)  # V/V = D7
            <Chord D dominant 7th>
        """
        target = self._scale.tones[degree - 1]
        # Build a dominant 7th a perfect 5th above the target
        from .chords import Chord
        root = target.add(7)
        return Chord(tones=[root, root.add(4), root.add(7), root.add(10)])

    @classmethod
    def all_keys(cls) -> list[Key]:
        """Return all 24 major and minor keys.

        Returns:
            A list of Key objects for all 12 major and 12 minor keys.

        Example::

            >>> for k in Key.all_keys():
            ...     print(k)
        """
        chromatic = ["C", "C#", "D", "D#", "E", "F",
                     "F#", "G", "G#", "A", "A#", "B"]
        keys = []
        for tonic in chromatic:
            keys.append(cls(tonic, "major"))
            keys.append(cls(tonic, "minor"))
        return keys

    @property
    def signature(self) -> dict:
        """The key signature — number and names of sharps or flats.

        In Western music, each key has a unique key signature that tells
        you which notes are sharped or flatted throughout a piece.

        Returns:
            A dict with:
            - ``sharps`` (int): number of sharps (0 if flat key)
            - ``flats`` (int): number of flats (0 if sharp key)
            - ``accidentals`` (list[str]): the sharped/flatted note names

        Example::

            >>> Key("G", "major").signature
            {'sharps': 1, 'flats': 0, 'accidentals': ['F#']}
            >>> Key("F", "major").signature
            {'sharps': 0, 'flats': 1, 'accidentals': ['Bb']}
            >>> Key("C", "major").signature
            {'sharps': 0, 'flats': 0, 'accidentals': []}
        """
        # Compare scale notes against the natural notes C D E F G A B
        naturals = {"C", "D", "E", "F", "G", "A", "B"}
        scale_notes = set(self.note_names[:-1])  # exclude octave

        sharps = [n for n in scale_notes if "#" in n]
        flats = [n for n in scale_notes if "b" in n[1:]]  # skip first char for B

        # Order sharps: F C G D A E B
        sharp_order = ["F#", "C#", "G#", "D#", "A#", "E#", "B#"]
        flat_order = ["Bb", "Eb", "Ab", "Db", "Gb", "Cb", "Fb"]

        sharps_sorted = [s for s in sharp_order if s in sharps]
        flats_sorted = [f for f in flat_order if f in flats]

        if sharps_sorted:
            return {"sharps": len(sharps_sorted), "flats": 0, "accidentals": sharps_sorted}
        elif flats_sorted:
            return {"sharps": 0, "flats": len(flats_sorted), "accidentals": flats_sorted}
        else:
            return {"sharps": 0, "flats": 0, "accidentals": []}

    @property
    def borrowed_chords(self) -> list[str]:
        """Chords borrowed from the parallel key.

        Modal interchange (or modal mixture) borrows chords from the
        parallel major or minor key. In C major, the parallel minor
        is C minor, which provides chords like Ab major, Bb major,
        and Eb major — commonly heard in rock, film, and pop music.

        Returns:
            A list of chord names from the parallel key that are NOT
            in the current key's diatonic chords.

        Example::

            >>> Key("C", "major").borrowed_chords
            ['C minor', 'D diminished', 'D# major', ...]
        """
        par = self.parallel
        if par is None:
            return []
        own = set(self.chords)
        return [c for c in par.chords if c not in own]

    def random_progression(self, length: int = 4) -> list:
        """Generate a random diatonic chord progression.

        Uses weighted probabilities based on common chord function:
        I and vi are most common, IV and V are very common, ii is
        common, iii and viidim are rare. Always starts on I and
        ends on I or V.

        Args:
            length: Number of chords (default 4).

        Returns:
            A list of Chord objects.

        Example::

            >>> Key("C", "major").random_progression(4)
            [<Chord C major>, <Chord F major>, <Chord G major>, <Chord C major>]
        """
        import random

        harmonized = self._scale.harmonize()
        unique = len(harmonized)
        # Weights: I=high, ii=med, iii=low, IV=high, V=high, vi=med, vii=low
        weights = [10, 5, 2, 8, 8, 5, 1]
        if unique < len(weights):
            weights = weights[:unique]

        chords = [harmonized[0]]  # Start on I
        for _ in range(length - 2):
            chords.append(random.choices(harmonized, weights=weights, k=1)[0])
        if length > 1:
            # End on I or V
            chords.append(random.choice([harmonized[0], harmonized[4 % unique]]))
        return chords

    @property
    def relative(self) -> Optional[Key]:
        """The relative major or minor key.

        If this is a major key, returns the relative minor (vi).
        If this is a minor key, returns the relative major (bIII).
        """
        if self.mode == "major":
            # Relative minor starts on the 6th degree
            minor_tonic = self._scale.tones[5].name
            return Key(minor_tonic, "minor")
        elif self.mode in ("minor", "aeolian"):
            # Relative major starts on the 3rd degree
            major_tonic = self._scale.tones[2].name
            return Key(major_tonic, "major")
        return None

    @property
    def parallel(self) -> Optional[Key]:
        """The parallel major or minor key (same tonic, different mode)."""
        if self.mode == "major":
            return Key(self.tonic_name, "minor")
        elif self.mode in ("minor", "aeolian"):
            return Key(self.tonic_name, "major")
        return None


class TonedScale:
    def __init__(self, *, system: Union[str, System] = SYSTEMS["western"], tonic: Union[str, Tone]) -> None:
        """Initialize a TonedScale with a tonic note and tone system.

        Args:
            system: A tone system name or :class:`System` instance.
            tonic: The tonic note as a string (e.g. ``"C4"``) or :class:`Tone`.
        """
        if isinstance(system, str):
            system = SYSTEMS[system]
        self.system = system

        if not isinstance(tonic, Tone):
            tonic = Tone.from_string(tonic, system=self.system)

        self.tonic = tonic
        self._cached_scales: Optional[dict[str, Scale]] = None

    def __repr__(self) -> str:
        return f"<TonedScale system={self.system!r} tonic={self.tonic}>"

    def __getitem__(self, scale: str) -> Scale:
        """Retrieve a scale by name.

        Raises:
            KeyError: If the named scale is not found in this system.
        """
        result = self.get(scale)
        if result is None:
            raise KeyError(scale)
        return result

    def get(self, scale: str) -> Optional[Scale]:
        """Look up a scale by name, returning ``None`` if not found."""
        try:
            return self._scales[scale]
        except KeyError:
            return None

    @property
    def scales(self) -> tuple[str, ...]:
        """Tuple of all available scale names in this system."""
        return tuple(self._scales.keys())

    @property
    def _scales(self) -> dict[str, Scale]:
        """Lazily computed (and cached) mapping of scale names to Scale objects."""
        if self._cached_scales is not None:
            return self._cached_scales

        scales = {}

        for scale_type in self.system.scales:
            for scale in self.system.scales[scale_type]:

                working_scale = []
                reference_scale = self.system.scales[scale_type][scale]["intervals"]

                current_tone = self.tonic
                working_scale.append(current_tone)

                for interval in reference_scale:
                    current_tone = current_tone.add(interval)
                    working_scale.append(current_tone)

                scales[scale] = Scale(tones=tuple(working_scale))

        self._cached_scales = scales
        return scales
