"""Integration tests for the Pi Multi-FX Pedal — main entry point wiring.

Tests the boot sequence, callback wiring, signal routing, display
sync, bypass toggle, graceful shutdown, and systemd service generation.

All hardware-dependent modules (RPi.GPIO, board, neopixel, dotstar,
serial, rtmidi) are mocked via pytest-mock at the import level.
"""

from __future__ import annotations

import json
import os
import signal
import sys
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch

import pytest
import yaml

# ── Module-level patching for hardware imports ──────────────────────
# These must be applied before importing the modules under test.
# We patch at the sys.modules level in conftest-style fixtures.

pytest_plugins = ["pytest_mock"]


# ═══════════════════════════════════════════════════════════════════
# Fixtures
# ═══════════════════════════════════════════════════════════════════


@pytest.fixture(autouse=True)
def mock_hardware_modules():
    """Mock all hardware-dependent modules so tests run on non-RPi.

    Sets sys.modules entries directly before any test imports happen.
    This avoids the patch.dict wrapper/unwrapper cycle that destroys
    numpy C-extension references between tests.
    """
    mock_gpio = MagicMock()
    mock_gpio.BCM = 11
    mock_gpio.IN = 1
    mock_gpio.PUD_UP = 2
    mock_gpio.PUD_DOWN = 3
    mock_gpio.input.return_value = False

    for mod_name, mod in [
        ("RPi", MagicMock()),
        ("RPi.GPIO", mock_gpio),
        ("board", MagicMock()),
        ("busio", MagicMock()),
        ("neopixel", MagicMock()),
        ("adafruit_ssd1306", MagicMock()),
        ("adafruit_circuitpython_neopixel", MagicMock()),
        ("serial", MagicMock()),
        ("rtmidi", MagicMock()),
        ("PIL", MagicMock()),
        ("PIL.Image", MagicMock()),
        ("PIL.ImageDraw", MagicMock()),
        ("PIL.ImageFont", MagicMock()),
    ]:
        if mod_name not in sys.modules:
            sys.modules[mod_name] = mod
    yield
    # Cleanup only the mocks we added, don't touch existing entries
    for mod_name, _ in [
        ("RPi", mock_gpio),
        ("RPi.GPIO", mock_gpio),
        ("board", None),
        ("busio", None),
        ("neopixel", None),
        ("adafruit_ssd1306", None),
        ("adafruit_circuitpython_neopixel", None),
        ("serial", None),
        ("rtmidi", None),
        ("PIL", None),
        ("PIL.Image", None),
        ("PIL.ImageDraw", None),
        ("PIL.ImageFont", None),
    ]:
        sys.modules.pop(mod_name, None)


@pytest.fixture
def mock_jack(mocker):
    """Mock _jack_is_running and subprocess calls for audio."""
    mocker.patch("src.system.audio._jack_is_running", return_value=False)
    mocker.patch("src.system.audio.subprocess.run", return_value=MagicMock(returncode=0))
    mocker.patch("src.system.audio.subprocess.Popen", return_value=MagicMock())
    yield


@pytest.fixture
def temp_config(tmp_path: Path) -> Path:
    """Create a temporary config directory and return config path."""
    cfg_dir = tmp_path / ".pedal"
    cfg_dir.mkdir(parents=True)
    cfg_path = cfg_dir / "config.yaml"
    cfg_path.write_text(
        yaml.dump({
            "audio": {
                "hat_type": "audioinjector",
                "profile": "standard",
                "jack_enabled": False,  # No JACK in tests
                "auto_connect": False,
            },
            "midi": {"uart_port": None, "usb": False},
            "presets": {"dir": str(tmp_path / "presets"), "install_factory": False},
        })
    )
    return cfg_path


@pytest.fixture
def pedal_app(temp_config, mock_jack, tmp_path):
    """Build a PedalApp with temp config, patching home dir."""
    with patch.object(Path, "home", return_value=tmp_path):
        from main import PedalApp
        app = PedalApp(config_path=temp_config)
        return app


# ═══════════════════════════════════════════════════════════════════
# Config tests
# ═══════════════════════════════════════════════════════════════════


class TestConfigLoading:
    def test_load_config_creates_default_file(self, tmp_path):
        """When no config exists, load_config writes defaults."""
        cfg_path = tmp_path / "no-config.yaml"
        with patch.object(Path, "home", return_value=tmp_path):
            from src.system.config import load_config
            cfg = load_config(cfg_path)
        assert cfg_path.exists()
        assert "audio" in cfg
        assert cfg["audio"]["hat_type"] == "audioinjector"

    def test_load_config_merges_overrides(self, tmp_path):
        """Partial overrides are deep-merged with defaults."""
        cfg_path = tmp_path / "partial.yaml"
        cfg_path.write_text(yaml.dump({"audio": {"profile": "low"}, "midi": {"usb": False}}))
        from src.system.config import load_config
        cfg = load_config(cfg_path)
        assert cfg["audio"]["profile"] == "low"
        assert cfg["audio"]["hat_type"] == "audioinjector"  # from defaults
        assert cfg["midi"]["usb"] is False

    def test_deep_merge_replaces_scalars(self):
        """Deep merge replaces scalar values in nested dicts."""
        from src.system.config import _deep_merge
        base = {"a": {"b": 1, "c": 2}, "d": 3}
        _deep_merge(base, {"a": {"b": 99}, "d": 100})
        assert base["a"]["b"] == 99
        assert base["a"]["c"] == 2  # preserved
        assert base["d"] == 100

    def test_deep_merge_adds_new_keys(self):
        """Deep merge adds keys not in the base."""
        from src.system.config import _deep_merge
        base = {"a": 1}
        _deep_merge(base, {"b": 2, "c": {"d": 3}})
        assert base["b"] == 2
        assert base["c"]["d"] == 3


# ═══════════════════════════════════════════════════════════════════
# Boot tests
# ═══════════════════════════════════════════════════════════════════


class TestPedalAppBoot:
    """Tests for PedalApp.boot() — subsystem creation and startup order."""

    def test_boot_creates_all_subsystems(self, pedal_app):
        """After boot, all subsystem attributes are populated."""
        result = pedal_app.boot()
        assert result is True
        assert pedal_app.audio_config is not None
        assert pedal_app.audio_system is not None
        assert pedal_app.nam_host is not None
        assert pedal_app.ir_loader is not None
        assert pedal_app.pipeline is not None
        assert pedal_app.presets is not None
        assert pedal_app.midi is not None
        assert pedal_app.footswitches is not None
        assert pedal_app.leds is not None
        assert pedal_app.display is not None

    def test_boot_audio_not_started_when_disabled(self, pedal_app):
        """With jack_enabled=False, JACK is not started."""
        pedal_app._config["audio"]["jack_enabled"] = False
        pedal_app.boot()
        # JACK should not have been started — audio_system.start_jack
        # returns False when jack_enabled is False (handles cleanly)
        assert pedal_app.audio_system is not None

    def test_boot_stores_boot_time(self, pedal_app):
        """Boot sets _boot_time to a monotonic timestamp."""
        pedal_app.boot()
        assert pedal_app._boot_time is not None
        assert isinstance(pedal_app._boot_time, float)

    def test_boot_loads_config_with_defaults(self, pedal_app):
        """Boot uses the config path passed to __init__."""
        pedal_app.boot()
        assert pedal_app.audio_config.hat_type == "audioinjector"

    def test_boot_midi_starts_with_no_interfaces(self, pedal_app):
        """MIDI starts gracefully even without hardware interfaces."""
        pedal_app.boot()
        assert pedal_app.midi is not None
        # On our mocked setup, no interfaces are actually opened
        assert pedal_app.midi.interfaces == []


# ═══════════════════════════════════════════════════════════════════
# MIDI routing tests
# ═══════════════════════════════════════════════════════════════════


class TestMIDIRouting:
    """MIDI PC and CC events route correctly through to preset manager."""

    def test_midi_pc_selects_preset(self, pedal_app):
        """MIDI Program Change triggers preset selection."""
        pedal_app.boot()
        # Create a preset at bank 0, program 1
        from src.presets.types import Preset, FXBlock, FXType
        preset = Preset(name="Test", bank=0, program=1)
        pedal_app.presets.save(preset)
        # Simulate MIDI PC: channel=0 (bank), program=1
        # We need to get the preset manager to actually have bank 0
        pedal_app.presets._get_or_create_bank(0)
        pedal_app._on_midi_pc(0, 1)
        assert pedal_app.presets.current_bank == 0
        assert pedal_app.presets.current_program == 1

    def test_midi_cc_maps_expression(self, pedal_app):
        """MIDI CC 11 adjusts master volume on the pipeline."""
        pedal_app.boot()
        assert pedal_app.pipeline is not None
        # Simulate expression pedal at 50% (value=64, CC=11, channel=0)
        pedal_app._on_midi_cc(64, 11, 0)
        assert pedal_app.pipeline._master_volume == pytest.approx(64 / 127.0)

    def test_midi_pc_without_presets_does_not_crash(self, pedal_app):
        """MIDI PC with no presets loaded is handled gracefully."""
        pedal_app.boot()
        # No presets exist — should not crash
        pedal_app._on_midi_pc(0, 99)
        # Should still be at whatever state we're in
        assert pedal_app.presets is not None


# ═══════════════════════════════════════════════════════════════════
# Footswitch routing tests
# ═══════════════════════════════════════════════════════════════════


class TestFootswitchRouting:
    """Footswitch events route correctly through to action handlers."""

    def test_preset_up_triggers_selection(self, pedal_app):
        """preset_up footswitch action calls preset_up on manager."""
        pedal_app.boot()
        with patch.object(pedal_app.presets, "preset_up", wraps=pedal_app.presets.preset_up) as spy:
            pedal_app._on_preset_up()
            spy.assert_called_once()

    def test_bypass_toggle_flags_pipeline(self, pedal_app):
        """Bypass toggle sets the pipeline bypass flag."""
        pedal_app.boot()
        assert pedal_app._bypassed is False
        pedal_app._on_bypass_toggle()
        assert pedal_app._bypassed is True
        assert pedal_app.pipeline._bypassed is True
        # Toggle back
        pedal_app._on_bypass_toggle()
        assert pedal_app._bypassed is False
        assert pedal_app.pipeline._bypassed is False

    def test_all_footswitch_actions_have_handlers(self, pedal_app):
        """Each SwitchAction in the config has a registered callback."""
        pedal_app.boot()
        registered = pedal_app.footswitches._callbacks
        expected_actions = {
            "preset_up", "preset_down", "bank_up", "bank_down",
            "bypass", "tap_tempo", "tuner", "snapshot_save",
        }
        registered_keys = {a.value for a in registered}
        for action in expected_actions:
            assert action in registered_keys, f"Missing handler for {action}"


# ═══════════════════════════════════════════════════════════════════
# Display sync tests
# ═══════════════════════════════════════════════════════════════════


class TestDisplaySync:
    """Display updates after preset changes and bypass toggles."""

    def test_display_updates_on_preset_change(self, pedal_app):
        """_on_preset_changed triggers display update."""
        pedal_app.boot()
        with patch.object(pedal_app.display, "update") as spy:
            from src.presets.types import Preset
            preset = Preset(name="Test", bank=0, program=0)
            pedal_app._on_preset_changed(preset)
            spy.assert_called_once()

    def test_display_updates_on_bypass(self, pedal_app):
        """Bypass toggle triggers display update."""
        pedal_app.boot()
        with patch.object(pedal_app.display, "update") as spy:
            pedal_app._on_bypass_toggle()
            spy.assert_called()

    def test_display_degraded_without_presets(self, pedal_app):
        """_update_display handles missing presets gracefully."""
        pedal_app.boot()
        with patch.object(pedal_app.display, "update") as spy:
            pedal_app._update_display()
            call_args = spy.call_args[0][0]
            assert call_args.mode == "preset"
            # Display should show something reasonable (boot auto-creates bank 0/preset 0)
            assert call_args.preset_name  # not empty


# ═══════════════════════════════════════════════════════════════════
# Shutdown tests
# ═══════════════════════════════════════════════════════════════════


class TestShutdown:
    """Graceful shutdown stops all subsystems in reverse order."""

    def test_shutdown_stops_midi(self, pedal_app):
        """Shutdown stops MIDI handler."""
        pedal_app.boot()
        with patch.object(pedal_app.midi, "stop") as spy:
            pedal_app.shutdown()
            spy.assert_called_once()

    def test_shutdown_stops_footswitch(self, pedal_app):
        """Shutdown stops footswitch controller."""
        pedal_app.boot()
        with patch.object(pedal_app.footswitches, "stop") as spy:
            pedal_app.shutdown()
            spy.assert_called_once()

    def test_shutdown_saves_preset_state(self, pedal_app):
        """Shutdown saves the current preset state."""
        pedal_app.boot()
        with patch.object(pedal_app.presets, "save_state") as spy:
            pedal_app.shutdown()
            spy.assert_called_once()

    def test_shutdown_stops_jack(self, pedal_app):
        """Shutdown stops JACK via audio_system."""
        pedal_app.boot()
        with patch.object(pedal_app.audio_system, "stop_jack") as spy:
            pedal_app.shutdown()
            spy.assert_called_once()

    def test_shutdown_clears_display(self, pedal_app):
        """Shutdown clears the OLED display."""
        pedal_app.boot()
        with patch.object(pedal_app.display, "clear") as spy:
            pedal_app.shutdown()
            spy.assert_called_once()

    def test_shutdown_is_idempotent(self, pedal_app):
        """Calling shutdown multiple times doesn't crash."""
        pedal_app.boot()
        # First shutdown
        pedal_app.shutdown()
        # Second shutdown — should not raise
        pedal_app.shutdown()


# ═══════════════════════════════════════════════════════════════════
# Main loop / signal handling
# ═══════════════════════════════════════════════════════════════════


class TestMainLoop:
    """Main loop and signal handling behavior."""

    def test_signal_handler_requests_shutdown(self, pedal_app):
        """SIGTERM sets the shutdown event and kills _running."""
        pedal_app.boot()
        pedal_app._running = True  # run() normally sets this
        assert pedal_app._running is True
        pedal_app._signal_handler(signal.SIGTERM, None)
        assert pedal_app._shutdown_requested.is_set()
        assert pedal_app._running is False

    def test_signal_handler_sigint(self, pedal_app):
        """SIGINT also triggers shutdown."""
        pedal_app.boot()
        pedal_app._signal_handler(signal.SIGINT, None)
        assert pedal_app._shutdown_requested.is_set()


# ═══════════════════════════════════════════════════════════════════
# Systemd service content
# ═══════════════════════════════════════════════════════════════════


class TestSystemdServices:
    """Service unit content generation."""

    def test_pedal_service_content_has_required_sections(self):
        """Generated service unit has mandatory systemd sections."""
        from src.system.services import pedal_service_content
        content = pedal_service_content(install_dir="/opt/pi-multifx-pedal")
        assert "[Unit]" in content
        assert "[Service]" in content
        assert "[Install]" in content

    def test_pedal_service_uses_correct_paths(self):
        """Service unit references correct executable and working dir."""
        from src.system.services import pedal_service_content
        install = "/opt/pi-multifx-pedal"
        content = pedal_service_content(install_dir=install)
        assert f"WorkingDirectory={install}" in content
        assert f"{install}/main.py" in content

    def test_pedal_service_has_rt_limits(self):
        """Service unit includes real-time audio priority limits."""
        from src.system.services import pedal_service_content
        content = pedal_service_content()
        assert "LimitRTPRIO=99" in content
        assert "LimitMEMLOCK=infinity" in content
        assert "LimitNICE=-20" in content

    def test_pedal_target_has_correct_structure(self):
        """Generated target unit has mandatory sections."""
        from src.system.services import pedal_target_content
        content = pedal_target_content()
        assert "[Unit]" in content
        assert "[Install]" in content
        assert "BindsTo=jackd.service" in content

    def test_install_service_copies_project(self):
        """install_service.sh copies project to install dir."""
        script = Path("scripts/install_service.sh")
        assert script.exists(), "install_service.sh must exist"
        content = script.read_text()
        assert "rsync" in content
        assert "pi-multifx-pedal.service" in content

    def test_service_generation_matches_install_script(self):
        """The service content generated by Python matches what install_service.sh uses."""
        from src.system.services import pedal_service_content, pedal_target_content
        srv = pedal_service_content()
        tgt = pedal_target_content()
        assert srv
        assert tgt


# ═══════════════════════════════════════════════════════════════════
# Preset activation pipeline routing
# ═══════════════════════════════════════════════════════════════════


class TestPresetActivation:
    """Preset activation routes through pipeline and UI."""

    def test_preset_activate_calls_pipeline(self, pedal_app):
        """Selecting a preset calls pipeline.load_preset via PresetManager."""
        pedal_app.boot()
        from src.presets.types import Preset
        preset = Preset(name="Test", bank=0, program=0, chain=[])
        with patch.object(pedal_app.pipeline, "load_preset") as spy:
            pedal_app.presets.save(preset)
            pedal_app.presets.select(0, 0)
            spy.assert_called_once()

    def test_preset_up_wraps_within_bank(self, pedal_app):
        """preset_up wraps from program 3 back to 0."""
        pedal_app.boot()
        from src.presets.types import Preset
        # Fill all 4 slots in bank 0
        for pg in range(4):
            p = Preset(name=f"P{pg}", bank=0, program=pg)
            pedal_app.presets.save(p)
        # Select program 3
        pedal_app.presets.select(0, 3)
        # preset_up should wrap to 0
        pedal_app._on_preset_up()
        assert pedal_app.presets.current_program == 0


# ═══════════════════════════════════════════════════════════════════
# Start-up time check (acceptance criterion: < 30s)
# ═══════════════════════════════════════════════════════════════════


class TestBootTime:
    """Boot completes within acceptable time on dev machine."""

    def test_boot_completes_under_5_seconds(self, pedal_app):
        """Boot should complete quickly even with mocked hardware."""
        start = time.monotonic()
        pedal_app.boot()
        elapsed = time.monotonic() - start
        # On a dev machine with mock hardware, boot should be < 2s.
        # We set a generous 5s limit to account for CI slowdown.
        assert elapsed < 5.0, f"Boot took {elapsed:.2f}s (limit: 5s)"


# ═══════════════════════════════════════════════════════════════════
# CLI entry
# ═══════════════════════════════════════════════════════════════════


class TestCLIEntry:
    """main() CLI entry point behavior."""

    def test_main_returns_0_on_clean_shutdown(self, pedal_app, temp_config, mocker):
        """main() returns 0 when boot succeeds and app runs then shuts down."""
        from main import main
        mocker.patch("sys.argv", ["main.py", "-c", str(temp_config)])
        # Replace boot and run so that main() returns quickly with 0
        with patch("main.PedalApp.boot", return_value=True), \
             patch("main.PedalApp.run"):
            ret = main()
            assert ret == 0

    def test_main_returns_1_on_boot_failure(self, mocker, temp_config):
        """main() returns 1 when boot fails."""
        from main import main
        mocker.patch("sys.argv", ["main.py", "-c", str(temp_config)])
        with patch("main.PedalApp.boot", return_value=False):
            ret = main()
            assert ret == 1


# ═══════════════════════════════════════════════════════════════════
# Edge cases
# ═══════════════════════════════════════════════════════════════════


class TestEdgeCases:
    """Edge cases and error resilience."""

    def test_corrupt_config_falls_back_to_defaults(self, tmp_path, mock_jack):
        """A corrupt YAML config falls back to defaults without crashing."""
        cfg_path = tmp_path / "corrupt.yaml"
        cfg_path.write_text("{{{{{ not valid yaml }}}}")
        with patch.object(Path, "home", return_value=tmp_path):
            from main import PedalApp
            app = PedalApp(config_path=cfg_path)
            result = app.boot()
            assert result is True  # Should still boot with defaults

    def test_boot_with_empty_preset_dir(self, pedal_app):
        """Boot succeeds with an empty (or missing) preset directory."""
        result = pedal_app.boot()
        assert result is True

    def test_shutdown_without_boot(self, tmp_path, mock_jack):
        """Calling shutdown() without boot() does not crash."""
        cfg_path = tmp_path / "minimal.yaml"
        cfg_path.write_text(yaml.dump({"presets": {"install_factory": False}}))
        with patch.object(Path, "home", return_value=tmp_path):
            from main import PedalApp
            app = PedalApp(config_path=cfg_path)
            # No boot — just shutdown
            app.shutdown()

    def test_concurrent_midi_and_footswitch_no_crash(self, pedal_app):
        """Rapid alternation of MIDI and footswitch events doesn't crash."""
        pedal_app.boot()
        from src.presets.types import Preset
        for pg in range(4):
            pedal_app.presets.save(Preset(name=f"P{pg}", bank=0, program=pg, chain=[]))
        # Rapid-fire events
        for _ in range(10):
            pedal_app._on_preset_up()
            pedal_app._on_midi_pc(0, 2)
            pedal_app._on_bypass_toggle()
        # Should still be in a consistent state
        assert pedal_app.presets is not None