"""
AbstractMusic CLI.

Goals:
- Provide a minimal, practical interface similar to AbstractVision's CLI.
- Support a simple REPL loop for iterative prompt→audio generation.
- Keep module import light; heavy ML imports happen only when generating.
"""

from __future__ import annotations

import argparse
import cmd
import json
import os
import re
import shlex
import sys
import time
from pathlib import Path
from typing import Any, Optional, Sequence

from .errors import AbstractMusicError
from .huggingface import require_hf_repo_id
from .prompt_planner import MusicPlanningRequest, compile_music_prompt_plan, create_music_prompt_plan

if os.environ.get("DIFFUSERS_SLOW_IMPORT", "").strip().upper() in {"1", "ON", "YES", "TRUE"}:
    os.environ["DIFFUSERS_SLOW_IMPORT"] = "0"


SUPPORTED_BACKENDS = (
    "acemusic",
    "elevenlabs",
    "acestep",
    "diffusers",
    "musicgen",
    "stable-audio",
    "stable-audio-3",
)
DEFAULT_BACKEND = "acemusic"

_DEFAULT_MODEL_ID_CACHE: dict[str, str | None] | None = None


def _default_model_id_for_backend(backend_kind: str) -> str | None:
    """Return the packaged default model id for a backend kind."""

    global _DEFAULT_MODEL_ID_CACHE
    if _DEFAULT_MODEL_ID_CACHE is None:
        mapping: dict[str, str | None] = {kind: None for kind in SUPPORTED_BACKENDS}
        try:
            from .model_capabilities import MusicModelCapabilitiesRegistry

            reg = MusicModelCapabilitiesRegistry()
            models = list(reg.list_models())
        except Exception:
            models = []

        for kind in SUPPORTED_BACKENDS:
            if kind == "diffusers":
                mapping[kind] = None
                continue
            candidates = [m for m in models if kind in set(str(k) for k in m.backend_kinds)]
            chosen = next((m for m in candidates if getattr(m, "default_for_backend", False)), None)
            if chosen is None:
                chosen = next((m for m in candidates if bool(getattr(m, "recommended", False))), None)
            if chosen is None and candidates:
                chosen = candidates[0]
            mapping[kind] = str(chosen.id) if chosen is not None else None

        _DEFAULT_MODEL_ID_CACHE = mapping

    return (_DEFAULT_MODEL_ID_CACHE or {}).get(str(backend_kind), None)
BACKEND_ALIASES = {
    "remote": "acemusic",
    "api": "acemusic",
    "ace-music": "acemusic",
    "ace_music": "acemusic",
    "acemusic-api": "acemusic",
    "aceapi": "acemusic",
    "eleven": "elevenlabs",
    "11labs": "elevenlabs",
    "elevenlabs-music": "elevenlabs",
    "eleven-music": "elevenlabs",
    "eleven_labs": "elevenlabs",
    "11labs-music": "elevenlabs",
    "acestep": "acestep",
    "ace": "acestep",
    "ace-step": "acestep",
    "xl": "acestep",
    "ace-xl": "acestep",
    "acestep-xl": "acestep",
    "musicgen-small": "musicgen",
    "facebook-musicgen-small": "musicgen",
    "stable": "stable-audio",
    "stableaudio": "stable-audio",
    "stable-audio-open-small": "stable-audio",
    "sa3": "stable-audio-3",
    "stableaudio3": "stable-audio-3",
    "stable-audio3": "stable-audio-3",
    "stable-audio-3-small": "stable-audio-3",
    "stable-audio-3-small-music": "stable-audio-3",
    "stable-audio-3-medium": "stable-audio-3",
    "hf": "diffusers",
    "generic": "diffusers",
}


def _env(key: str, default: Optional[str] = None) -> Optional[str]:
    v = os.environ.get(str(key), None)
    if v is None:
        return default
    s = str(v).strip()
    return s if s else default


def _env_flag(key: str, default: bool = False) -> bool:
    value = _env(key)
    if value is None:
        return bool(default)
    return value.lower() in {"1", "on", "true", "yes"}


def _downloads_enabled_default() -> bool:
    """Return True when local HF-backed engines should be allowed to download weights."""

    offline = (
        _env_flag("ABSTRACTMUSIC_LOCAL_FILES_ONLY")
        or _env_flag("HF_HUB_OFFLINE")
        or _env_flag("TRANSFORMERS_OFFLINE")
        or _env_flag("DIFFUSERS_OFFLINE")
    )
    return not offline


def _timestamp_id() -> str:
    return time.strftime("%Y%m%d-%H%M%S")


def _split_style_values(values: object) -> tuple[str, ...]:
    if values is None:
        return ()
    if isinstance(values, str):
        candidates: Sequence[object] = [values]
    elif isinstance(values, Sequence) and not isinstance(values, (str, bytes)):
        candidates = values
    else:
        candidates = [values]

    out: list[str] = []
    for item in candidates:
        for part in re.split(r"[,\n]+", str(item or "")):
            text = str(part).strip()
            if text and text not in out:
                out.append(text)
    return tuple(out)


def _write_bytes(path: Path, content: bytes) -> None:
    p = Path(path).expanduser()
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(bytes(content))


def _normalize_backend(value: str) -> str:
    raw = str(value or "").strip().lower()
    backend = BACKEND_ALIASES.get(raw, raw)
    if backend not in SUPPORTED_BACKENDS:
        raise ValueError(f"Unknown engine {value!r}; expected one of: {', '.join(SUPPORTED_BACKENDS)}")
    return backend


def _parse_backend_arg(value: str) -> str:
    try:
        return _normalize_backend(value)
    except ValueError as e:
        raise argparse.ArgumentTypeError(str(e)) from e


def _parse_model_id_arg(value: str) -> str:
    try:
        return require_hf_repo_id(value, field_name="--model-id")
    except ValueError as e:
        raise argparse.ArgumentTypeError(str(e)) from e


def _copy_namespace(args: argparse.Namespace) -> argparse.Namespace:
    return argparse.Namespace(**vars(args))


def _acemusic_default_base_url() -> str:
    return _env("ACEMUSIC_BASE_URL") or "https://api.acemusic.ai"


def _acemusic_default_api_key() -> Optional[str]:
    return _env("ACEMUSIC_API_KEY")


def _elevenlabs_default_base_url() -> str:
    return _env("ELEVENLABS_BASE_URL") or "https://api.elevenlabs.io"


def _elevenlabs_default_api_key() -> Optional[str]:
    return _env("ELEVENLABS_API_KEY")


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="abstractmusic", description="AbstractMusic — text-to-audio/music generation")

    def _add_common_args(parser: argparse.ArgumentParser, *, use_defaults: bool) -> None:
        """
        Add common args to a parser.

        Why:
        - `argparse` subparsers do not accept top-level options placed AFTER the subcommand.
        - We want both of these to work:
          - abstractmusic --backend acestep t2m "..." --duration 10
          - abstractmusic t2m "..." --backend acestep --duration 10
        """

        default_suppress = argparse.SUPPRESS if not use_defaults else None
        verbose_default = _env_flag("ABSTRACTMUSIC_VERBOSE") or _env_flag("ABSTRACTMUSIC_ACESTEP_VERBOSE")

        parser.add_argument(
            "--backend",
            "--engine",
            dest="backend",
            type=_parse_backend_arg,
            default=_normalize_backend(_env("ABSTRACTMUSIC_BACKEND", DEFAULT_BACKEND) or DEFAULT_BACKEND)
            if use_defaults
            else default_suppress,
            help=(
                "Generation engine/backend (acemusic|acestep|elevenlabs|diffusers|musicgen|"
                "stable-audio|stable-audio-3; aliases: remote, ace-music, eleven, 11labs, "
                "ace, xl, sa3, hf). "
                "Default: acemusic."
            ),
        )
        parser.add_argument(
            "--model-id",
            type=_parse_model_id_arg,
            default=_env("ABSTRACTMUSIC_MODEL_ID") if use_defaults else default_suppress,
            help="Hugging Face model repo id (or set $ABSTRACTMUSIC_MODEL_ID). "
            "Local checkpoint paths are rejected; weights must come from the default Hugging Face cache. "
            "Diffusers: any Diffusers audio checkpoint id (license varies). "
            "ACE-Step: default reviewed model is ACE-Step/acestep-v15-xl-turbo-diffusers. "
            "Other trained official or compatible AceStepPipeline checkpoints can be selected explicitly. "
            "MusicGen: facebook/musicgen-small. Stable Audio Open: stabilityai/stable-audio-open-small. "
            "Stable Audio 3: stabilityai/stable-audio-3-small-music.",
        )
        parser.add_argument(
            "--acemusic-base-url",
            dest="acemusic_base_url",
            default=_acemusic_default_base_url() if use_defaults else default_suppress,
            help="ACE Music API base URL, or set $ACEMUSIC_BASE_URL. Default: https://api.acemusic.ai.",
        )
        parser.add_argument(
            "--acemusic-api-key",
            dest="acemusic_api_key",
            default=_acemusic_default_api_key() if use_defaults else default_suppress,
            help="ACE Music API key, or set $ACEMUSIC_API_KEY.",
        )
        parser.add_argument(
            "--elevenlabs-base-url",
            dest="elevenlabs_base_url",
            default=_elevenlabs_default_base_url() if use_defaults else default_suppress,
            help="ElevenLabs API base URL, or set $ELEVENLABS_BASE_URL. Default: https://api.elevenlabs.io.",
        )
        parser.add_argument(
            "--elevenlabs-api-key",
            dest="elevenlabs_api_key",
            default=_elevenlabs_default_api_key() if use_defaults else default_suppress,
            help="ElevenLabs API key, or set $ELEVENLABS_API_KEY.",
        )
        parser.add_argument(
            "--elevenlabs-model",
            dest="elevenlabs_model",
            default=_env("ELEVENLABS_MUSIC_MODEL", "music_v1") if use_defaults else default_suppress,
            help="ElevenLabs Music model id. Default: music_v1.",
        )
        parser.add_argument(
            "--elevenlabs-output-format",
            dest="elevenlabs_output_format",
            default=_env("ELEVENLABS_MUSIC_OUTPUT_FORMAT") if use_defaults else default_suppress,
            help="Optional ElevenLabs native output_format override, e.g. mp3_44100_128.",
        )
        parser.add_argument(
            "--composition-plan",
            dest="composition_plan",
            default=None if use_defaults else default_suppress,
            help="Optional JSON composition plan path for backends that support structured music plans.",
        )
        parser.add_argument(
            "--composition-mode",
            dest="composition_mode",
            choices=("auto", "prompt", "plan"),
            default=_env("ABSTRACTMUSIC_COMPOSITION_MODE", "auto") if use_defaults else default_suppress,
            help="Structured composition routing for capable remote backends. Default: auto.",
        )
        parser.add_argument(
            "--revision",
            default=_env("ABSTRACTMUSIC_REVISION") if use_defaults else default_suppress,
            help="Optional Hugging Face revision (commit/tag).",
        )
        parser.add_argument(
            "--download",
            action=argparse.BooleanOptionalAction,
            default=_downloads_enabled_default() if use_defaults else default_suppress,
            help="Allow downloading Hugging Face model files when missing. "
            "Default: enabled unless $HF_HUB_OFFLINE / $TRANSFORMERS_OFFLINE / $DIFFUSERS_OFFLINE "
            "or $ABSTRACTMUSIC_LOCAL_FILES_ONLY is set. Use --no-download to require cache only.",
        )
        parser.add_argument(
            "--device",
            default=_env("ABSTRACTMUSIC_DEVICE", "auto") if use_defaults else default_suppress,
            help="auto|cuda|mps|cpu",
        )
        parser.add_argument(
            "--dtype",
            default=_env("ABSTRACTMUSIC_TORCH_DTYPE", "auto") if use_defaults else default_suppress,
            help="auto|float16|bfloat16|float32",
        )
        parser.add_argument(
            "--pipeline-class",
            default=_env("ABSTRACTMUSIC_PIPELINE_CLASS") if use_defaults else default_suppress,
            help="Optional diffusers pipeline class name (e.g. AudioLDMPipeline). Usually not needed.",
        )
        parser.add_argument(
            "--steps",
            type=int,
            default=None if use_defaults else default_suppress,
            help="Inference steps. Diffusers generic backend defaults to 50; ACE-Step follows checkpoint defaults (turbo 8, base/sft 50).",
        )
        parser.add_argument(
            "--duration",
            type=float,
            default=float(_env("ABSTRACTMUSIC_DURATION_S", "10") or "10") if use_defaults else default_suppress,
            help="seconds",
        )
        parser.add_argument(
            "--format",
            choices=("wav", "mp3", "flac"),
            default=_env("ABSTRACTMUSIC_FORMAT", "wav") if use_defaults else default_suppress,
            help="Output audio format when supported by the selected backend. Default: wav.",
        )
        bpm_env = _env("ABSTRACTMUSIC_BPM")
        parser.add_argument(
            "--bpm",
            type=int,
            default=int(bpm_env) if use_defaults and bpm_env is not None else default_suppress,
            help="Optional target BPM for engines that support music metadata.",
        )
        parser.add_argument(
            "--keyscale",
            default=_env("ABSTRACTMUSIC_KEYSCALE") if use_defaults else default_suppress,
            help="Optional musical key/scale, e.g. C major or A minor.",
        )
        parser.add_argument(
            "--timesignature",
            default=_env("ABSTRACTMUSIC_TIMESIGNATURE") if use_defaults else default_suppress,
            help="Optional time signature metadata, e.g. 4.",
        )
        parser.add_argument(
            "--vocal-language",
            default=_env("ABSTRACTMUSIC_VOCAL_LANGUAGE", _env("ABSTRACTMUSIC_ACESTEP_VOCAL_LANGUAGE"))
            if use_defaults
            else default_suppress,
            help="Optional ACE-Step vocal language hint, e.g. en. Default leaves language as unknown.",
        )
        parser.add_argument(
            "--enhance-prompt",
            action=argparse.BooleanOptionalAction,
            default=_env_flag("ABSTRACTMUSIC_ENHANCE_PROMPT", False) if use_defaults else default_suppress,
            help="Locally expand short prompts into richer music captions before generation.",
        )
        parser.add_argument(
            "--structure-prompt",
            action=argparse.BooleanOptionalAction,
            default=_env_flag("ABSTRACTMUSIC_STRUCTURE_PROMPT", True) if use_defaults else default_suppress,
            help="Automatically add a compact section plan for long generations (>=45s). Default: on.",
        )
        parser.add_argument(
            "--text-planner",
            default=_env("ABSTRACTMUSIC_TEXT_PLANNER", "deterministic") if use_defaults else default_suppress,
            help="Text planning provider mode: deterministic|auto|required|off. Default: deterministic.",
        )
        parser.add_argument(
            "--auto-lyrics",
            action=argparse.BooleanOptionalAction,
            default=_env_flag("ABSTRACTMUSIC_AUTO_LYRICS", False) if use_defaults else default_suppress,
            help="Generate simple structured lyrics locally from the prompt.",
        )
        parser.add_argument(
            "--instrumental",
            action=argparse.BooleanOptionalAction,
            default=_env_flag("ABSTRACTMUSIC_INSTRUMENTAL", False) if use_defaults else default_suppress,
            help="Use the ACE-Step [Instrumental] lyrics marker.",
        )
        parser.add_argument(
            "--print-plan",
            action=argparse.BooleanOptionalAction,
            default=_env_flag("ABSTRACTMUSIC_PRINT_PLAN", False) if use_defaults else default_suppress,
            help="Print the effective caption, lyrics, and music metadata before generation.",
        )
        parser.add_argument(
            "--guidance-scale",
            type=float,
            default=None if use_defaults else default_suppress,
        )
        parser.add_argument(
            "--shift",
            type=float,
            default=float(_env("ABSTRACTMUSIC_ACESTEP_SHIFT", "3.0") or "3.0") if use_defaults else default_suppress,
            help="ACE-Step timestep shift. Default: 3.0 for turbo checkpoints.",
        )
        parser.add_argument(
            "--seed",
            type=int,
            default=None if use_defaults else default_suppress,
        )
        parser.add_argument(
            "--negative",
            default=None if use_defaults else default_suppress,
            help="Negative prompt (backend-supported)",
        )
        parser.add_argument(
            "--style",
            dest="positive_styles",
            action="append",
            default=[] if use_defaults else default_suppress,
            help="Positive style/instrument tags (repeatable or comma-separated). "
            "Used to enrich prompts and composition plans when supported.",
        )
        parser.add_argument(
            "--negative-style",
            dest="negative_styles",
            action="append",
            default=[] if use_defaults else default_suppress,
            help="Negative style tags to avoid (repeatable or comma-separated). "
            "Mainly used by composition-plan backends like ElevenLabs Music.",
        )
        parser.add_argument(
            "--verbose",
            action="store_true",
            default=verbose_default if use_defaults else default_suppress,
            help="Show backend logs/progress. Default keeps the REPL output quiet.",
        )

    _add_common_args(p, use_defaults=True)

    sub = p.add_subparsers(dest="cmd", required=True)

    t2m = sub.add_parser("t2m", help="Generate an audio file from a single prompt")
    _add_common_args(t2m, use_defaults=False)
    t2m.add_argument("prompt", help="Text prompt")
    t2m.add_argument(
        "--lyrics",
        default=None,
        help="Optional lyrics (ACE-Step supports lyrics natively). Use 'auto' to generate local lyrics.",
    )
    t2m.add_argument("--out", default="out.wav", help="Output audio path")

    repl = sub.add_parser("repl", help="Interactive modular prompt→audio loop", aliases=["cli"])
    _add_common_args(repl, use_defaults=False)
    repl.add_argument("--out-dir", default=".", help="Directory to write WAV files into")
    repl.add_argument("--prefix", default="music", help="Filename prefix for outputs")
    repl.add_argument("--prompt", default=None, help="Initial prompt for /run.")
    repl.add_argument(
        "--lyrics",
        default=None,
        help="Optional lyrics applied to every prompt in this REPL session. Use 'auto' to generate local lyrics.",
    )
    repl.add_argument("--open", action="store_true", help="Open output file after each generation (best-effort)")

    return p


def _open_file(path: Path) -> None:
    p = Path(path).expanduser().resolve()
    if not p.exists():
        return
    if sys.platform == "darwin":
        os.system(f"open {str(p)!r}")  # nosec - best-effort UX
        return
    if sys.platform.startswith("win"):
        os.system(f'start "" {str(p)!r}')  # nosec - best-effort UX
        return
    os.system(f"xdg-open {str(p)!r}")  # nosec - best-effort UX


def _make_manager_from_args(args: argparse.Namespace):
    # Import lazily to avoid pulling torch/diffusers during `abstractmusic --help`.
    from .music_manager import MusicManager

    backend_kind = str(getattr(args, "backend", DEFAULT_BACKEND) or DEFAULT_BACKEND).strip().lower()
    model_id = str(getattr(args, "model_id", "") or "").strip()
    if backend_kind not in {"acemusic", "elevenlabs"} and model_id:
        try:
            model_id = require_hf_repo_id(model_id, field_name="--model-id / ABSTRACTMUSIC_MODEL_ID")
        except ValueError as e:
            raise SystemExit(str(e)) from e

    if backend_kind == "acemusic":
        from .backends.acemusic import AceMusicBackend, AceMusicBackendConfig

        cfg = AceMusicBackendConfig(
            base_url=str(getattr(args, "acemusic_base_url", None) or _acemusic_default_base_url()),
            api_key=getattr(args, "acemusic_api_key", None) or _acemusic_default_api_key(),
        )
        backend = AceMusicBackend(config=cfg)
        return MusicManager(backend=backend)

    if backend_kind == "elevenlabs":
        from .backends.elevenlabs_music import ElevenLabsMusicBackend, ElevenLabsMusicBackendConfig

        cfg = ElevenLabsMusicBackendConfig(
            base_url=str(getattr(args, "elevenlabs_base_url", None) or _elevenlabs_default_base_url()),
            api_key=getattr(args, "elevenlabs_api_key", None) or _elevenlabs_default_api_key(),
            model=str(getattr(args, "elevenlabs_model", None) or "music_v1"),
            output_format=str(getattr(args, "elevenlabs_output_format", "") or "").strip() or None,
            composition_mode=str(getattr(args, "composition_mode", "auto") or "auto"),
        )
        backend = ElevenLabsMusicBackend(config=cfg)
        return MusicManager(backend=backend)

    if backend_kind == "acestep":
        from .backends.acestep import AceStepBackend, AceStepBackendConfig

        if not model_id:
            model_id = _default_model_id_for_backend("acestep") or ""
        if not model_id:
            raise SystemExit("No default model is configured for engine=acestep; pass --model-id explicitly.")
        allow_download = bool(getattr(args, "download", _downloads_enabled_default()))
        cfg = AceStepBackendConfig(
            model_id=model_id,
            device=str(getattr(args, "device", "auto") or "auto"),
            torch_dtype=str(getattr(args, "dtype", "auto") or "auto"),
            num_inference_steps=int(getattr(args, "steps")) if getattr(args, "steps", None) is not None else None,
            duration_s=float(getattr(args, "duration", 10.0)),
            guidance_scale=float(getattr(args, "guidance_scale")) if getattr(args, "guidance_scale", None) is not None else None,
            shift=float(getattr(args, "shift")) if getattr(args, "shift", None) is not None else 3.0,
            revision=str(getattr(args, "revision", "") or "").strip() or None,
            local_files_only=not allow_download,
        )
        backend = AceStepBackend(config=cfg)
        return MusicManager(backend=backend)

    if backend_kind == "diffusers":
        from .backends.diffusers_audio import DiffusersAudioBackend, DiffusersAudioBackendConfig

        if not model_id:
            raise SystemExit("Missing --model-id (or $ABSTRACTMUSIC_MODEL_ID) for backend=diffusers.")
        cfg = DiffusersAudioBackendConfig(
            model_id=model_id,
            device=str(getattr(args, "device", "auto") or "auto"),
            torch_dtype=str(getattr(args, "dtype", "auto") or "auto"),
            pipeline_class=str(getattr(args, "pipeline_class") or "").strip() or None,
            num_inference_steps=int(getattr(args, "steps", None) or 50),
            duration_s=float(getattr(args, "duration", 10.0)),
            guidance_scale=float(getattr(args, "guidance_scale")) if getattr(args, "guidance_scale", None) is not None else None,
        )
        backend = DiffusersAudioBackend(config=cfg)
        return MusicManager(backend=backend)

    if backend_kind == "musicgen":
        from .backends.musicgen import MusicGenBackend, MusicGenBackendConfig

        if not model_id:
            model_id = _default_model_id_for_backend("musicgen") or ""
        if not model_id:
            raise SystemExit("No default model is configured for engine=musicgen; pass --model-id explicitly.")
        cfg = MusicGenBackendConfig(
            model_id=model_id,
            device=str(getattr(args, "device", "auto") or "auto"),
            torch_dtype=str(getattr(args, "dtype", "auto") or "auto"),
            duration_s=float(getattr(args, "duration", 10.0)),
            guidance_scale=float(getattr(args, "guidance_scale")) if getattr(args, "guidance_scale", None) is not None else 3.0,
        )
        backend = MusicGenBackend(config=cfg)
        return MusicManager(backend=backend)

    if backend_kind == "stable-audio":
        from .backends.stable_audio import StableAudioBackend, StableAudioBackendConfig

        if not model_id:
            model_id = _default_model_id_for_backend("stable-audio") or ""
        if not model_id:
            raise SystemExit("No default model is configured for engine=stable-audio; pass --model-id explicitly.")
        cfg = StableAudioBackendConfig(
            model_id=model_id,
            device=str(getattr(args, "device", "auto") or "auto"),
            duration_s=float(getattr(args, "duration", 10.0)),
            num_inference_steps=int(getattr(args, "steps", None) or 8),
            guidance_scale=float(getattr(args, "guidance_scale")) if getattr(args, "guidance_scale", None) is not None else 1.0,
        )
        backend = StableAudioBackend(config=cfg)
        return MusicManager(backend=backend)

    if backend_kind == "stable-audio-3":
        from .backends.stable_audio_3 import StableAudio3Backend, StableAudio3BackendConfig

        if not model_id:
            model_id = _default_model_id_for_backend("stable-audio-3") or ""
        if not model_id:
            raise SystemExit("No default model is configured for engine=stable-audio-3; pass --model-id explicitly.")
        cfg = StableAudio3BackendConfig(
            model_id=model_id,
            device=str(getattr(args, "device", "auto") or "auto"),
            torch_dtype=str(getattr(args, "dtype", "auto") or "auto"),
            duration_s=float(getattr(args, "duration", 30.0)),
            num_inference_steps=int(getattr(args, "steps", None) or 8),
            guidance_scale=float(getattr(args, "guidance_scale")) if getattr(args, "guidance_scale", None) is not None else 1.0,
        )
        backend = StableAudio3Backend(config=cfg)
        return MusicManager(backend=backend)

    raise SystemExit(f"Unknown --backend: {backend_kind!r}")


def _native_lyrics_supported(backend_kind: str) -> bool:
    return str(backend_kind or "").strip().lower() in {"acemusic", "elevenlabs", "acestep"}


def _load_composition_plan(path_value: Optional[str]) -> Optional[dict[str, Any]]:
    text = str(path_value or "").strip()
    if not text:
        return None
    path = Path(text).expanduser()
    try:
        parsed = json.loads(path.read_text(encoding="utf-8"))
    except Exception as exc:
        raise SystemExit(f"Could not read --composition-plan JSON from {path}: {exc}") from exc
    if not isinstance(parsed, dict):
        raise SystemExit("--composition-plan must point to a JSON object.")
    return dict(parsed)


def _resolve_generation_text(
    args: argparse.Namespace, prompt: str, lyrics: Optional[str]
) -> tuple[str, Optional[str], dict[str, Any], Optional[Any]]:
    """Resolve local prompt expansion, lyrics, and music metadata for one request."""

    backend_kind = str(getattr(args, "backend", DEFAULT_BACKEND) or DEFAULT_BACKEND).strip().lower()
    duration = float(getattr(args, "duration", 10.0))
    positive_styles = _split_style_values(getattr(args, "positive_styles", None))
    negative_styles = _split_style_values(getattr(args, "negative_styles", None))
    plan_request = MusicPlanningRequest(
        prompt=str(prompt or ""),
        lyrics=lyrics,
        vocal_language=getattr(args, "vocal_language", None),
        duration_s=duration,
        bpm=getattr(args, "bpm", None),
        keyscale=getattr(args, "keyscale", None),
        timesignature=getattr(args, "timesignature", None),
        positive_styles=positive_styles,
        negative_styles=negative_styles,
        instrumental=bool(getattr(args, "instrumental", False)),
        enhance_prompt=bool(getattr(args, "enhance_prompt", False)),
        structure_prompt=bool(getattr(args, "structure_prompt", True)),
        auto_lyrics=bool(getattr(args, "auto_lyrics", False)),
        backend=backend_kind,
        model_id=getattr(args, "model_id", None),
    )
    plan = create_music_prompt_plan(
        plan_request,
        mode=str(getattr(args, "text_planner", "deterministic") or "deterministic"),
    )
    compiled = compile_music_prompt_plan(
        plan,
        backend=backend_kind,
        native_lyrics_supported=_native_lyrics_supported(backend_kind),
    )

    if bool(getattr(args, "print_plan", False)):
        print("Effective music plan:", file=sys.stderr)
        print(f"  prompt: {plan.prompt}", file=sys.stderr)
        print(f"  lyrics: {plan.lyrics or 'none'}", file=sys.stderr)
        print(f"  bpm: {plan.bpm if plan.bpm is not None else 'auto'}", file=sys.stderr)
        print(f"  keyscale: {plan.keyscale or 'auto'}", file=sys.stderr)
        print(f"  timesignature: {plan.timesignature or 'auto'}", file=sys.stderr)
        if positive_styles:
            print(f"  positive_styles: {', '.join(positive_styles)}", file=sys.stderr)
        if negative_styles:
            print(f"  negative_styles: {', '.join(negative_styles)}", file=sys.stderr)
        print(f"  structured_prompt: {'yes' if plan.structured_prompt else 'no'}", file=sys.stderr)
        print(f"  planner_backend: {plan.planner_backend}", file=sys.stderr)
        print(f"  generated_fields: {', '.join(plan.generated_fields) if plan.generated_fields else 'none'}", file=sys.stderr)
        print(f"  warnings: {', '.join(compiled.metadata.get('planner_warnings') or ()) or 'none'}", file=sys.stderr)

    return compiled.prompt, compiled.lyrics, dict(compiled.metadata), compiled.composition_plan


def _cmd_t2m(args: argparse.Namespace) -> int:
    mm = _make_manager_from_args(args)
    prompt, lyrics, plan_meta, planned_composition_plan = _resolve_generation_text(
        args, str(args.prompt), getattr(args, "lyrics", None)
    )
    composition_plan = _load_composition_plan(getattr(args, "composition_plan", None)) or planned_composition_plan

    wav = mm.t2m(
        prompt,
        duration_s=float(args.duration),
        format=str(getattr(args, "format", "wav") or "wav"),
        num_inference_steps=int(args.steps) if args.steps is not None else None,
        guidance_scale=args.guidance_scale,
        seed=args.seed,
        negative_prompt=args.negative,
        lyrics=lyrics,
        vocal_language=plan_meta["vocal_language"],
        composition_plan=composition_plan,
        composition_mode=getattr(args, "composition_mode", "auto"),
        bpm=plan_meta["bpm"],
        keyscale=plan_meta["keyscale"],
        timesignature=plan_meta["timesignature"],
        metadata=plan_meta,
    )
    out_path = Path(str(args.out)).expanduser()
    _write_bytes(out_path, wav)
    print(str(out_path))
    return 0


class MusicREPL(cmd.Cmd):
    """Interactive music generation shell with switchable engines."""

    prompt = "music> "
    ruler = ""

    def __init__(self, args: argparse.Namespace) -> None:
        super().__init__()
        self.args = _copy_namespace(args)
        self.args.backend = _normalize_backend(str(getattr(self.args, "backend", DEFAULT_BACKEND) or DEFAULT_BACKEND))
        self.out_dir = Path(str(getattr(self.args, "out_dir", ".") or ".")).expanduser()
        self.prefix = str(getattr(self.args, "prefix", "music") or "music").strip() or "music"
        self.open_outputs = bool(getattr(self.args, "open", False))
        self.current_prompt = str(getattr(self.args, "prompt", "") or "").strip()
        self._manager = None
        self._manager_dirty = True
        self._registry = None
        self._render_count = 0
        self._init_readline()
        self.intro = (
            "AbstractMusic REPL\n"
            "Type a prompt to generate music, or use /prompt then /run. Commands: /help, /status, /engines, /models, /exit"
        )

    def _get_registry(self):
        if self._registry is None:
            from .model_capabilities import MusicModelCapabilitiesRegistry

            self._registry = MusicModelCapabilitiesRegistry()
        return self._registry

    def _spec_for_model_id(self, model_id: str):
        reg = self._get_registry()
        try:
            return reg.get(str(model_id))
        except Exception:
            return None

    def _engine_for_model_id(self, model_id: str) -> str | None:
        spec = self._spec_for_model_id(model_id)
        if spec is None:
            return None
        for kind in spec.backend_kinds:
            if str(kind) in set(SUPPORTED_BACKENDS):
                return str(kind)
        return None

    def _effective_engine(self) -> str:
        return str(getattr(self.args, "backend", DEFAULT_BACKEND) or DEFAULT_BACKEND).strip().lower()

    def _default_model_for_engine(self, engine: str) -> str | None:
        return _default_model_id_for_backend(str(engine))

    def _effective_model_id(self) -> str | None:
        explicit = getattr(self.args, "model_id", None)
        if explicit:
            return str(explicit)
        return self._default_model_for_engine(self._effective_engine())

    def _ensure_engine_model_consistency(self, *, reason: str) -> None:
        model_id = getattr(self.args, "model_id", None)
        if not model_id:
            return
        expected_engine = self._engine_for_model_id(str(model_id))
        current_engine = self._effective_engine()
        if expected_engine and expected_engine != current_engine:
            setattr(self.args, "model_id", None)
            self._mark_dirty()
            print(
                f"model: default (cleared; {model_id} belongs to engine {expected_engine}, current engine {current_engine}; {reason})"
            )

    def _init_readline(self) -> None:
        try:
            import atexit
            import readline  # type: ignore
        except Exception:
            return
        hist_dir = Path.home() / ".abstractmusic"
        hist_dir.mkdir(parents=True, exist_ok=True)
        hist_path = hist_dir / "repl_history"
        try:
            readline.read_history_file(str(hist_path))
        except FileNotFoundError:
            pass
        except Exception:
            pass
        try:
            readline.set_history_length(2000)
        except Exception:
            pass

        def _save_history() -> None:
            try:
                readline.write_history_file(str(hist_path))
            except Exception:
                pass

        atexit.register(_save_history)

    def parseline(self, line: str):  # type: ignore[override]
        line = str(line or "").strip()
        if line.startswith("/"):
            line = line[1:].lstrip()
            if not line:
                return None, None, line
            pieces = line.split(None, 1)
            cmd_name = pieces[0].replace("-", "_")
            arg = pieces[1] if len(pieces) > 1 else ""
            parsed_line = f"{cmd_name} {arg}".strip()
            return cmd_name, arg, parsed_line
        return super().parseline(line)

    def emptyline(self) -> bool:
        return False

    def _mark_dirty(self) -> None:
        self._manager_dirty = True

    def _get_manager(self):
        if self._manager is None or self._manager_dirty:
            try:
                self._manager = _make_manager_from_args(self.args)
            except SystemExit as e:
                raise ValueError(str(e)) from e
            self._manager_dirty = False
        return self._manager

    def _next_output_path(self) -> Path:
        self._render_count += 1
        stamp = _timestamp_id()
        fmt = str(getattr(self.args, "format", "wav") or "wav").strip().lower() or "wav"
        return self.out_dir / f"{self.prefix}-{stamp}-{self._render_count:03d}.{fmt}"

    @staticmethod
    def _tokens(arg: str) -> list[str]:
        try:
            return shlex.split(str(arg or ""))
        except ValueError as e:
            raise ValueError(f"Could not parse command: {e}") from e

    def _set_optional_text(self, name: str, arg: str, label: str) -> None:
        value = str(arg or "").strip()
        if value.lower() in {"", "clear", "none", "null", "off"}:
            setattr(self.args, name, None)
            print(f"{label} cleared")
        else:
            setattr(self.args, name, value)
            print(f"{label}: {value}")

    def _set_optional_int(self, name: str, arg: str, label: str) -> None:
        value = str(arg or "").strip()
        if value.lower() in {"", "clear", "none", "null", "auto", "off"}:
            setattr(self.args, name, None)
            print(f"{label}: auto")
            return
        parsed = int(value)
        setattr(self.args, name, parsed)
        print(f"{label}: {parsed}")

    def _set_optional_float(self, name: str, arg: str, label: str, *, clear_to_none: bool = True) -> None:
        value = str(arg or "").strip()
        if not value and not clear_to_none:
            print(f"{label}: {getattr(self.args, name, None)}")
            return
        if clear_to_none and value.lower() in {"", "clear", "none", "null", "auto", "off"}:
            setattr(self.args, name, None)
            print(f"{label}: auto")
            return
        parsed = float(value)
        setattr(self.args, name, parsed)
        print(f"{label}: {parsed:g}")

    def _set_bool_flag(self, name: str, arg: str, label: str) -> None:
        value = str(arg or "").strip().lower()
        if not value:
            print(f"{label}: {'on' if bool(getattr(self.args, name, False)) else 'off'}")
            return
        if value in {"1", "on", "true", "yes"}:
            setattr(self.args, name, True)
            print(f"{label}: on")
            return
        if value in {"0", "off", "false", "no", "clear", "none"}:
            setattr(self.args, name, False)
            print(f"{label}: off")
            return
        print(f"Usage: /{label} [on|off]")

    def _generate(self, prompt: str | None = None) -> None:
        prompt = str(prompt or "").strip()
        if prompt:
            self.current_prompt = prompt
        else:
            prompt = self.current_prompt
        if not prompt:
            print("Usage: /prompt <text>, then /run; or /generate <prompt>")
            return
        mm = self._get_manager()
        backend_kind = self._effective_engine()
        effective_model = self._effective_model_id()
        if backend_kind == "diffusers" and not effective_model:
            print("ERROR: engine=diffusers requires /model <huggingface_repo_id>", file=sys.stderr)
            return
        request_prompt, request_lyrics, plan_meta, planned_composition_plan = _resolve_generation_text(
            self.args,
            prompt,
            getattr(self.args, "lyrics", None),
        )
        composition_plan = _load_composition_plan(getattr(self.args, "composition_plan", None)) or planned_composition_plan
        duration = float(getattr(self.args, "duration", 10.0))
        model_suffix = ""
        if getattr(self.args, "model_id", None) is None and self._default_model_for_engine(backend_kind):
            model_suffix = " [default]"
        print(f"Generating with {backend_kind} (model={effective_model or 'unset'}{model_suffix}, {duration:g}s)...")
        wav = mm.t2m(
            request_prompt,
            duration_s=duration,
            format=str(getattr(self.args, "format", "wav") or "wav"),
            num_inference_steps=int(getattr(self.args, "steps")) if getattr(self.args, "steps", None) is not None else None,
            guidance_scale=getattr(self.args, "guidance_scale", None),
            seed=getattr(self.args, "seed", None),
            negative_prompt=getattr(self.args, "negative", None),
            lyrics=request_lyrics,
            vocal_language=plan_meta["vocal_language"],
            composition_plan=composition_plan,
            composition_mode=getattr(self.args, "composition_mode", "auto"),
            bpm=plan_meta["bpm"],
            keyscale=plan_meta["keyscale"],
            timesignature=plan_meta["timesignature"],
            metadata=plan_meta,
        )
        out_path = self._next_output_path()
        _write_bytes(out_path, wav)
        print(f"Saved: {out_path}")
        if self.open_outputs:
            _open_file(out_path)

    def default(self, line: str) -> None:
        line = str(line or "").strip()
        if not line:
            return
        if line.startswith("!"):
            print("Shell escapes are not supported in this REPL.")
            return
        try:
            self.current_prompt = line
            self._generate(line)
        except Exception as e:
            print(f"ERROR: {e}", file=sys.stderr)

    def do_help(self, arg: str = "") -> None:  # noqa: D401
        """Show REPL commands."""
        _ = arg
        print("Core commands:")
        print("  /status                  Show current engine + effective model + key params")
        print("  /engines                 List engines and their default models")
        print("  /engine <name>           Select engine (acemusic, elevenlabs, acestep, diffusers, musicgen, stable-audio, stable-audio-3)")
        print("  /models                  List known models (grouped by engine)")
        print("  /model <id|default>      Select model id (auto-switches engine when known)")
        print("")
        print("Generation:")
        print("  /prompt <text|clear>     Set or clear the session prompt")
        print("  /run                     Generate from current prompt")
        print("  /generate <prompt>       Generate from a prompt (bare prompt also works)")
        print("")
        print("Common params:")
        print("  /download <on|off>       Allow/disallow downloading missing HF model files")
        print("  /duration <seconds>      Duration in seconds")
        print("  /style <tags|clear>      Set positive style/instrument tags (comma-separated)")
        print("  /negative-style <tags|clear> Set negative style tags to avoid (comma-separated)")
        print("  /steps <n|auto>          Inference steps")
        print("  /seed <n|auto>           Seed")
        print("  /format <wav|mp3|flac>   Output format (remote backends may support more)")
        print("  /device <auto|mps|cuda|cpu>")
        print("  /dtype <auto|float16|bfloat16|float32>")
        print("  /verbose <on|off>        Show/hide backend logs")
        print("")
        print("Session:")
        print("  /params                  Show all settings")
        print("  /out <dir>               Output directory")
        print("  /prefix <name>           Output filename prefix")
        print("  /exit                    Quit")

    def do_exit(self, arg: str = "") -> bool:
        _ = arg
        return True

    def do_quit(self, arg: str = "") -> bool:
        return self.do_exit(arg)

    def do_q(self, arg: str = "") -> bool:
        return self.do_exit(arg)

    def do_EOF(self, arg: str = "") -> bool:  # noqa: N802
        print("")
        return self.do_exit(arg)

    def do_generate(self, arg: str) -> None:
        try:
            self._generate(arg)
        except Exception as e:
            print(f"ERROR: {e}", file=sys.stderr)

    def do_run(self, arg: str = "") -> None:
        _ = arg
        self.do_generate("")

    def do_prompt(self, arg: str = "") -> None:
        value = str(arg or "").strip()
        if not value:
            print(self.current_prompt or "none")
            return
        if value.lower() in {"clear", "none", "null", "off"}:
            self.current_prompt = ""
            print("prompt cleared")
            return
        self.current_prompt = value
        print(f"prompt: {self.current_prompt}")

    def do_status(self, arg: str = "") -> None:
        _ = arg
        engine = self._effective_engine()
        explicit_model = getattr(self.args, "model_id", None)
        effective_model = self._effective_model_id()
        if explicit_model:
            print(f"engine: {engine}")
            print(f"model: {explicit_model}")
        else:
            print(f"engine: {engine}")
            print(f"model: default ({effective_model or 'unset'})")

        spec = self._spec_for_model_id(str(effective_model)) if effective_model else None
        if spec is not None:
            rec = "recommended" if spec.recommended else "candidate"
            max_dur = f"{spec.max_duration_s:g}s" if spec.max_duration_s is not None else "n/a"
            locality = "remote" if bool(spec.raw.get("remote", False)) else "local"
            print(f"provider: {spec.provider} ({rec}, {locality}, status={spec.status}, max_duration={max_dur})")

        print(f"duration: {float(getattr(self.args, 'duration', 10.0)):g}s")
        print(f"steps: {getattr(self.args, 'steps', None) or 'auto'}")
        print(f"seed: {getattr(self.args, 'seed', None) if getattr(self.args, 'seed', None) is not None else 'auto'}")
        print(f"format: {str(getattr(self.args, 'format', 'wav') or 'wav')}")
        print(f"style: {', '.join(_split_style_values(getattr(self.args, 'positive_styles', None))) or 'none'}")
        print(f"negative_style: {', '.join(_split_style_values(getattr(self.args, 'negative_styles', None))) or 'none'}")
        print(f"download: {'on' if bool(getattr(self.args, 'download', _downloads_enabled_default())) else 'off'}")

    def do_engines(self, arg: str = "") -> None:
        _ = arg
        rows: list[dict[str, str]] = []
        current_engine = self._effective_engine()
        for engine in SUPPORTED_BACKENDS:
            default_model = self._default_model_for_engine(engine) or ""
            spec = self._spec_for_model_id(default_model) if default_model else None
            provider = spec.provider if spec is not None else ("Hugging Face" if engine == "diffusers" else "")
            max_dur = f"{spec.max_duration_s:g}s" if (spec is not None and spec.max_duration_s is not None) else ""
            locality = "remote" if (spec is not None and bool(spec.raw.get("remote", False))) else ("local" if spec is not None else "")
            marker = "*" if engine == current_engine else " "
            rows.append(
                {
                    "sel": marker,
                    "engine": engine,
                    "default_model": default_model or ("(requires /model)" if engine == "diffusers" else ""),
                    "provider": provider,
                    "locality": locality,
                    "max_dur": max_dur,
                }
            )

        w_engine = max(len(r["engine"]) for r in rows) if rows else 6
        w_model = max(len(r["default_model"]) for r in rows) if rows else 12
        w_provider = max(len(r["provider"]) for r in rows) if rows else 8
        w_locality = max(len(r["locality"]) for r in rows) if rows else 6
        w_max = max(len(r["max_dur"]) for r in rows) if rows else 3

        print(
            f"  {'ENGINE'.ljust(w_engine)}  {'DEFAULT MODEL'.ljust(w_model)}  {'PROVIDER'.ljust(w_provider)}  {'WHERE'.ljust(w_locality)}  {'MAX'.ljust(w_max)}"
        )
        for r in rows:
            print(
                f"{r['sel']} {r['engine'].ljust(w_engine)}  {r['default_model'].ljust(w_model)}  {r['provider'].ljust(w_provider)}  {r['locality'].ljust(w_locality)}  {r['max_dur'].ljust(w_max)}"
            )

    def do_engine(self, arg: str) -> None:
        value = str(arg or "").strip()
        if not value:
            engine = self._effective_engine()
            explicit_model = getattr(self.args, "model_id", None)
            effective_model = self._effective_model_id() or "unset"
            suffix = f" (model={effective_model})"
            if explicit_model is None and self._default_model_for_engine(engine):
                suffix += " [default]"
            print(f"{engine}{suffix}")
            return
        backend = _normalize_backend(value)
        setattr(self.args, "backend", backend)
        self._mark_dirty()
        self._ensure_engine_model_consistency(reason="engine explicitly changed")
        if backend == "diffusers" and not getattr(self.args, "model_id", None):
            print("engine: diffusers (requires /model <huggingface_repo_id>)")
            return
        print(f"engine: {backend}")

    def do_backend(self, arg: str) -> None:
        self.do_engine(arg)

    def complete_engine(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
        _ = line, begidx, endidx
        options = list(SUPPORTED_BACKENDS) + sorted(BACKEND_ALIASES)
        return [item for item in options if item.startswith(text)]

    complete_backend = complete_engine

    def do_model(self, arg: str) -> None:
        value = str(arg or "").strip()
        if not value:
            engine = self._effective_engine()
            explicit = getattr(self.args, "model_id", None)
            default_model = self._default_model_for_engine(engine)
            if explicit:
                print(str(explicit))
                return
            if default_model:
                print(f"default ({default_model})")
                return
            print("unset (engine requires /model)")
            return
        if value.lower() in {"clear", "none", "default"}:
            setattr(self.args, "model_id", None)
            effective = self._effective_model_id()
            if effective:
                print(f"model: default ({effective})")
            else:
                print("model: default")
        else:
            try:
                value = require_hf_repo_id(value, field_name="model")
            except ValueError as e:
                print(f"ERROR: {e}", file=sys.stderr)
                return
            setattr(self.args, "model_id", value)
            selected_engine = self._engine_for_model_id(value)
            if selected_engine and selected_engine != self._effective_engine():
                setattr(self.args, "backend", selected_engine)
                print(f"engine: {selected_engine} (auto)")

            spec = self._spec_for_model_id(value)
            if spec is not None and spec.max_duration_s is not None:
                try:
                    current_duration = float(getattr(self.args, "duration", 10.0))
                except Exception:
                    current_duration = 10.0
                max_duration = float(spec.max_duration_s)
                if current_duration > max_duration > 0:
                    setattr(self.args, "duration", max_duration)
                    print(f"duration: {max_duration:g} (auto; max for {spec.id})")

            print(f"model: {value}")
        self._mark_dirty()

    def complete_model(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
        _ = line, begidx, endidx
        prefix = str(text or "")
        out: list[str] = []
        for sentinel in ["default", "clear"]:
            if sentinel.startswith(prefix):
                out.append(sentinel)
        try:
            reg = self._get_registry()
            wanted = prefix.lower()
            for spec in reg.list_models():
                model_id = str(spec.id)
                if not wanted or model_id.lower().startswith(wanted):
                    out.append(model_id)
        except Exception:
            return out
        return sorted(set(out))

    def do_format(self, arg: str) -> None:
        value = str(arg or "").strip().lower()
        if not value:
            print(str(getattr(self.args, "format", "wav") or "wav"))
            return
        if value not in {"wav", "mp3", "flac"}:
            print("Usage: /format [wav|mp3|flac]")
            return
        setattr(self.args, "format", value)
        print(f"format: {value}")

    def do_composition_mode(self, arg: str) -> None:
        value = str(arg or "").strip().lower()
        if not value:
            print(str(getattr(self.args, "composition_mode", "auto") or "auto"))
            return
        if value not in {"auto", "prompt", "plan"}:
            print("Usage: /composition-mode [auto|prompt|plan]")
            return
        setattr(self.args, "composition_mode", value)
        print(f"composition-mode: {value}")

    def do_device(self, arg: str) -> None:
        value = str(arg or "").strip()
        if not value:
            print(str(getattr(self.args, "device", "auto") or "auto"))
            return
        setattr(self.args, "device", value)
        self._mark_dirty()
        print(f"device: {value}")

    def do_dtype(self, arg: str) -> None:
        value = str(arg or "").strip()
        if not value:
            print(str(getattr(self.args, "dtype", "auto") or "auto"))
            return
        setattr(self.args, "dtype", value)
        self._mark_dirty()
        print(f"dtype: {value}")

    def do_download(self, arg: str = "") -> None:
        value = str(arg or "").strip().lower()
        current = bool(getattr(self.args, "download", _downloads_enabled_default()))
        if not value:
            print("download: on" if current else "download: off")
            return
        if value in {"1", "on", "true", "yes"}:
            setattr(self.args, "download", True)
            self._mark_dirty()
            print("download: on")
            return
        if value in {"0", "off", "false", "no"}:
            setattr(self.args, "download", False)
            self._mark_dirty()
            print("download: off")
            return
        print("Usage: /download [on|off]")

    def do_duration(self, arg: str) -> None:
        self._set_optional_float("duration", arg, "duration", clear_to_none=False)

    def do_bpm(self, arg: str) -> None:
        self._set_optional_int("bpm", arg, "bpm")

    def do_keyscale(self, arg: str) -> None:
        self._set_optional_text("keyscale", arg, "keyscale")

    def do_timesignature(self, arg: str) -> None:
        self._set_optional_text("timesignature", arg, "timesignature")

    def do_vocal_language(self, arg: str) -> None:
        self._set_optional_text("vocal_language", arg, "vocal-language")

    def do_enhance_prompt(self, arg: str) -> None:
        self._set_bool_flag("enhance_prompt", arg, "enhance-prompt")

    def do_structure_prompt(self, arg: str) -> None:
        self._set_bool_flag("structure_prompt", arg, "structure-prompt")

    def do_text_planner(self, arg: str) -> None:
        value = str(arg or "").strip().lower()
        if not value:
            print(str(getattr(self.args, "text_planner", "deterministic") or "deterministic"))
            return
        if value not in {"deterministic", "fallback", "local", "auto", "provider", "required", "off", "none", "raw"}:
            print("Usage: /text-planner [deterministic|auto|required|off]")
            return
        setattr(self.args, "text_planner", value)
        print(f"text-planner: {value}")

    def do_auto_lyrics(self, arg: str) -> None:
        self._set_bool_flag("auto_lyrics", arg, "auto-lyrics")

    def do_instrumental(self, arg: str) -> None:
        self._set_bool_flag("instrumental", arg, "instrumental")

    def do_print_plan(self, arg: str) -> None:
        self._set_bool_flag("print_plan", arg, "print-plan")

    def do_steps(self, arg: str) -> None:
        self._set_optional_int("steps", arg, "steps")

    def do_seed(self, arg: str) -> None:
        self._set_optional_int("seed", arg, "seed")

    def do_guidance(self, arg: str) -> None:
        self._set_optional_float("guidance_scale", arg, "guidance")

    def do_guidance_scale(self, arg: str) -> None:
        self.do_guidance(arg)

    def do_shift(self, arg: str) -> None:
        self._set_optional_float("shift", arg, "shift", clear_to_none=False)

    def do_verbose(self, arg: str = "") -> None:
        value = str(arg or "").strip().lower()
        if not value:
            print("on" if bool(getattr(self.args, "verbose", False)) else "off")
            return
        if value in {"1", "on", "true", "yes"}:
            setattr(self.args, "verbose", True)
            self._mark_dirty()
            print("verbose: on")
            return
        if value in {"0", "off", "false", "no"}:
            setattr(self.args, "verbose", False)
            self._mark_dirty()
            print("verbose: off")
            return
        print("Usage: /verbose [on|off]")

    def do_negative(self, arg: str) -> None:
        self._set_optional_text("negative", arg, "negative")

    def _set_style_list(self, name: str, arg: str, label: str) -> None:
        value = str(arg or "").strip()
        current = _split_style_values(getattr(self.args, name, None))
        if not value:
            print(", ".join(current) if current else "none")
            return
        if value.lower() in {"clear", "none", "null", "off"}:
            setattr(self.args, name, [])
            print(f"{label}: cleared")
            return
        parsed = _split_style_values([value])
        setattr(self.args, name, list(parsed))
        print(f"{label}: {', '.join(parsed) if parsed else 'none'}")

    def do_style(self, arg: str = "") -> None:
        self._set_style_list("positive_styles", arg, "style")

    def do_negative_style(self, arg: str = "") -> None:
        self._set_style_list("negative_styles", arg, "negative-style")

    def do_lyrics(self, arg: str) -> None:
        self._set_optional_text("lyrics", arg, "lyrics")

    def do_out(self, arg: str) -> None:
        value = str(arg or "").strip()
        if not value:
            print(str(self.out_dir))
            return
        self.out_dir = Path(value).expanduser()
        print(f"out: {self.out_dir}")

    def do_out_dir(self, arg: str) -> None:
        self.do_out(arg)

    def do_prefix(self, arg: str) -> None:
        value = str(arg or "").strip()
        if not value:
            print(self.prefix)
            return
        self.prefix = value
        print(f"prefix: {self.prefix}")

    def do_params(self, arg: str = "") -> None:
        _ = arg
        fields: list[tuple[str, Any]] = [
            ("engine", getattr(self.args, "backend", None)),
            ("model", getattr(self.args, "model_id", None) or "default"),
            ("prompt", self.current_prompt or "none"),
            ("format", getattr(self.args, "format", "wav")),
            ("device", getattr(self.args, "device", "auto")),
            ("dtype", getattr(self.args, "dtype", "auto")),
            ("download", "on" if bool(getattr(self.args, "download", _downloads_enabled_default())) else "off"),
            ("duration", getattr(self.args, "duration", None)),
            ("bpm", getattr(self.args, "bpm", None) if getattr(self.args, "bpm", None) is not None else "auto"),
            ("keyscale", getattr(self.args, "keyscale", None) or "auto"),
            ("timesignature", getattr(self.args, "timesignature", None) or "auto"),
            ("style", ", ".join(_split_style_values(getattr(self.args, "positive_styles", None))) or "none"),
            ("negative_style", ", ".join(_split_style_values(getattr(self.args, "negative_styles", None))) or "none"),
            ("vocal_language", getattr(self.args, "vocal_language", None) or "unknown"),
            ("enhance_prompt", "on" if bool(getattr(self.args, "enhance_prompt", False)) else "off"),
            ("structure_prompt", "on" if bool(getattr(self.args, "structure_prompt", True)) else "off"),
            ("text_planner", getattr(self.args, "text_planner", "deterministic") or "deterministic"),
            ("auto_lyrics", "on" if bool(getattr(self.args, "auto_lyrics", False)) else "off"),
            ("instrumental", "on" if bool(getattr(self.args, "instrumental", False)) else "off"),
            ("print_plan", "on" if bool(getattr(self.args, "print_plan", False)) else "off"),
            ("steps", getattr(self.args, "steps", None) or "auto"),
            ("seed", getattr(self.args, "seed", None) if getattr(self.args, "seed", None) is not None else "auto"),
            ("guidance", getattr(self.args, "guidance_scale", None) if getattr(self.args, "guidance_scale", None) is not None else "auto"),
            ("shift", getattr(self.args, "shift", 3.0)),
            ("verbose", "on" if bool(getattr(self.args, "verbose", False)) else "off"),
            ("lyrics", getattr(self.args, "lyrics", None) or "none"),
            ("negative", getattr(self.args, "negative", None) or "none"),
            ("out", self.out_dir),
            ("prefix", self.prefix),
        ]
        width = max(len(k) for k, _v in fields)
        for key, value in fields:
            print(f"{key.rjust(width)}: {value}")

    def do_models(self, arg: str = "") -> None:
        tokens = self._tokens(arg)
        task = tokens[0] if tokens else None
        reg = self._get_registry()
        current_engine = self._effective_engine()
        current_model = self._effective_model_id()

        rows: list[dict[str, str]] = []
        for spec in reg.list_models(task=task):
            engine = self._engine_for_model_id(spec.id) or ",".join(spec.backend_kinds) or "unknown"
            rec = "yes" if spec.recommended else "no"
            max_dur = f"{spec.max_duration_s:g}s" if spec.max_duration_s is not None else ""
            locality = "remote" if bool(spec.raw.get("remote", False)) else "local"
            marker = "*" if (str(spec.id) == str(current_model) and str(engine) == str(current_engine)) else " "
            rows.append(
                {
                    "sel": marker,
                    "engine": str(engine),
                    "model_id": str(spec.id),
                    "provider": str(spec.provider),
                    "rec": rec,
                    "status": str(spec.status),
                    "where": locality,
                    "max": max_dur,
                }
            )

        if not rows:
            print("No models found.")
            return

        rows.sort(key=lambda r: (r["engine"], r["rec"] != "yes", r["provider"].lower(), r["model_id"].lower()))
        w_engine = max(len(r["engine"]) for r in rows)
        w_model = max(len(r["model_id"]) for r in rows)
        w_provider = max(len(r["provider"]) for r in rows)
        w_status = max(len(r["status"]) for r in rows)
        w_where = max(len(r["where"]) for r in rows)
        w_max = max(len(r["max"]) for r in rows)

        print(
            f"  {'ENGINE'.ljust(w_engine)}  {'MODEL'.ljust(w_model)}  {'PROVIDER'.ljust(w_provider)}  REC  {'WHERE'.ljust(w_where)}  {'MAX'.ljust(w_max)}  {'STATUS'.ljust(w_status)}"
        )
        last_engine = None
        for r in rows:
            if last_engine is not None and r["engine"] != last_engine:
                print("")
            last_engine = r["engine"]
            print(
                f"{r['sel']} {r['engine'].ljust(w_engine)}  {r['model_id'].ljust(w_model)}  {r['provider'].ljust(w_provider)}  {r['rec'].rjust(3)}  {r['where'].ljust(w_where)}  {r['max'].ljust(w_max)}  {r['status'].ljust(w_status)}"
            )


def _cmd_repl(args: argparse.Namespace) -> int:
    MusicREPL(args).cmdloop()
    return 0


def main(argv: Optional[list[str]] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    try:
        if args.cmd == "t2m":
            return _cmd_t2m(args)
        if args.cmd in {"repl", "cli"}:
            return _cmd_repl(args)
    except AbstractMusicError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    raise SystemExit(f"Unknown command: {args.cmd}")


if __name__ == "__main__":
    raise SystemExit(main())
