"""Tests for the web UI server — REST API, WebSocket, and HTML pages.

Run with:
    pytest tests/test_web.py -v
"""

from __future__ import annotations

import json
import sys
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock

import pytest
from fastapi.testclient import TestClient

# Ensure src is on sys.path
SRC = Path(__file__).resolve().parent.parent / "src"
if str(SRC) not in sys.path:
    sys.path.insert(0, str(SRC))

from src.web.server import WebServer, WebServerDeps


# ── Fixtures ─────────────────────────────────────────────────────────────────


@pytest.fixture
def mock_presets():
    """A mock PresetManager with a real Preset returned on load."""
    from src.presets.types import Preset
    pm = MagicMock()
    pm.current_bank = 0
    pm.current_program = 0
    pm._current_snapshot = 0
    pm.load.return_value = Preset(name="Default", bank=0, program=0)
    return pm


@pytest.fixture
def mock_pipeline():
    pl = MagicMock()
    pl._bypassed = False
    pl._tuner_enabled = False
    pl._tuner_frequency = 0.0
    pl._tuner_note = '--'
    pl._tuner_cents = 0
    pl._tuner_string = -1
    pl._tuner_confidence = 0.0
    pl._master_volume = 0.8
    pl.routing_mode = "mono"
    pl.routing_breakpoint = 7

    def _set_routing(mode, breakpoint=7):
        pl.routing_mode = mode
        pl.routing_breakpoint = breakpoint
    pl.set_routing = MagicMock(side_effect=_set_routing)
    return pl


@pytest.fixture
def mock_nam():
    nam = MagicMock()
    nam.is_loaded = False
    nam.current_model = None
    nam._models_dir = Path("/tmp/pedal-test/nam")
    return nam


@pytest.fixture
def mock_ir():
    ir = MagicMock()
    ir.is_loaded = False
    ir.current_ir = None
    ir._ir_dir = Path("/tmp/pedal-test/irs")
    return ir


@pytest.fixture
def deps(mock_presets, mock_pipeline, mock_nam, mock_ir):
    return WebServerDeps(
        presets=mock_presets,
        pipeline=mock_pipeline,
        nam_host=mock_nam,
        ir_loader=mock_ir,
    )


@pytest.fixture
def client(deps, monkeypatch):
    """TestClient with a fresh server per test."""
    # Mock hardware-dependent gathers so tests don't hang on bluetoothctl/nmcli
    monkeypatch.setattr(WebServer, "_gather_wifi_state", lambda self: {
        "connected": False, "ssid": None, "ip": None, "signal": 0,
        "hotspot_active": False, "hotspot_ssid": None, "hotspot_clients": 0,
    })
    monkeypatch.setattr(WebServer, "_gather_bt_state", lambda self: {
        "available": False, "powered": False, "name": None,
        "address": None, "discoverable": False,
        "midi_enabled": False, "midi_running": False,
    })
    server = WebServer(deps)
    from fastapi.testclient import TestClient
    test_client = TestClient(server._app)
    return test_client


@pytest.fixture
def client_no_deps():
    """Server with no pedal dependencies connected (disconnected state)."""
    server = WebServer(WebServerDeps())
    test_client = TestClient(server._app)
    return test_client


# ── HTML page tests ──────────────────────────────────────────────────────────


class TestPages:
    """Verify all HTML pages are served with correct status and content type."""

    def test_dashboard(self, client):
        res = client.get("/")
        assert res.status_code == 200
        assert "text/html" in res.headers["content-type"]

    def test_presets_page(self, client):
        res = client.get("/presets")
        assert res.status_code == 200
        assert "text/html" in res.headers["content-type"]

    def test_models_page(self, client):
        res = client.get("/models")
        assert res.status_code == 200
        assert "text/html" in res.headers["content-type"]

    def test_irs_page(self, client):
        res = client.get("/irs")
        assert res.status_code == 200
        assert "text/html" in res.headers["content-type"]

    def test_settings_page(self, client):
        res = client.get("/settings")
        assert res.status_code == 200
        assert "text/html" in res.headers["content-type"]

    def test_404_page(self, client):
        res = client.get("/nonexistent")
        assert res.status_code == 404

    def test_static_css(self, client):
        res = client.get("/static/style.css")
        assert res.status_code == 200
        assert "text/css" in res.headers["content-type"]


# ── REST API tests ───────────────────────────────────────────────────────────


class TestAPIState:
    """GET /api/state — pedal state snapshot."""

    def test_state_connected(self, client, mock_presets, mock_pipeline, mock_nam, mock_ir):
        res = client.get("/api/state")
        assert res.status_code == 200
        data = res.json()
        assert data["connected"] is True
        assert "current_preset" in data
        assert "bypass" in data
        assert "tuner_enabled" in data
        assert "master_volume" in data
        assert data["master_volume"] == 0.8

    def test_state_disconnected(self, client_no_deps):
        res = client_no_deps.get("/api/state")
        assert res.status_code == 200
        data = res.json()
        assert data["connected"] is False


class TestAPIPresets:
    """Preset CRUD endpoints."""

    def test_list_presets(self, client):
        res = client.get("/api/presets")
        assert res.status_code == 200
        data = res.json()
        assert "banks" in data

    def test_list_presets_no_deps(self, client_no_deps):
        res = client_no_deps.get("/api/presets")
        assert res.status_code == 503

    def test_get_preset_not_found(self, client, mock_presets):
        mock_presets.load.side_effect = FileNotFoundError("No preset")
        res = client.get("/api/presets/99/0")
        assert res.status_code == 404

    def test_activate_preset(self, client, mock_presets):
        from src.presets.types import Preset
        fake_preset = Preset(name="Test", bank=0, program=0)
        mock_presets.select.return_value = fake_preset
        res = client.post("/api/presets/0/0/activate")
        assert res.status_code == 200
        data = res.json()
        assert data["ok"] is True
        mock_presets.select.assert_called_with(0, 0)

    def test_save_preset(self, client, mock_presets):
        payload = {
            "name": "My Tone",
            "master_volume": 0.7,
            "chain": [
                {"fx_type": "overdrive", "enabled": True, "bypass": False, "params": {"drive": 0.5}}
            ],
        }
        res = client.put("/api/presets/1/2", json=payload)
        assert res.status_code == 200
        assert res.json()["name"] == "My Tone"
        mock_presets.save.assert_called_once()

    def test_delete_preset(self, client, mock_presets):
        res = client.delete("/api/presets/0/0")
        assert res.status_code == 200
        mock_presets.delete.assert_called_with(0, 0)


class TestAPIBypass:
    """Bypass toggle endpoint."""

    def test_bypass_toggle_on(self, client):
        res = client.post("/api/bypass", json={"bypass": True})
        assert res.status_code == 200
        assert res.json()["bypass"] is True

    def test_bypass_toggle_off(self, client):
        res = client.post("/api/bypass", json={"bypass": False})
        assert res.status_code == 200
        assert res.json()["bypass"] is False

    def test_bypass_no_deps(self, client_no_deps):
        res = client_no_deps.post("/api/bypass")
        assert res.status_code == 503


class TestAPIVolume:
    """Volume control endpoint."""

    def test_set_volume(self, client, mock_pipeline):
        res = client.put("/api/volume", json={"volume": 0.5})
        assert res.status_code == 200
        assert res.json()["volume"] == 0.5
        assert mock_pipeline._master_volume == 0.5

    def test_set_volume_clamp(self, client, mock_pipeline):
        res = client.put("/api/volume", json={"volume": 2.5})
        assert res.status_code == 200
        assert res.json()["volume"] == 1.0  # clamped


class TestAPIModels:
    """NAM model listing and loading."""

    def test_list_models(self, client, mock_nam):
        mock_nam.list_available_models.return_value = []
        res = client.get("/api/models")
        assert res.status_code == 200
        data = res.json()
        assert "models" in data
        assert "current" in data

    def test_load_model(self, client, mock_nam):
        mock_nam.load_model.return_value = True
        res = client.post("/api/models/load", json={"path": "/tmp/test.nam"})
        assert res.status_code == 200
        assert res.json()["ok"] is True

    def test_load_model_fail(self, client, mock_nam):
        mock_nam.load_model.return_value = False
        res = client.post("/api/models/load", json={"path": "/tmp/bad.nam"})
        assert res.status_code == 500

    def test_unload_model(self, client, mock_nam):
        res = client.post("/api/models/unload")
        assert res.status_code == 200
        mock_nam.unload.assert_called_once()


class TestAPIIRs:
    """IR listing and loading."""

    def test_list_irs(self, client, mock_ir):
        mock_ir.get_irs.return_value = []
        res = client.get("/api/irs")
        assert res.status_code == 200
        data = res.json()
        assert "irs" in data

    def test_load_ir(self, client, mock_ir):
        mock_ir.load_ir.return_value = True
        res = client.post("/api/irs/load", json={"path": "/tmp/test.wav"})
        assert res.status_code == 200
        assert res.json()["ok"] is True

    def test_unload_ir(self, client, mock_ir):
        res = client.post("/api/irs/unload")
        assert res.status_code == 200
        mock_ir.unload.assert_called_once()


class TestAPIBlockParams:
    """FX block parameter schemas."""

    def test_overdrive_params(self, client):
        res = client.get("/api/block-params/overdrive")
        assert res.status_code == 200
        data = res.json()
        assert data["fx_type"] == "overdrive"
        assert len(data["params"]) > 0
        keys = [p["key"] for p in data["params"]]
        assert "drive" in keys

    def test_unknown_fx_type(self, client):
        res = client.get("/api/block-params/unknown_fx")
        assert res.status_code == 200
        assert res.json()["params"] == []


class TestAPITuner:
    """Tuner toggle."""

    def test_tuner_toggle_on(self, client):
        res = client.post("/api/tuner", json={"enabled": True})
        assert res.status_code == 200
        assert res.json()["tuner_enabled"] is True

    def test_tuner_toggle_off(self, client):
        res = client.post("/api/tuner", json={"enabled": False})
        assert res.status_code == 200
        assert res.json()["tuner_enabled"] is False


# ── WebSocket tests ──────────────────────────────────────────────────────────


class TestWebSocket:
    """WebSocket connection and message handling."""

    def test_ws_connect_receives_state(self, client, mock_presets):
        """On connect, server sends 'connected' message with state."""
        from src.presets.types import Preset
        mock_presets.load.return_value = Preset(name="Default", bank=0, program=0)

        with client.websocket_connect("/ws") as ws:
            data = ws.receive_text()
            msg = json.loads(data)
            assert msg["type"] == "connected"
            assert "state" in msg

    def test_ws_ping_pong(self, client):
        """Ping message gets pong response."""
        with client.websocket_connect("/ws") as ws:
            # Consume the initial connected message
            ws.receive_text()
            ws.send_text(json.dumps({"type": "ping"}))
            data = ws.receive_text()
            msg = json.loads(data)
            assert msg["type"] == "pong"

    def test_ws_get_state(self, client, mock_presets):
        """Requesting state returns full state object."""
        from src.presets.types import Preset
        mock_presets.load.return_value = Preset(name="Test", bank=1, program=2)

        with client.websocket_connect("/ws") as ws:
            ws.receive_text()  # initial connected
            ws.send_text(json.dumps({"type": "get_state"}))
            data = ws.receive_text()
            msg = json.loads(data)
            assert msg["type"] == "state"
            assert "state" in msg


# ── Error handling ───────────────────────────────────────────────────────────


class TestErrors:
    """Edge cases and error responses."""

    def test_missing_volume_key(self, client):
        """PUT /api/volume with no 'volume' key defaults."""
        res = client.put("/api/volume", json={})
        assert res.status_code == 200
        assert res.json()["volume"] == 0.8  # default

    def test_empty_model_load_path(self, client):
        res = client.post("/api/models/load", json={"path": ""})
        assert res.status_code == 400


class TestAPIRouting:
    """4CM routing mode and breakpoint endpoints."""

    def test_get_routing_default(self, client, mock_pipeline):
        """GET /api/routing returns current routing config."""
        res = client.get("/api/routing")
        assert res.status_code == 200
        data = res.json()
        assert data["routing_mode"] == "mono"
        assert data["routing_breakpoint"] == 7

    def test_get_routing_no_deps(self, client_no_deps):
        """GET /api/routing returns 503 when pipeline not connected."""
        res = client_no_deps.get("/api/routing")
        assert res.status_code == 503

    def test_set_routing_mode_4cm(self, client, mock_pipeline):
        """POST /api/routing switches to 4CM mode."""
        res = client.post("/api/routing", json={"routing_mode": "4cm"})
        assert res.status_code == 200
        data = res.json()
        assert data["routing_mode"] == "4cm"
        mock_pipeline.set_routing.assert_called_with("4cm", 7)

    def test_set_routing_mode_mono(self, client, mock_pipeline):
        """POST /api/routing switches back to mono."""
        res = client.post("/api/routing", json={"routing_mode": "mono"})
        assert res.status_code == 200
        data = res.json()
        assert data["routing_mode"] == "mono"
        mock_pipeline.set_routing.assert_called_with("mono", 7)

    def test_set_routing_breakpoint(self, client, mock_pipeline):
        """POST /api/routing sets breakpoint position."""
        res = client.post("/api/routing", json={"routing_breakpoint": 3})
        assert res.status_code == 200
        data = res.json()
        assert data["routing_breakpoint"] == 3
        mock_pipeline.set_routing.assert_called_with("mono", 3)

    def test_set_routing_both(self, client, mock_pipeline):
        """POST /api/routing sets both mode and breakpoint."""
        res = client.post("/api/routing", json={
            "routing_mode": "4cm",
            "routing_breakpoint": 5,
        })
        assert res.status_code == 200
        data = res.json()
        assert data["routing_mode"] == "4cm"
        assert data["routing_breakpoint"] == 5
        mock_pipeline.set_routing.assert_called_with("4cm", 5)

    def test_set_routing_no_deps(self, client_no_deps):
        """POST /api/routing returns 503 when pipeline not connected."""
        res = client_no_deps.post("/api/routing", json={"routing_mode": "4cm"})
        assert res.status_code == 503