"""Tests for handle_device_event after dedup/throttle removal.

Contract:
- Client (firmware) owns event-id uniqueness + debounce.
- Every event fires BOTH set_emotion AND on_utterance.
- Reset-style events (caress_end/shake_end/sulking_timeout) resolve emotion
  via posture baseline AND fire utterance.
- BLE link state branches on payload.data.connected.
"""

from __future__ import annotations

import asyncio
from typing import Any

import pytest

from app.services.device_reactions import event_handler as eh
from app.services.device_reactions import reaction_map as rm

DEV = "dev-handler-test"


class _Capture:
    def __init__(self) -> None:
        self.set_emotion_calls: list[tuple[str, str, int]] = []
        self.vibration_calls: list[tuple[str, int]] = []
        self.utterances: list[str] = []
        self.postures: list[tuple[str, str]] = []

    async def fake_set_emotion(self, device_id, label, duration_ms):
        self.set_emotion_calls.append((device_id, label, duration_ms))

    async def fake_pulse_vibration(self, device_id, vib_ms):
        self.vibration_calls.append((device_id, vib_ms))

    def fake_set_posture(self, device_id, posture):
        self.postures.append((device_id, posture))

    async def fake_on_utterance(self, text: str):
        self.utterances.append(text)


@pytest.fixture
def cap(monkeypatch):
    c = _Capture()
    monkeypatch.setattr(eh, "set_emotion", c.fake_set_emotion)
    monkeypatch.setattr(eh, "pulse_vibration", c.fake_pulse_vibration)
    monkeypatch.setattr(
        eh.device_capabilities, "set_device_posture", c.fake_set_posture
    )
    return c


async def _run(params: dict[str, Any], on_utterance=None):
    """Run handler then flush emotion/vibration asyncio.create_task tasks."""
    await eh.handle_device_event(DEV, params, on_utterance)
    await asyncio.sleep(0)
    await asyncio.sleep(0)


# ---------- Module/symbol contracts ----------


def test_event_dedup_module_removed():
    """Phase-01 contract: server no longer dedupes/throttles events."""
    with pytest.raises(ImportError):
        from app.services.device_reactions import event_dedup  # noqa: F401


def test_known_ignored_events_symbol_removed():
    """Phase-02 contract: every event reacts, no skip-list."""
    assert not hasattr(rm, "KNOWN_IGNORED_EVENTS"), (
        "KNOWN_IGNORED_EVENTS must be removed — every event should react"
    )


# ---------- Every non-reset event fires set_emotion + utterance ----------

NON_RESET_EVENTS_AND_LABEL = [
    # Touch family
    ("touch", "happy"),
    ("hit", "sad"),
    ("caress_begin", "love"),
    ("shake_start", "shake"),
    # Battery
    ("charging_started", "happy"),
    ("charging_stopped", "idle"),
    ("low_battery_20", "sad"),
    # Sulking
    ("strong_shake", "shake"),
    ("knock", "sad"),
    ("sulking_3knock", "angry"),
    # BLE peer (self_identity only; ble_link_state tested separately)
    ("self_identity", "happy"),
    # IMU settled
    ("target0_settled", "idle"),
    ("target90_settled", "drowsy"),
    ("target180_settled", "suspect"),
    # IMU transitions + motion
    ("motion_detected", "question"),
    ("target0_enter", "idle"),
    ("target0_exit", "suspect"),
    ("target90_enter", "drowsy"),
    ("target90_exit", "suspect"),
    ("target180_enter", "suspect"),
    ("target180_exit", "suspect"),
]


@pytest.mark.parametrize("event,expected_label", NON_RESET_EVENTS_AND_LABEL)
async def test_event_fires_emotion_and_utterance(cap, event, expected_label):
    await _run(
        {"source": "test", "event": event, "event_id": f"id-{event}"},
        on_utterance=cap.fake_on_utterance,
    )
    assert any(label == expected_label for _, label, _ in cap.set_emotion_calls), (
        f"{event} did not fire emotion={expected_label}; got {cap.set_emotion_calls}"
    )
    assert cap.utterances, f"{event} did not fire utterance"


# ---------- Reset-style events resolve to posture baseline + fire utterance ----------

RESET_EVENTS_AND_POSTURE_BASELINE = [
    ("caress_end", "standing", "idle"),
    ("shake_end", "lying_back", "drowsy"),
    ("sulking_timeout", "lying_face_down", "suspect"),
]


@pytest.mark.parametrize(
    "event,posture,expected_label", RESET_EVENTS_AND_POSTURE_BASELINE
)
async def test_reset_event_fires_baseline_and_utterance(
    cap, monkeypatch, event, posture, expected_label
):
    monkeypatch.setattr(
        eh.device_capabilities, "get_device_posture", lambda _device_id: posture
    )
    await _run(
        {"source": "test", "event": event, "event_id": f"id-{event}"},
        on_utterance=cap.fake_on_utterance,
    )
    assert any(label == expected_label for _, label, _ in cap.set_emotion_calls), (
        f"{event} did not reset to baseline={expected_label}; got {cap.set_emotion_calls}"
    )
    assert cap.utterances, f"{event} did not fire utterance"


async def test_reset_event_no_posture_falls_back_to_default(cap, monkeypatch):
    monkeypatch.setattr(eh.device_capabilities, "get_device_posture", lambda _d: None)
    await _run(
        {"source": "test", "event": "caress_end", "event_id": "x"},
        on_utterance=cap.fake_on_utterance,
    )
    assert cap.set_emotion_calls == [(DEV, "idle", 0)]
    assert cap.utterances


# ---------- BLE synthetic-key routing ----------


async def test_ble_link_state_connected_routes_to_synthetic_key(cap):
    await _run(
        {
            "source": "ble",
            "event": "ble_link_state",
            "event_id": "ble-c",
            "data": {"connected": True},
        },
        on_utterance=cap.fake_on_utterance,
    )
    assert any(label == "happy" for _, label, _ in cap.set_emotion_calls)
    assert cap.utterances


async def test_ble_link_state_disconnected_routes_to_synthetic_key(cap):
    await _run(
        {
            "source": "ble",
            "event": "ble_link_state",
            "event_id": "ble-d",
            "data": {"connected": False},
        },
        on_utterance=cap.fake_on_utterance,
    )
    assert any(label == "idle" for _, label, _ in cap.set_emotion_calls)
    assert cap.utterances


async def test_ble_link_state_missing_data_defaults_to_disconnected(cap):
    await _run(
        {"source": "ble", "event": "ble_link_state", "event_id": "ble-x"},
        on_utterance=cap.fake_on_utterance,
    )
    assert any(label == "idle" for _, label, _ in cap.set_emotion_calls)


# ---------- Unknown events still warn ----------


async def test_unknown_event_no_reaction(cap):
    await _run(
        {"source": "?", "event": "totally_made_up", "event_id": "e99"},
        on_utterance=cap.fake_on_utterance,
    )
    assert cap.set_emotion_calls == []
    assert cap.utterances == []


# ---------- Posture side-effect ----------


async def test_posture_event_updates_posture(cap):
    await _run(
        {"source": "qmi8658", "event": "target0_settled", "event_id": "p1"},
        on_utterance=cap.fake_on_utterance,
    )
    assert (DEV, "standing") in cap.postures


# ---------- Map structural integrity ----------

ALL_EVENTS = {
    # Touch
    "touch",
    "hit",
    "caress_begin",
    "caress_end",
    "shake_start",
    "shake_end",
    # Battery
    "charging_started",
    "charging_stopped",
    "low_battery_20",
    # Sulking
    "strong_shake",
    "knock",
    "sulking_3knock",
    "sulking_timeout",
    # BLE
    "ble_link_state_connected",
    "ble_link_state_disconnected",
    "self_identity",
    # IMU settled
    "target0_settled",
    "target90_settled",
    "target180_settled",
    # IMU transitions + motion
    "motion_detected",
    "target0_enter",
    "target0_exit",
    "target90_enter",
    "target90_exit",
    "target180_enter",
    "target180_exit",
}


def test_all_events_have_utterance():
    missing = ALL_EVENTS - set(rm.EVENT_UTTERANCES)
    assert not missing, f"events missing utterance: {missing}"


def test_non_reset_events_have_physical():
    """Reset-style events resolve emotion via posture baseline at runtime —
    they do NOT need an EVENT_PHYSICAL entry. Everyone else must."""
    non_reset = ALL_EVENTS - rm.EMOTION_RESET_EVENTS
    missing = non_reset - set(rm.EVENT_PHYSICAL)
    assert not missing, f"non-reset events missing physical: {missing}"


def test_reset_events_exact_set():
    assert rm.EMOTION_RESET_EVENTS == frozenset(
        {"caress_end", "shake_end", "sulking_timeout"}
    )


# ---------- Uncovered handler branches ----------


async def test_on_utterance_none_warns_but_still_fires_emotion(cap, monkeypatch):
    """When on_utterance is None, emotion + vibration still fire; utterance dropped."""
    warnings: list[str] = []
    monkeypatch.setattr(
        eh.logger,
        "warning",
        lambda msg, *args, **kw: warnings.append(
            msg.format(*args, **kw) if args or kw else msg
        ),
    )
    await _run(
        {"source": "test", "event": "touch", "event_id": "u-none"},
        on_utterance=None,
    )
    assert any(label == "happy" for _, label, _ in cap.set_emotion_calls)
    assert any("dropped" in w for w in warnings), (
        f"expected dropped warning, got: {warnings}"
    )


async def test_handler_swallows_unexpected_exception(cap, monkeypatch):
    """Exception path must be caught and logged via loguru (no re-raise)."""
    errors: list[str] = []

    def boom(_seq):
        raise RuntimeError("boom")

    monkeypatch.setattr(
        "app.services.device_reactions.event_handler.random.choice", boom
    )

    # Capture loguru .opt(exception=True).error(...) — opt returns a proxy with .error
    class _OptProxy:
        def error(self, msg, *args, **kw):
            errors.append(msg.format(*args, **kw) if args or kw else msg)

    monkeypatch.setattr(eh.logger, "opt", lambda **kw: _OptProxy())
    # Must not raise.
    await _run(
        {"source": "test", "event": "touch", "event_id": "exc-1"},
        on_utterance=cap.fake_on_utterance,
    )
    assert any("unexpected error" in e for e in errors), (
        f"expected error log, got: {errors}"
    )
