"""Pet state machine — pure logic, no Qt/audio dependencies, so it's cheap
to unit test. The UI layer (ui/pet_window.py) reacts to state changes by
swapping the active sprite animation; the worker thread (ui/app.py) drives
transitions as the mic/wake/converse/tts pipeline progresses.
"""

from __future__ import annotations

from enum import Enum
from typing import Callable, Optional


class PetState(str, Enum):
    IDLE = "idle"           # waiting for the wake phrase (or a click)
    LISTENING = "listening"  # actively recording an utterance
    THINKING = "thinking"    # waiting on the server (STT done, converse in flight)
    TALKING = "talking"      # playing back the TTS reply
    ERROR = "error"          # brief flash state on failure, then back to idle


# States it's valid to move to from each state. Keeps ad-hoc bugs (e.g.
# firing TALKING before a reply exists) from silently passing through.
#
# IDLE -> TALKING is legal (not just IDLE -> LISTENING) because of proactive
# announcements: the heartbeat poll (controller.py's _maybe_heartbeat) can
# make the pet speak unprompted — a reminder firing, a nudge from the server
# — without the user having said anything first, so there's no preceding
# LISTENING/THINKING leg for that turn.
#
# TALKING -> THINKING is the mirror case: a `dialoguectl` scene is played
# *mid-turn*, while the server is still waiting on the tool result, so the pet
# talks and then goes back to waiting rather than falling to IDLE (which would
# make it look like the turn had ended).
_TRANSITIONS: dict[PetState, set[PetState]] = {
    PetState.IDLE: {PetState.LISTENING, PetState.TALKING, PetState.ERROR},
    PetState.LISTENING: {PetState.THINKING, PetState.IDLE, PetState.ERROR},
    PetState.THINKING: {PetState.TALKING, PetState.IDLE, PetState.ERROR},
    PetState.TALKING: {PetState.IDLE, PetState.THINKING, PetState.ERROR},
    PetState.ERROR: {PetState.IDLE},
}


class InvalidTransition(Exception):
    pass


class PetStateMachine:
    def __init__(self, on_change: Optional[Callable[[PetState, PetState], None]] = None):
        self._state = PetState.IDLE
        self._on_change = on_change

    @property
    def state(self) -> PetState:
        return self._state

    def transition(self, new_state: PetState) -> None:
        if new_state == self._state:
            return
        allowed = _TRANSITIONS.get(self._state, set())
        if new_state not in allowed:
            raise InvalidTransition(f"{self._state} -> {new_state} is not allowed")
        old_state = self._state
        self._state = new_state
        if self._on_change is not None:
            self._on_change(old_state, new_state)

    def force(self, new_state: PetState) -> None:
        """Bypass the transition table — used only for recovering to IDLE
        from an unexpected/edge-case state (e.g. after an exception mid
        pipeline). Prefer transition() everywhere else."""
        old_state = self._state
        if new_state == old_state:
            return
        self._state = new_state
        if self._on_change is not None:
            self._on_change(old_state, new_state)
