"""Everything the pet says, and the policy differences between kinds of saying.

There used to be four of these in controller.py — a reply, a holding line, a
streamed sentence, a dialogue scene — each written when its feature was built,
each repeating the same dance: transition state, show the bubble, maybe record
history, reset barge-in, call TTS, reset barge-in again, resume state, decide
whether to keep the mic open. Only the *policy* differed, and the copies had
already started to drift: one forgot to arm barge-in, another logged a
different prefix, a third skipped the follow-up rule.

So the dance lives here once, and the differences are data:

    reply        the answer. Transcript, bubble, follow-up rule, ends IDLE.
    holding      "give me a sec" while a tool runs. No transcript — it is
                 filler, and the transcript should keep the answer. Resumes
                 whatever state it interrupted, because the turn isn't over.
    stream       one sentence of a streamed answer. Transcript and bubble like
                 a reply, but stays TALKING so the sprite doesn't flicker
                 between sentences, and the follow-up rule waits for the last.
    scene        a dialoguectl take. Transcript (the user heard it), resumes
                 mid-turn like a holding line.

The controller keeps the pipeline; this keeps the rules about talking.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Callable, Optional

from . import config, speech_text
from .state import PetState


@dataclass(frozen=True)
class Utterance:
    """One thing to say, and how saying it should behave."""

    text: str
    record: bool = True         # goes in the transcript, or is it filler?
    resume: bool = False        # return to the state it interrupted (mid-turn)
    hold_talking: bool = False  # stay TALKING afterwards (more is coming)
    follow_up: bool = True      # may leave the mic open if it ends on a question
    interruptible: bool = True  # arm barge-in for this one
    log_prefix: str = "Bolt"

    @classmethod
    def reply(cls, text: str) -> "Utterance":
        return cls(text)

    @classmethod
    def holding(cls, text: str) -> "Utterance":
        # Filler: no transcript, no follow-up, and not interruptible — cutting
        # off "give me a sec" would strand the tool that is already running.
        return cls(text, record=False, resume=True, follow_up=False,
                   interruptible=False, log_prefix="Bolt (holding)")

    @classmethod
    def stream_chunk(cls, text: str) -> "Utterance":
        return cls(text, hold_talking=True, follow_up=False)

    @classmethod
    def scene(cls, text: str) -> "Utterance":
        return cls(text, resume=True, follow_up=False)


class Speaker:
    """Says things on behalf of the controller.

    Collaborators are passed in rather than reached for, so the whole of
    speaking is testable without Qt, audio hardware or a state machine: hand it
    fakes and assert on what came out."""

    def __init__(self, *, state, tts, history=None, on_said=None, on_log=None,
                 barge_in: Callable[[], object] = lambda: None,
                 voice_id: Callable[[], str] = lambda: "",
                 detail_of: Callable[[], str] = lambda: "",
                 on_level: Optional[Callable[[float], None]] = None):
        self._state = state
        self._tts = tts
        self._history = history
        self._on_said = on_said or (lambda _text: None)
        self._on_log = on_log or (lambda _msg: None)
        # Read through a callable rather than held: the detector is built after
        # the speaker (it needs the mic stream), replaced when barge-in mode
        # changes, and swapped by tests. Two copies of it drifting apart is a
        # bug nobody notices until the wake model starts hearing the pet.
        self._barge_in_of = barge_in
        self._voice_id = voice_id
        # What actually fired, captured BEFORE the post-playback reset. Read it
        # afterwards and every interruption reports 0.000 at frame 0 — which
        # looks like hard evidence and is nothing of the sort.
        self._detail_of = detail_of
        self.last_detail = ""
        self._on_level = on_level

    @property
    def _barge_in(self):
        try:
            return self._barge_in_of()
        except Exception:
            return None

    def say(self, utterance: Utterance) -> bool:
        """Speak it. Returns False if it was interrupted.

        The one place that knows the order these steps go in — which is the
        point, because getting that order wrong is invisible until the wake
        model starts hearing the pet's own voice."""
        line = speech_text.for_display(utterance.text)
        if not line:
            return True

        resume_state = self._state.state
        if self._state.state != PetState.TALKING:
            self._state.transition(PetState.TALKING)
        self._on_said(line)
        self._on_log(f"{utterance.log_prefix}: {line}")
        if utterance.record and self._history is not None:
            self._history(utterance.text)

        should_stop = None
        if self._barge_in is not None and utterance.interruptible:
            self._barge_in.reset()
            should_stop = self._barge_in.check

        completed = self._tts.speak(
            utterance.text,
            on_error=lambda exc: self._on_log(f"TTS failed: {exc}"),
            should_stop=should_stop,
            voice_id=self._voice_id() or None,
            on_level=self._on_level,
        )
        self.last_detail = self._detail_of()
        if self._barge_in is not None:
            # Playback fed the pet's own voice into the wake model's rolling
            # window. Clear it before the idle listener scores again, or the
            # last sentence is still in there being re-heard.
            self._barge_in.reset()
        if self._on_level is not None:
            self._on_level(0.0)  # mouth closed; nothing is playing now

        if utterance.resume and resume_state in (PetState.THINKING, PetState.IDLE):
            self._state.transition(resume_state)
        return bool(completed)


    def say_pcm(self, utterance: Utterance, *, pcm, sample_rate: int) -> bool:
        """Speak audio that is already synthesized — a dialoguectl scene.

        Same policy, same barge-in handling, same mouth; only the source of
        the samples differs. Sharing this is why a scene can be talked over
        exactly like an ordinary reply."""
        line = speech_text.for_display(utterance.text)
        resume_state = self._state.state
        if self._state.state != PetState.TALKING:
            self._state.transition(PetState.TALKING)
        if line:
            self._on_said(line)
        self._on_log(f"{utterance.log_prefix}: {line}")
        if utterance.record and self._history is not None:
            self._history(utterance.text)

        should_stop = None
        if self._barge_in is not None and utterance.interruptible:
            self._barge_in.reset()
            should_stop = self._barge_in.check
        completed = self._tts.play_pcm(
            pcm, sample_rate, should_stop=should_stop, on_level=self._on_level)
        self.last_detail = self._detail_of()
        if self._barge_in is not None:
            self._barge_in.reset()
        if self._on_level is not None:
            self._on_level(0.0)
        if utterance.resume and resume_state in (PetState.THINKING, PetState.IDLE):
            self._state.transition(resume_state)
        return bool(completed)


def follow_up_decision(text: str, *, completed: bool, follow_ups: int) -> tuple[bool, str]:
    """Whether to keep listening after speaking, and why.

    Split out as a pure function because it is a *rule* with an off-by-one cap
    in it, and rules with counters deserve a test that doesn't need audio."""
    if not completed:
        return True, "interrupted"
    if not speech_text.is_question(text):
        return False, ""
    cap = config.FOLLOW_UP_MAX_TURNS
    if not config.FOLLOW_UP_LISTEN:
        return False, ""
    if cap > 0 and follow_ups >= cap:
        return False, f"follow-up cap ({cap}) reached"
    return True, "question"
