"""Tests for the hardware UI layer — footswitch, LEDs, display.

Uses mock GPIO/LED/display so tests run on any machine.
"""

from __future__ import annotations

import time
from unittest.mock import MagicMock, PropertyMock, patch

import pytest

from src.ui.footswitch import (
    DEBOUNCE_MS,
    LONG_PRESS_MS,
    FootSwitch,
    FootswitchController,
    SwitchAction,
)
from src.ui.leds import LEDConfig, LEDController, LEDDriver, LEDPattern
from src.ui.display import DisplayController, DisplayState, DISPLAY_W, DISPLAY_H


# ============================================================
# Footswitch tests
# ============================================================


class TestFootSwitch:
    """FootSwitch dataclass construction."""

    def test_basic_switch(self):
        sw = FootSwitch(17, SwitchAction.PRESET_UP, SwitchAction.TAP_TEMPO)
        assert sw.gpio_pin == 17
        assert sw.action_default == SwitchAction.PRESET_UP
        assert sw.action_long_press == SwitchAction.TAP_TEMPO
        assert sw.active_low is True

    def test_default_active_low(self):
        sw = FootSwitch(22, SwitchAction.BYPASS)
        assert sw.active_low is True

    def test_no_long_press(self):
        sw = FootSwitch(22, SwitchAction.BYPASS)
        assert sw.action_long_press is None


class TestFootswitchController:
    """FootswitchController — debounce, long-press, callbacks."""

    def test_default_layout_has_4_switches(self):
        ctrl = FootswitchController()
        assert len(ctrl._switches) == 4

    def test_register_and_fire_callback(self):
        ctrl = FootswitchController()
        fired = []

        def cb():
            fired.append("bye")

        ctrl.register_callback(SwitchAction.BYPASS, cb)
        ctrl._trigger(SwitchAction.BYPASS)
        assert fired == ["bye"]

    def test_callback_error_does_not_crash(self):
        ctrl = FootswitchController()

        def broken():
            raise ValueError("boom")

        def ok():
            pass

        ctrl.register_callback(SwitchAction.BYPASS, broken)
        ctrl.register_callback(SwitchAction.BYPASS, ok)
        ctrl._trigger(SwitchAction.BYPASS)  # should not raise

    def test_simulate_press_triggers_callback(self):
        ctrl = FootswitchController()
        fired = []

        def cb():
            fired.append("up")

        ctrl.register_callback(SwitchAction.PRESET_UP, cb)
        ctrl.simulate_press(SwitchAction.PRESET_UP)
        assert fired == ["up"]

    def test_start_stop_no_crash(self):
        ctrl = FootswitchController()
        ctrl.start()
        assert ctrl._running is True
        ctrl.stop()
        assert ctrl._running is False

    def test_virtual_gpio_press_and_release(self):
        """Test debounce engine via virtual GPIO pins."""
        ctrl = FootswitchController()
        actions = []

        ctrl.register_callback(SwitchAction.PRESET_UP, lambda: actions.append("up"))
        ctrl.start()

        try:
            pin = ctrl._switches[0].gpio_pin  # pin 17, PRESET_UP

            # Press the pin
            ctrl.simulate_gpio_change(pin, True)
            time.sleep((DEBOUNCE_MS + 10) / 1000)  # Wait past debounce window

            # Now release
            ctrl.simulate_gpio_change(pin, False)
            time.sleep((DEBOUNCE_MS + 10) / 1000)

            # Should have fired PRESET_UP on release (short press)
            assert "up" in actions, f"Expected 'up' in {actions}"
        finally:
            ctrl.stop()

    def test_long_press_via_gpio(self):
        """Test long-press triggers the long-press action."""
        ctrl = FootswitchController()
        actions = []

        ctrl.register_callback(SwitchAction.TAP_TEMPO, lambda: actions.append("tap"))
        ctrl.start()

        try:
            # FS1 has PRESET_UP default, TAP_TEMPO long-press
            pin = ctrl._switches[0].gpio_pin

            # Press and hold past LONG_PRESS_MS
            ctrl.simulate_gpio_change(pin, True)
            time.sleep((LONG_PRESS_MS + 50) / 1000)

            # Long press should fire without release
            assert "tap" in actions, f"Expected 'tap' in {actions}"

            # Release
            ctrl.simulate_gpio_change(pin, False)
            time.sleep((DEBOUNCE_MS + 10) / 1000)
        finally:
            ctrl.stop()

    def test_short_press_no_long_press_action(self):
        """Short press triggers default, not long-press."""
        actions = {"default": False, "long": False}

        ctrl = FootswitchController()
        ctrl.register_callback(SwitchAction.PRESET_UP, lambda: actions.update(default=True))
        ctrl.register_callback(SwitchAction.TAP_TEMPO, lambda: actions.update(long=True))
        ctrl.start()

        try:
            pin = ctrl._switches[0].gpio_pin

            # Short press
            ctrl.simulate_gpio_change(pin, True)
            time.sleep(DEBOUNCE_MS / 1000 + 0.01)
            ctrl.simulate_gpio_change(pin, False)
            time.sleep(DEBOUNCE_MS / 1000 + 0.01)

            # Poll cycle should have fired
            assert actions["default"] is True, f"Expected default fired, got {actions}"
            assert actions["long"] is False, f"Expected long NOT fired, got {actions}"
        finally:
            ctrl.stop()

    def test_multiple_callbacks_same_action(self):
        ctrl = FootswitchController()
        results = []

        ctrl.register_callback(SwitchAction.BYPASS, lambda: results.append(1))
        ctrl.register_callback(SwitchAction.BYPASS, lambda: results.append(2))
        ctrl._trigger(SwitchAction.BYPASS)
        assert results == [1, 2]


# ============================================================
# LED tests
# ============================================================


class TestLEDController:
    """LEDController — initialization, pixel control, animations."""

    def test_init_basic(self):
        ctrl = LEDController(4, driver=LEDDriver.MOCK)
        assert ctrl._num_leds == 4
        assert ctrl._global_brightness == 0.5
        assert ctrl._initialized is False

    def test_initialize_mock_returns_false(self):
        ctrl = LEDController(4, driver=LEDDriver.MOCK)
        result = ctrl.initialize()
        assert result is False
        assert ctrl._initialized is False

    def test_set_pixel(self):
        ctrl = LEDController(2, driver=LEDDriver.MOCK)
        ctrl.set_pixel(0, (255, 0, 0))
        assert ctrl._pixels[0] == (127, 0, 0)  # scaled by brightness 0.5

    def test_set_pixel_out_of_range(self):
        ctrl = LEDController(2, driver=LEDDriver.MOCK)
        ctrl.set_pixel(99, (255, 0, 0))  # should not crash
        assert ctrl._pixels[0] == (0, 0, 0)  # unchanged

    def test_set_all(self):
        ctrl = LEDController(3, driver=LEDDriver.MOCK)
        ctrl.set_all((100, 200, 50))
        for px in ctrl._pixels:
            assert px[0] == 50  # 100 * 0.5
            assert px[1] == 100  # 200 * 0.5

    def test_bypass_led_red_when_bypassed(self):
        ctrl = LEDController(4, driver=LEDDriver.MOCK)
        ctrl.set_bypass_led(0, bypassed=True)
        assert ctrl._pixels[0] == (127, 0, 0)  # red

    def test_bypass_led_green_when_active(self):
        ctrl = LEDController(4, driver=LEDDriver.MOCK)
        ctrl.set_bypass_led(0, bypassed=False)
        assert ctrl._pixels[0] == (0, 127, 0)  # green

    def test_clear_all(self):
        ctrl = LEDController(3, driver=LEDDriver.MOCK)
        ctrl.set_all((255, 255, 255))
        ctrl.clear_all()
        assert all(px == (0, 0, 0) for px in ctrl._pixels)

    def test_set_brightness(self):
        ctrl = LEDController(2, driver=LEDDriver.MOCK)
        ctrl.set_brightness(0.8)
        assert ctrl._global_brightness == 0.8

    def test_set_brightness_clamps(self):
        ctrl = LEDController(2, driver=LEDDriver.MOCK)
        ctrl.set_brightness(2.0)
        assert ctrl._global_brightness == 1.0
        ctrl.set_brightness(-0.5)
        assert ctrl._global_brightness == 0.0

    def test_context_manager(self):
        with LEDController(2, driver=LEDDriver.MOCK) as ctrl:
            assert ctrl._num_leds == 2
        # After exit, pixels should be cleared

    def test_preset_animate_does_not_crash(self):
        ctrl = LEDController(4, driver=LEDDriver.MOCK)
        ctrl.preset_animate("up")
        ctrl.preset_animate("down")

    def test_tap_tempo_blip_does_not_crash(self):
        ctrl = LEDController(4, driver=LEDDriver.MOCK)
        ctrl.tap_tempo_blip()


# ============================================================
# Display tests
# ============================================================


class TestDisplayController:
    """DisplayController — initialization, state updates, rendering modes."""

    def test_init(self):
        dc = DisplayController()
        assert dc._i2c_bus == 1
        assert dc._i2c_addr == 0x3C
        assert dc._initialized is False

    def test_initialize_returns_false_no_hardware(self):
        dc = DisplayController()
        result = dc.initialize()
        assert result is False  # No hardware

    def test_update_logs_state_in_headless(self, caplog):
        caplog.set_level("DEBUG")
        dc = DisplayController()
        state = DisplayState(
            mode="preset",
            preset_name="Crunch",
            bypassed=False,
            fx_active=["overdrive", "delay"],
            tuner_note="",
            tuner_cents=0,
        )
        dc.update(state)
        assert "Crunch" in caplog.text
        assert "overdrive" in caplog.text

    def test_update_tuner_mode(self, caplog):
        caplog.set_level("DEBUG")
        dc = DisplayController()
        state = DisplayState(
            mode="tuner",
            tuner_note="A",
            tuner_cents=-12,
        )
        dc.update(state)
        assert "tuner" in caplog.text.lower()
        assert "A" in caplog.text
        assert "-12" in caplog.text

    def test_display_state_defaults(self):
        state = DisplayState()
        assert state.mode == "preset"
        assert state.preset_name == ""
        assert state.fx_active == []
        assert state.bypassed is False

    def test_display_state_full(self):
        state = DisplayState(
            mode="fx_edit",
            param_name="Drive",
            param_value=0.75,
        )
        assert state.param_name == "Drive"
        assert state.param_value == 0.75

    def test_clear_no_crash(self):
        dc = DisplayController()
        dc.clear()  # Should not crash in headless

    def test_preset_mode_display_str(self, caplog):
        caplog.set_level("DEBUG")
        dc = DisplayController()
        state = DisplayState(preset_name="Clean", bank_name="A1", mode="preset")
        dc.update(state)
        assert "Clean" in caplog.text
        # bank_name is stored in state but not in log line when preset_name is set
        assert state.bank_name == "A1"


# ============================================================
# Integration helpers
# ============================================================


def test_switch_action_values():
    """All SwitchAction values are unique."""
    vals = [a.value for a in SwitchAction]
    assert len(vals) == len(set(vals)), "Duplicate SwitchAction values"


def test_led_pattern_values():
    """All LEDPattern values are unique."""
    vals = [p.value for p in LEDPattern]
    assert len(vals) == len(set(vals))


def test_led_driver_values():
    """All LEDDriver values are unique."""
    vals = [d.value for d in LEDDriver]
    assert len(vals) == len(set(vals))


def test_display_constants():
    assert DISPLAY_W == 128
    assert DISPLAY_H == 64