"""Window-side behaviour for the newer features: emote curves, petctl
actions, edge snapping, napping, click-through.

Needs a QApplication — run with QT_QPA_PLATFORM=offscreen.
"""

import sys
from pathlib import Path

import pytest
from PySide6.QtCore import QPoint
from PySide6.QtWidgets import QApplication

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

from bolt_pet import config
from bolt_pet.state import PetState
from bolt_pet.ui.pet_window import (
    _EMOTE_TICKS, _WALK_PIXELS_PER_FRAME, PetWindow, emote_transform,
)
from bolt_pet.ui.sprite import WALK


@pytest.fixture(scope="module")
def qt_app():
    yield QApplication.instance() or QApplication([])


@pytest.fixture
def pet(qt_app):
    window = PetWindow()
    yield window
    window.close()


# ── emote curves (pure maths) ───────────────────────────────────────────────

@pytest.mark.parametrize("emote", ["wave", "hop", "bounce", "spin", "nod", "shake", "wiggle"])
def test_every_emote_returns_the_sprite_to_rest(emote):
    # Anything that doesn't land back at the identity transform leaves the pet
    # permanently askew. (approx: the sine curves land on ~1e-16, not 0.0.)
    rest = pytest.approx((0.0, 0.0, 0.0, 1.0), abs=1e-9)
    assert emote_transform(emote, 1.0) == rest
    assert emote_transform(emote, 0.0) == rest


def test_emotes_actually_move_the_sprite_mid_animation():
    for emote in ("wave", "hop", "spin", "nod", "shake"):
        samples = [emote_transform(emote, i / 20) for i in range(1, 20)]
        assert any(sample != (0.0, 0.0, 0.0, 1.0) for sample in samples), emote


def test_an_unknown_emote_is_a_no_op_not_a_crash():
    assert emote_transform("moonwalk", 0.5) == (0.0, 0.0, 0.0, 1.0)


def test_progress_is_clamped():
    assert emote_transform("hop", 5.0) == emote_transform("hop", 1.0)
    assert emote_transform("hop", -3.0) == emote_transform("hop", 0.0)


def test_an_emote_finishes_and_clears_itself(pet):
    pet.start_emote("spin")
    assert pet._emote == "spin"
    for _ in range(_EMOTE_TICKS + 2):
        pet._advance_emote()
    assert pet._emote is None


# ── petctl actions ──────────────────────────────────────────────────────────

def test_move_action_sets_a_walk_target(pet):
    pet.apply_action({"action": "move", "anchor": "top-left"})
    assert pet._wander_target is not None
    assert pet._commanded_move is True


def test_commanded_moves_happen_even_while_talking(pet, monkeypatch):
    monkeypatch.setattr(config, "PET_EDGE_SNAP", False)  # snapping would move it again on arrival
    pet.set_state(PetState.TALKING)
    pet.apply_action({"action": "move", "anchor": "top-left"})
    target = pet._wander_target
    if target is None:
        pytest.skip("no usable screen geometry on this host")
    for _ in range(2000):
        pet._wander_tick()
        if pet._wander_target is None:
            break
    assert pet.pos() == target


def test_a_commanded_move_survives_the_reply_arriving(pet):
    # Real ordering: `petctl move` comes back as a tool call mid-turn, then
    # the reply flips the pet to TALKING a moment later. That must not cancel
    # the walk it was just told to make.
    pet.apply_action({"action": "move", "anchor": "center"})
    pet.set_state(PetState.TALKING)
    assert pet._wander_target is not None


def test_move_to_explicit_coordinates_is_clamped_on_screen(pet):
    pet.apply_action({"action": "move", "x": -5000, "y": -5000})
    geo = pet._screen_geometry()
    if geo is None:
        pytest.skip("no usable screen geometry on this host")
    assert pet._wander_target.x() >= geo.left()
    assert pet._wander_target.y() >= geo.top()


def test_say_action_shows_the_bubble(pet):
    pet.apply_action({"action": "say", "text": "build is green"})
    assert pet._bubble.text == "build is green"


def test_wander_and_nap_actions(pet):
    pet.apply_action({"action": "wander", "enabled": False})
    assert pet._wander_enabled is False
    pet.apply_action({"action": "nap", "enabled": True})
    assert pet.napping is True


def test_emote_action_starts_the_emote(pet):
    pet.apply_action({"action": "emote", "emote": "wave"})
    assert pet._emote == "wave"


# ── napping ─────────────────────────────────────────────────────────────────

def test_napping_dims_the_pet_and_stops_it_wandering(pet):
    pet.set_napping(True)
    assert pet.windowOpacity() < 1.0
    start = pet.pos()
    pet.wander_now()
    for _ in range(60):
        pet._wander_tick()
    assert pet.pos() == start

    pet.set_napping(False)
    assert pet.windowOpacity() == 1.0


# ── edge snapping ───────────────────────────────────────────────────────────

def test_snaps_flush_when_parked_near_an_edge(pet, monkeypatch):
    geo = pet._screen_geometry()
    if geo is None:
        pytest.skip("no usable screen geometry on this host")
    monkeypatch.setattr(config, "PET_EDGE_SNAP", True)
    pet.move(geo.left() + 10, geo.top() + 10)
    assert pet.snap_to_edge() is True
    assert pet.pos() == QPoint(geo.left(), geo.top())


def test_does_not_snap_from_the_middle_of_the_screen(pet, monkeypatch):
    geo = pet._screen_geometry()
    if geo is None:
        pytest.skip("no usable screen geometry on this host")
    monkeypatch.setattr(config, "PET_EDGE_SNAP", True)
    middle = QPoint(geo.left() + geo.width() // 2, geo.top() + geo.height() // 2)
    pet.move(middle)
    assert pet.snap_to_edge() is False
    assert pet.pos() == middle


def test_snapping_can_be_turned_off(pet, monkeypatch):
    geo = pet._screen_geometry()
    if geo is None:
        pytest.skip("no usable screen geometry on this host")
    monkeypatch.setattr(config, "PET_EDGE_SNAP", False)
    pet.move(geo.left() + 10, geo.top() + 10)
    assert pet.snap_to_edge() is False


# ── click-through ───────────────────────────────────────────────────────────

def test_click_through_toggles_mouse_transparency(pet):
    from PySide6.QtCore import Qt

    pet.set_click_through(True)
    assert pet.click_through is True
    assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is True

    pet.set_click_through(False)
    assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is False


# ── monitors ────────────────────────────────────────────────────────────────

def test_window_publishes_a_monitor_list(pet):
    """Whatever the test host's screen setup is, the window must describe it
    in the shape the controller expects."""
    monitors = pet.monitors()
    assert monitors, "offscreen Qt still reports at least one screen"
    assert [m.index for m in monitors] == list(range(len(monitors)))
    assert all(m.width > 0 and m.height > 0 for m in monitors)
    assert all(m.name for m in monitors)
    assert sum(1 for m in monitors if m.primary) <= 1


def test_publish_monitors_re_emits_when_forced(pet):
    """ui/app.py relies on this: the window is built before the controller
    exists, so its constructor's publish reaches nobody and has to be redone."""
    seen = []
    pet.monitors_changed.connect(seen.append)
    pet.publish_monitors()  # force defaults to True
    assert len(seen) == 1
    pet.publish_monitors(force=False)  # nothing changed -> stays quiet
    assert len(seen) == 1


def test_pet_reports_which_monitor_it_is_on(pet):
    seen = []
    pet.pet_monitor_changed.connect(seen.append)
    pet.publish_monitors()
    assert seen and seen[-1] == pet.current_monitor_index()
    assert 0 <= seen[-1] < len(pet.monitors())


def test_jump_moves_the_window_onto_the_target_screen(pet):
    monitors = pet.monitors()
    target = len(monitors) - 1
    pet.apply_action({"action": "jump", "monitor": target})
    assert pet.current_monitor_index() == target
    # a jump lands with a hop rather than sliding there
    assert pet._emote == "hop"


def test_jump_cancels_a_stroll_so_it_does_not_walk_back(pet):
    pet.apply_action({"action": "move", "anchor": "top-left"})
    assert pet._wander_target is not None
    pet.apply_action({"action": "jump", "monitor": 0})
    assert pet._wander_target is None
    assert pet._commanded_move is False


def test_jump_to_a_bogus_index_is_a_no_op(pet):
    before = pet.pos()
    pet.apply_action({"action": "jump", "monitor": 99})
    pet.apply_action({"action": "jump", "monitor": -1})
    assert pet.pos() == before


# ── walk cycle ──────────────────────────────────────────────────────────────

def test_walk_art_loads_as_a_non_state_animation(pet):
    """Walking is a property of movement, not a PetState, so it lives outside
    the state machine but still loads like any other animation."""
    assert pet.sprites.has(WALK)
    assert len(pet.sprites.get(WALK).frames) == 8
    assert pet.sprites.get("nonsense") is pet.sprites.get(PetState.IDLE)
    assert not pet.sprites.has("nonsense")


def test_walking_overrides_the_state_animation(pet):
    assert pet._animation_key() == pet._current_state
    pet._advance_walk(50, 0, 1.0)
    assert pet._animation_key() == WALK


def test_walk_cycle_advances_by_distance_not_by_the_clock(pet):
    """The planted paw tracks backwards at the speed the window moves
    forwards; drive it off the animation timer instead and the feet skate."""
    anim = pet.sprites.get(WALK)
    anim.reset()
    pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 3)
    assert anim._index == 3
    # a step too small to cross the threshold banks the distance instead
    pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 0.5)
    assert anim._index == 3
    pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 0.5)
    assert anim._index == 4


def test_the_animation_timer_does_not_double_step_the_walk(pet):
    anim = pet.sprites.get(WALK)
    pet._advance_walk(50, 0, 1.0)
    anim.reset()
    pet._advance_frame()
    assert anim._index == 0


def test_facing_follows_horizontal_travel(pet):
    pet._advance_walk(50, 0, 1.0)
    assert pet._facing == 1
    pet._advance_walk(-50, 0, 1.0)
    assert pet._facing == -1


def test_a_near_vertical_stroll_does_not_flip_him(pet):
    """Rounding noise on dx would otherwise flip him back and forth every
    tick on a straight-up walk."""
    pet._facing = 1
    pet._advance_walk(0.4, 60, 1.0)
    assert pet._facing == 1


def test_walking_left_paints_a_mirrored_frame(pet):
    frame = pet.sprites.get(WALK).current()
    pet._facing = 1
    assert pet._oriented(frame) is frame  # art is drawn facing right
    pet._facing = -1
    flipped = pet._oriented(frame)
    assert flipped is not frame
    assert flipped.size() == frame.size()
    assert pet._oriented(frame) is flipped  # cached, not re-flipped per paint


def test_stopping_resets_the_cycle_to_a_standing_frame(pet):
    pet._advance_walk(50, 0, _WALK_PIXELS_PER_FRAME * 2)
    assert pet._walking
    pet._stop_walking()
    assert not pet._walking
    assert pet._walk_distance == 0.0
    assert pet.sprites.get(WALK)._index == 0
    assert pet._animation_key() == pet._current_state


def test_walk_art_suppresses_the_hard_coded_bob(pet):
    """The frames carry their own weight shift — bobbing the window as well
    would double it up."""
    pet._advance_walk(50, 0, 5.0)
    assert pet._bob_offset == 0


def test_without_walk_art_it_falls_back_to_the_old_bob(qt_app, tmp_path):
    window = PetWindow(sprite_dir=tmp_path)
    try:
        assert not window.sprites.has(WALK)
        window._advance_walk(50, 0, 5.0)
        assert window._walking
        assert window._animation_key() == window._current_state
        assert window._bob_offset < 0  # still visibly moving
    finally:
        window.close()
