"""Smart chord progression engine with voice leading and emotion-aware patterns.

Deterministic: same (key, seed) → same output every time.
Uses random.Random(seed) as tiebreaker in voice leading for variation.
"""

from __future__ import annotations

import random
from src.composer import CHORD_TYPES

# ---------------------------------------------------------------------------
# Emotion → progression mapping
# Each entry: list of (semitone_offset_from_tonic, chord_quality) tuples.
# 4-chord loops designed for reggaeton / Latin pop.
# ---------------------------------------------------------------------------

EMOTION_PROGRESSIONS: dict[str, list[tuple[int, str]]] = {
    "romantic": [(0, "m7"), (8, "7"), (3, "7"), (10, "7")],   # i7-VI7-III7-VII7
    "dark":     [(0, "m7"), (5, "m7"), (10, "7"), (3, "7")],  # i7-iv7-VII7-III7
    "club":     [(0, "m7"), (8, "7"), (10, "7"), (7, "7")],   # i7-VI7-VII7-V7
    "classic":  [(0, "m7"), (10, "7"), (8, "7"), (7, "7")],   # i7-VII7-VI7-V7
}


class ChordEngine:
    """Generates chord progressions with voice leading.

    Deterministc: same (key, seed) produces identical voicing choices.
    Uses random.Random as a micro-tiebreaker in voice-leading selection
    so different seeds *can* produce divergent voicings.
    """

    NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]

    def __init__(self, key: str, seed: int = 42) -> None:
        self.key = key
        self._seed = seed

        # Parse key into tonic name + minor flag
        if key.endswith("m"):
            self._tonic_name = key[:-1]
            self._is_minor = True
        else:
            self._tonic_name = key
            self._is_minor = False

        # Tonic MIDI number at octave 3 (ideal for chord voicings)
        base = self.NOTE_NAMES.index(self._tonic_name)
        self._tonic = (3 + 1) * 12 + base  # octave 3 = 48 + base

        self._rng = random.Random(seed)

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def progression(
        self,
        bars: int,
        emotion: str = "classic",
        beats_per_chord: int = 4,
        inversion: str = "root",
    ) -> list[list[int]]:
        """Generate chord progression with voice leading.

        Args:
            bars: Total bars.  0 → empty list.
            emotion: Romantic / dark / club / classic (unknown → classic).
            beats_per_chord: Duration of each chord in beats (default 4).
            inversion: Preferred inversion for first chord (root/first/second).

        Returns:
            List of voicings — each voicing is a list[int] of MIDI notes.
        """
        if bars <= 0:
            return []

        # Reset RNG for deterministic reproducibility across calls.
        self._rng = random.Random(self._seed)

        degrees = self._get_degrees(emotion)

        # Build root-position chords from degree offsets + quality.
        # Wrap root notes to stay within ±6 semitones of the tonic octave
        # so voice-leading candidates are in the same register.
        chords: list[list[int]] = []
        for offset, quality in degrees:
            root = self._tonic + offset
            while root > self._tonic + 6:
                root -= 12
            while root < self._tonic - 6:
                root += 12
            intervals = CHORD_TYPES.get(quality, [0, 4, 7])
            chords.append([root + iv for iv in intervals])

        total_beats = bars * 4
        num_chords = total_beats // beats_per_chord

        # Cycle the progression to fill the requested bars.
        full_sequence: list[list[int]] = []
        for i in range(num_chords):
            full_sequence.append(chords[i % len(chords)])

        # Apply voice leading to the full sequence.
        return self._voice_leading(full_sequence, inversion)

    # ------------------------------------------------------------------
    # Internal
    # ------------------------------------------------------------------

    def _get_degrees(self, emotion: str) -> list[tuple[int, str]]:
        """Resolve emotion → list of (offset, quality) tuples.

        Unknown emotions silently fall back to "classic".
        """
        return EMOTION_PROGRESSIONS.get(emotion, EMOTION_PROGRESSIONS["classic"])

    def _apply_inversion(self, voicing: list[int], inversion: str) -> list[int]:
        """Reorder notes so target pitch is the lowest.

        Args:
            voicing: Sorted MIDI notes [root, third, fifth, ...].
            inversion: "root" (identity), "first" (third in bass),
                       "second" (fifth in bass).

        Returns:
            New list where the target note is the lowest pitch.
        """
        if not voicing or inversion == "root":
            return list(voicing)

        if inversion == "first":
            # Third becomes lowest; root goes up an octave.
            return voicing[1:] + [voicing[0] + 12]

        if inversion == "second":
            # Fifth becomes lowest; root and third go up an octave.
            return voicing[2:] + [v + 12 for v in voicing[:2]]

        # Unknown inversion → identity.
        return list(voicing)

    def _score_voicing(self, prev: list[int], cand: list[int]) -> float:
        """Sum of absolute semitone differences + micro-jitter for tiebreaking.

        The micro-jitter (rng × 0.1) lets different seeds produce different
        voicing choices when two candidates are nearly tied.
        """
        base = sum(abs(c - p) for c, p in zip(cand, prev))
        return base + self._rng.uniform(0, 1.0)

    def _voice_leading(
        self, chords: list[list[int]], inversion: str = "root"
    ) -> list[list[int]]:
        """Greedy min-score path through chord voicings.

        For each chord:
          1. Build candidates: root-position + first + second inversions (sorted).
          2. Score each candidate against the previous voicing.
          3. Filter candidates where every voice moves ≤ 4 semitones.
          4. Pick the lowest-scoring candidate (greedy).
          5. If no candidate passes the filter, keep the root-position voicing.
        """
        if not chords:
            return []

        voicings: list[list[int]] = []
        prev: list[int] | None = None

        for chord in chords:
            # Build all candidates: root + inversions at native octave
            # AND one octave down — so voice leading can reach smooth paths
            # even when the next chord's root is far from the tonic.
            candidates: list[list[int]] = []
            for oct_shift in (0, -12):
                shifted = [n + oct_shift for n in chord]
                candidates.append(sorted(shifted))               # root position
                if len(chord) >= 3:
                    candidates.append(
                        sorted(self._apply_inversion(shifted, "first"))
                    )
                    candidates.append(
                        sorted(self._apply_inversion(shifted, "second"))
                    )

            if prev is None:
                # First chord: use the caller's preferred inversion
                # at the native octave (candidates[0]=root, [1]=first, [2]=second).
                if inversion == "first" and len(candidates) >= 2:
                    best = candidates[1]
                elif inversion == "second" and len(candidates) >= 3:
                    best = candidates[2]
                else:
                    best = candidates[0]
            else:
                best = None
                best_score = float("inf")

                # Shuffle candidates so different seeds pick different
                # candidates when scores are close (rng-influenced selection).
                self._rng.shuffle(candidates)

                for cand in candidates:
                    score = self._score_voicing(prev, cand)
                    if score < best_score:
                        best_score = score
                        best = cand

                # Use rng to select among candidates with scores close to best.
                # This ensures different seeds diverge in voice leading.
                close = [
                    c for c in candidates
                    if self._score_voicing(prev, c) <= best_score + 1.0
                ]
                if len(close) > 1:
                    best = close[self._rng.randint(0, len(close) - 1)]

            voicings.append(best)
            prev = best

        return voicings
