"""`dialoguectl` — multi-voice scene parsing, voice resolution, API limits,
and the request the ElevenLabs Text to Dialogue endpoint actually gets.

Pure logic plus one mocked HTTP call: no audio device, no network, no display.
"""

import sys
from pathlib import Path
from unittest.mock import MagicMock, patch

import numpy as np
import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from bolt_pet import dialogue
from bolt_pet.audio import tts

SELF_ID = "aaorr6ZHIL88gEexu7dC"
NARRATOR_ID = "9BWtsMINqrJLrRacOk9x"
VILLAIN_ID = "IKne3meq5aSn9XLyUdCD"
VOICES = {"narrator": NARRATOR_ID, "villain": VILLAIN_ID}


def _scene(*lines):
    return '{"lines": [' + ", ".join(lines) + "]}"


# ── parsing ─────────────────────────────────────────────────────────────────

def test_non_dialogue_commands_are_left_alone():
    assert dialogue.parse("ls -la") is None
    assert dialogue.parse('filectl {"op": "list"}') is None
    assert dialogue.parse("") is None
    # "dialogues" must not be mistaken for the "dialogue" prefix
    assert dialogue.parse("dialogues --list") is None


def test_a_scene_parses_into_lines():
    action = dialogue.parse(
        'dialoguectl ' + _scene(
            '{"voice": "self", "text": "[cheerfully] Hello, how are you?"}',
            '{"voice": "villain", "text": "[stuttering] I am... fine."}',
        )
    )
    assert action["action"] == "dialogue"
    assert [line["voice"] for line in action["lines"]] == ["self", "villain"]
    assert action["lines"][0]["text"].startswith("[cheerfully]")


def test_the_elevenlabs_field_names_are_accepted_too():
    """The model has read that API; copying its shape is the obvious thing to
    try, so 'inputs'/'voice_id' work as well as 'lines'/'voice'."""
    action = dialogue.parse(
        'dialoguectl {"inputs": [{"voice_id": "%s", "text": "hi"}]}' % NARRATOR_ID
    )
    assert action["lines"] == [{"voice": NARRATOR_ID, "text": "hi"}]


def test_a_line_with_no_voice_defaults_to_the_pet_itself():
    action = dialogue.parse('dialoguectl {"lines": [{"text": "just me talking"}]}')
    assert action["lines"][0]["voice"] == "self"


def test_truncated_json_explains_the_one_line_rule():
    """The real failure mode: the server's command extractor stops at the
    first newline, so a multi-line payload arrives cut in half. The error has
    to name the cause, since Bolt is the one who has to fix it."""
    with pytest.raises(dialogue.DialogueError, match="one line"):
        dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"')


def test_an_empty_or_shapeless_payload_is_rejected():
    with pytest.raises(dialogue.DialogueError, match="needs a JSON argument"):
        dialogue.parse("dialoguectl")
    with pytest.raises(dialogue.DialogueError, match="non-empty"):
        dialogue.parse('dialoguectl {"lines": []}')
    with pytest.raises(dialogue.DialogueError, match="no text"):
        dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "  "}]}')


def test_optional_model_and_stability_ride_along():
    action = dialogue.parse(
        'dialoguectl {"model_id": "eleven_v3", "stability": 0.8, '
        '"lines": [{"text": "hi"}]}'
    )
    assert action["model"] == "eleven_v3"
    assert action["stability"] == 0.8


# ── voice resolution ────────────────────────────────────────────────────────

def test_named_voices_resolve_from_the_configured_cast():
    action = dialogue.parse('dialoguectl ' + _scene(
        '{"voice": "narrator", "text": "Once upon a time."}',
        '{"voice": "villain", "text": "Not this again."}',
    ))
    inputs = dialogue.resolve(action, voices=VOICES, self_voice=SELF_ID)
    assert [entry["voice_id"] for entry in inputs] == [NARRATOR_ID, VILLAIN_ID]


def test_self_tracks_the_voice_the_pet_is_currently_using():
    """A scene featuring Bolt should sound like whoever Bolt currently is —
    including a voice the server picked mid-conversation with speak_as."""
    action = dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"}]}')
    picked = "VOICEfromSPEAKas1234"
    assert dialogue.resolve(action, self_voice=picked)[0]["voice_id"] == picked


def test_a_raw_voice_id_passes_straight_through():
    action = dialogue.parse('dialoguectl {"lines": [{"voice": "%s", "text": "hi"}]}' % NARRATOR_ID)
    assert dialogue.resolve(action, self_voice=SELF_ID)[0]["voice_id"] == NARRATOR_ID


def test_an_unknown_name_lists_what_is_available():
    action = dialogue.parse('dialoguectl {"lines": [{"voice": "wizard", "text": "hi"}]}')
    with pytest.raises(dialogue.DialogueError) as excinfo:
        dialogue.resolve(action, voices=VOICES, self_voice=SELF_ID)
    message = str(excinfo.value)
    assert "wizard" in message and "narrator" in message and "villain" in message


def test_self_without_a_configured_voice_says_so():
    action = dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"}]}')
    with pytest.raises(dialogue.DialogueError, match="ELEVENLABS_VOICE_ID"):
        dialogue.resolve(action, self_voice="")


def test_the_voice_map_parser_skips_typos_instead_of_dying():
    voices = dialogue.parse_voice_map(f"narrator:{NARRATOR_ID}, broken-entry, villain:{VILLAIN_ID}")
    assert voices == {"narrator": NARRATOR_ID, "villain": VILLAIN_ID}
    assert dialogue.parse_voice_map("") == {}


# ── API limits, enforced before the request goes out ────────────────────────

def test_too_many_distinct_voices_is_refused_locally():
    inputs = [{"text": "hi", "voice_id": f"voice{index:015d}"} for index in range(11)]
    with pytest.raises(dialogue.DialogueError, match="limit is 10"):
        dialogue.check_limits(inputs)


def test_an_over_long_scene_is_refused_with_advice():
    inputs = [{"text": "x" * 1100, "voice_id": SELF_ID} for _ in range(2)]
    with pytest.raises(dialogue.DialogueError) as excinfo:
        dialogue.check_limits(inputs)
    assert "Split it" in str(excinfo.value)  # actionable, since Bolt reads this


# ── display / reporting ─────────────────────────────────────────────────────

def test_delivery_tags_are_stripped_from_what_the_bubble_shows():
    action = dialogue.parse('dialoguectl ' + _scene(
        '{"voice": "self", "text": "[cheerfully] Hello there!"}',
        '{"voice": "narrator", "text": "[whispering] He is lying."}',
    ))
    assert dialogue.spoken_text(action) == "Hello there! He is lying."


def test_the_relay_report_names_the_cast():
    action = dialogue.parse('dialoguectl ' + _scene(
        '{"voice": "self", "text": "one"}', '{"voice": "narrator", "text": "two"}',
    ))
    assert dialogue.describe(action) == "[dialogue] played 2 lines in 2 voices: narrator, self"


# ── the HTTP request ────────────────────────────────────────────────────────

def _pcm_response(samples=(1, 2, 3, 4)):
    response = MagicMock()
    response.content = np.array(samples, dtype=np.int16).tobytes()
    response.raise_for_status = MagicMock()
    return response


def test_the_request_matches_the_text_to_dialogue_api(monkeypatch):
    monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key")
    monkeypatch.setattr(tts.config, "TTS_SAMPLE_RATE", 24000)
    monkeypatch.setattr(tts.config, "DIALOGUE_MODEL_ID", "eleven_v3")
    inputs = [
        {"text": "[cheerfully] Hello", "voice_id": NARRATOR_ID},
        {"text": "[stuttering] H-hi", "voice_id": VILLAIN_ID},
    ]
    with patch.object(tts.requests, "post", return_value=_pcm_response()) as post:
        pcm, rate = tts.synthesize_dialogue(inputs)

    assert rate == 24000 and pcm.tolist() == [1, 2, 3, 4]
    args, kwargs = post.call_args
    assert args[0] == "https://api.elevenlabs.io/v1/text-to-dialogue"
    assert kwargs["params"] == {"output_format": "pcm_24000"}
    assert kwargs["headers"] == {"xi-api-key": "test-key"}
    assert kwargs["json"]["inputs"] == inputs
    assert kwargs["json"]["model_id"] == "eleven_v3"
    assert "settings" not in kwargs["json"]  # omitted unless asked for


def test_stability_is_only_sent_when_given(monkeypatch):
    monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key")
    with patch.object(tts.requests, "post", return_value=_pcm_response()) as post:
        tts.synthesize_dialogue([{"text": "hi", "voice_id": SELF_ID}], stability=0.3)
    assert post.call_args.kwargs["json"]["settings"] == {"stability": 0.3}


def test_a_rejected_request_surfaces_what_the_api_said(monkeypatch):
    """The API explains refusals in the body; Bolt reads this through the tool
    relay, so it has to reach him rather than being flattened to '422'."""
    monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key")
    failure = MagicMock()
    failure.text = '{"detail": "voice_id not found"}'
    error = Exception("422 Client Error")
    error.response = failure
    response = MagicMock()
    response.raise_for_status = MagicMock(side_effect=error)

    with patch.object(tts.requests, "post", return_value=response):
        with pytest.raises(tts.TtsError, match="voice_id not found"):
            tts.synthesize_dialogue([{"text": "hi", "voice_id": "nope"}])


def test_no_api_key_fails_before_the_request(monkeypatch):
    monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "")
    with patch.object(tts.requests, "post") as post:
        with pytest.raises(tts.TtsError):
            tts.synthesize_dialogue([{"text": "hi", "voice_id": SELF_ID}])
    post.assert_not_called()
