"""Tools for generating accompaniment audio from Score objects.

Provides a pluggable ``score_to_wav`` engine architecture.  The default engine
uses MMA (Musical MIDI Accompaniment), but any callable with the
:data:`ScoreToWav` signature can be swapped in — including engines built from
accompy's own converter pipeline.

Quick start::

    >>> from accompy import Score, generate_wav
    >>> score = Score.from_string("| Dm7 | G7 | C^7 |")
    >>> generate_wav(score, "/tmp/test.wav", groove="BossaNova", tempo=140)  # doctest: +SKIP

Using an alternative engine::

    >>> from accompy.tools import make_converter_engine, generate_wav
    >>> engine = make_converter_engine(resolver="pychord", midi_gen="pretty_midi",
    ...                                 audio_renderer="fluidsynth")
    >>> generate_wav(score, "/tmp/test.wav", score_to_wav=engine, tempo=140)  # doctest: +SKIP
"""

from __future__ import annotations

import os
import subprocess
import tempfile
from pathlib import Path
from typing import Callable, Optional, Protocol, Sequence, Union

from .base import Score


# ---------------------------------------------------------------------------
# ScoreToWav protocol — the pluggable engine interface
# ---------------------------------------------------------------------------


class ScoreToWav(Protocol):
    """Callable that renders a :class:`Score` to a WAV file.

    Any function matching this signature can be used as a ``score_to_wav``
    engine in :func:`generate_wav` and :func:`generate_variations`.
    """

    def __call__(
        self,
        score: Score,
        output_path: Union[str, Path],
        *,
        groove: str = "Swing",
        tempo: int = 120,
        repeats: int = 1,
    ) -> Path: ...


# ---------------------------------------------------------------------------
# MMA engine (default)
# ---------------------------------------------------------------------------


def mma_score_to_wav(
    score: Score,
    output_path: Union[str, Path],
    *,
    groove: str = "Swing",
    tempo: int = 120,
    repeats: int = 1,
) -> Path:
    """Render a Score to WAV using MMA (Musical MIDI Accompaniment).

    Accepts any valid MMA groove name (e.g. ``"Bebop"``, ``"GypsyJazz"``).
    Run ``mma -Dg`` to list available grooves.

    Args:
        score: A :class:`Score` with chord measures.
        output_path: Destination WAV file path.
        groove: MMA groove name (case-sensitive).
        tempo: Tempo in BPM.
        repeats: Number of times to repeat the progression.

    Returns:
        Path to the generated WAV file.

    Raises:
        RuntimeError: If MMA is not installed or the groove is invalid.
    """
    from accompy.main import _find_mma, _ensure_mma_grooves, _ireal_chord_to_mma
    from accompy.synthesis.fluidsynth import FluidSynthBackend

    _ensure_mma_grooves()
    mma_cmd = _find_mma()

    # Build MMA content
    lines = [
        f"// Generated by accompy",
        f"// {score.title}",
        f"Tempo {tempo}",
        f"Groove {groove}",
        "",
    ]
    bar_num = 1
    for _ in range(max(1, repeats)):
        for measure in score.measures:
            chords_mma = " ".join(_ireal_chord_to_mma(c) for c in measure)
            lines.append(f"{bar_num} {chords_mma}")
            bar_num += 1

    mma_content = "\n".join(lines)

    # Write MMA file and run MMA → MIDI
    mma_path = Path(tempfile.mktemp(suffix=".mma"))
    midi_path = mma_path.with_suffix(".mid")
    output_path = Path(output_path)

    try:
        mma_path.write_text(mma_content)
        subprocess.run(
            [mma_cmd, str(mma_path), "-f", str(midi_path)],
            capture_output=True,
            text=True,
            check=True,
        )
        # Render MIDI → WAV
        backend = FluidSynthBackend()
        backend.render_to_file(midi_path, output_path)
        return output_path
    except subprocess.CalledProcessError as e:
        error_msg = (e.stderr or e.stdout or "").strip()
        raise RuntimeError(f"MMA failed (groove={groove!r}): {error_msg}") from e
    except FileNotFoundError:
        raise RuntimeError("MMA not found. Install from https://www.mellowood.ca/mma/")
    finally:
        mma_path.unlink(missing_ok=True)
        midi_path.unlink(missing_ok=True)


# ---------------------------------------------------------------------------
# Converter-pipeline engine factory
# ---------------------------------------------------------------------------


def make_converter_engine(
    *,
    resolver: Optional[str] = None,
    midi_gen: Optional[str] = None,
    audio_renderer: Optional[str] = None,
    soundfont: Optional[str] = None,
    sr: int = 44100,
) -> ScoreToWav:
    """Create a :data:`ScoreToWav` engine from accompy's converter pipeline.

    This wraps the ``ChordSequence → NoteSequence → MidiData → AudioData``
    converter chain into the same interface that :func:`generate_wav` expects,
    so you can swap it in place of the default MMA engine.

    Args:
        resolver: Chord resolver name (e.g. ``"pychord"``, ``"tonal"``).
        midi_gen: MIDI generator name (e.g. ``"pretty_midi"``, ``"midiutil"``).
        audio_renderer: Audio renderer name (e.g. ``"fluidsynth"``, ``"pretty_midi"``).
        soundfont: Path to a SoundFont file (for FluidSynth-based renderers).
        sr: Sample rate.

    Returns:
        A callable matching the :data:`ScoreToWav` protocol.

    Example::

        >>> engine = make_converter_engine(audio_renderer="fluidsynth")  # doctest: +SKIP
        >>> generate_wav(score, "/tmp/out.wav", score_to_wav=engine, tempo=120)  # doctest: +SKIP
    """
    from .pipeline import chords_to_audio
    from .converters import ChordSequence

    def _converter_score_to_wav(
        score: Score,
        output_path: Union[str, Path],
        *,
        groove: str = "Swing",
        tempo: int = 120,
        repeats: int = 1,
    ) -> Path:
        output_path = Path(output_path)

        # Convert Score measures to a ChordSequence
        all_chords = []
        for _ in range(max(1, repeats)):
            for measure in score.measures:
                # Each measure may have multiple chords; give equal beats
                beats_per_chord = 4.0 / len(measure) if measure else 4.0
                for chord in measure:
                    all_chords.append((chord, beats_per_chord))

        cs = ChordSequence(
            chords=all_chords,
            title=score.title,
            key=score.key or "C",
            tempo=tempo,
            time_signature=score.time_signature
            if hasattr(score, "time_signature") and score.time_signature
            else (4, 4),
        )

        audio = chords_to_audio(
            cs,
            resolver=resolver,
            midi_gen=midi_gen,
            audio_renderer=audio_renderer,
            soundfont=soundfont,
            tempo=tempo,
            sr=sr,
            output_path=str(output_path),
        )
        return output_path

    # Give it a descriptive name for debugging
    parts = [resolver or "default", midi_gen or "default", audio_renderer or "default"]
    _converter_score_to_wav.__name__ = f"converter_engine({'+'.join(parts)})"
    _converter_score_to_wav.__qualname__ = _converter_score_to_wav.__name__

    return _converter_score_to_wav


# ---------------------------------------------------------------------------
# Main public API
# ---------------------------------------------------------------------------

# Sentinel for "use the default engine"
_DFLT_ENGINE = mma_score_to_wav


def generate_wav(
    score: Score,
    output_path: Union[str, Path],
    *,
    groove: str = "Swing",
    tempo: int = 120,
    repeats: int = 1,
    score_to_wav: Optional[ScoreToWav] = None,
) -> Path:
    """Generate a WAV file from a Score using a pluggable engine.

    By default uses MMA (Musical MIDI Accompaniment).  Pass a custom
    ``score_to_wav`` callable to use a different backend — for instance one
    built with :func:`make_converter_engine`.

    Args:
        score: A :class:`Score` with chord measures.
        output_path: Destination WAV file path.
        groove: Groove / style name (interpretation depends on the engine).
        tempo: Tempo in BPM.
        repeats: Number of times to repeat the progression.
        score_to_wav: Engine callable.  Defaults to :func:`mma_score_to_wav`.

    Returns:
        Path to the generated WAV file.

    Example::

        >>> from accompy import Score, generate_wav
        >>> score = Score.from_string("| Dm7 | G7 | C^7 |")
        >>> generate_wav(score, "/tmp/test.wav", groove="BossaNova", tempo=140)  # doctest: +SKIP
        PosixPath('/tmp/test.wav')

        >>> # With a custom engine:
        >>> from accompy.tools import make_converter_engine
        >>> engine = make_converter_engine(audio_renderer="fluidsynth")
        >>> generate_wav(score, "/tmp/test.wav", score_to_wav=engine, tempo=140)  # doctest: +SKIP
    """
    engine = score_to_wav or _DFLT_ENGINE
    return engine(score, output_path, groove=groove, tempo=tempo, repeats=repeats)


# Backward-compatible alias
generate_mma_wav = mma_score_to_wav
"""Alias for :func:`mma_score_to_wav`.  Kept for backward compatibility."""


# ---------------------------------------------------------------------------
# Batch generation
# ---------------------------------------------------------------------------


def generate_variations(
    score: Score,
    output_dir: Union[str, Path],
    *,
    keys: Sequence[str] = ("C",),
    tempos: Sequence[int] = (120,),
    grooves: Sequence[str] = ("Swing",),
    repeats: int = 1,
    filename_template: str = "{title}_{key}_{tempo}bpm_{groove}.wav",
    score_to_wav: Optional[ScoreToWav] = None,
) -> list[Path]:
    """Batch-generate WAV files for many key / tempo / groove combinations.

    Produces one WAV for each ``(key, tempo, groove)`` triple (cycled from the
    shortest sequences).  Useful for creating practice backing-track
    collections.

    Args:
        score: Base :class:`Score` (will be transposed per key).
        output_dir: Directory for output files.
        keys: Keys to transpose to.
        tempos: Tempos in BPM to cycle through.
        grooves: Groove / style names to cycle through.
        repeats: Number of repeats per file.
        filename_template: Template for filenames. Placeholders:
            ``{title}``, ``{key}``, ``{tempo}``, ``{groove}``.
        score_to_wav: Engine callable.  Defaults to :func:`mma_score_to_wav`.

    Returns:
        List of Paths to successfully generated files.

    Example::

        >>> from accompy import Score, generate_variations  # doctest: +SKIP
        >>> score = Score.from_string("| C6 | Do | C6/E | Fo |", key="C")
        >>> generate_variations(  # doctest: +SKIP
        ...     score, "/tmp/variations",
        ...     keys=["C", "G", "F"],
        ...     tempos=[100, 120],
        ...     grooves=["Swing", "BossaNova"],
        ... )

        >>> # With a custom engine:
        >>> from accompy.tools import make_converter_engine
        >>> engine = make_converter_engine(audio_renderer="fluidsynth")
        >>> generate_variations(  # doctest: +SKIP
        ...     score, "/tmp/variations",
        ...     keys=["C", "G"],
        ...     tempos=[100, 120],
        ...     grooves=["Swing"],
        ...     score_to_wav=engine,
        ... )
    """
    from itertools import cycle as _cycle
    from .util import transpose_score

    engine = score_to_wav or _DFLT_ENGINE

    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    # Build combos by cycling shorter sequences
    n = max(len(keys), len(tempos), len(grooves))
    key_cycle = _cycle(keys)
    tempo_cycle = _cycle(tempos)
    groove_cycle = _cycle(grooves)
    combos = [
        (next(key_cycle), next(tempo_cycle), next(groove_cycle)) for _ in range(n)
    ]

    results: list[Path] = []
    for key, tempo, groove in combos:
        transposed = transpose_score(score, key) if key != score.key else score
        safe_key = key.replace("#", "sharp").replace("b", "flat")
        safe_title = score.title.replace(" ", "_")
        filename = filename_template.format(
            title=safe_title,
            key=safe_key,
            tempo=tempo,
            groove=groove,
        )
        output_path = output_dir / filename
        try:
            engine(
                transposed,
                output_path,
                groove=groove,
                tempo=tempo,
                repeats=repeats,
            )
            size = os.path.getsize(output_path)
            print(f"  OK: {filename} ({size:,} bytes)")
            results.append(output_path)
        except Exception as e:
            print(f"  FAILED: {filename} - {e}")

    print(f"\nGenerated {len(results)}/{len(combos)} files in {output_dir}")
    return results
