"""Tests for M5-T10: MqttManager + POST /api/config/mqtt/test.

All paho interaction is mocked — no real broker is required.
"""
from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest
from fastapi.testclient import TestClient

from app.integrations.mqtt import MqttManager


# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------

def _make_settings(
    *,
    mqtt_enabled: bool = True,
    mqtt_broker_host: str = "broker.test",
    mqtt_broker_port: int = 1883,
    mqtt_username: str = "",
    mqtt_password: str = "",
    mqtt_tls_enabled: bool = False,
    ha_discovery_prefix: str = "homeassistant",
):
    """Return a simple namespace that acts like a Settings object for MqttManager tests."""
    s = MagicMock()
    s.mqtt_enabled = mqtt_enabled
    s.mqtt_broker_host = mqtt_broker_host
    s.mqtt_broker_port = mqtt_broker_port
    s.mqtt_username = mqtt_username
    s.mqtt_password = mqtt_password
    s.mqtt_tls_enabled = mqtt_tls_enabled
    s.ha_discovery_prefix = ha_discovery_prefix
    return s


def _login(client: TestClient) -> None:
    resp = client.post(
        "/api/auth/login",
        json={"username": "admin", "password": "test-password"},
    )
    assert resp.status_code == 200, f"Login failed: {resp.status_code}"


# ---------------------------------------------------------------------------
# MqttManager.is_configured
# ---------------------------------------------------------------------------

def test_is_configured_returns_false_when_disabled() -> None:
    manager = MqttManager()
    settings = _make_settings(mqtt_enabled=False, mqtt_broker_host="broker.test")
    assert manager.is_configured(settings) is False


def test_is_configured_returns_false_when_host_empty() -> None:
    manager = MqttManager()
    settings = _make_settings(mqtt_enabled=True, mqtt_broker_host="")
    assert manager.is_configured(settings) is False


def test_is_configured_returns_true_when_enabled_and_host_set() -> None:
    manager = MqttManager()
    settings = _make_settings(mqtt_enabled=True, mqtt_broker_host="broker.test")
    assert manager.is_configured(settings) is True


# ---------------------------------------------------------------------------
# MqttManager.connect — no-op when not configured
# ---------------------------------------------------------------------------

def test_connect_is_noop_when_disabled() -> None:
    manager = MqttManager()
    settings = _make_settings(mqtt_enabled=False)
    # Should not raise and should not create a client
    with patch("app.integrations.mqtt.mqtt.Client") as mock_client_cls:
        manager.connect(settings)
        mock_client_cls.assert_not_called()
    assert manager._client is None


def test_connect_is_noop_when_host_empty() -> None:
    manager = MqttManager()
    settings = _make_settings(mqtt_broker_host="")
    with patch("app.integrations.mqtt.mqtt.Client") as mock_client_cls:
        manager.connect(settings)
        mock_client_cls.assert_not_called()
    assert manager._client is None


# ---------------------------------------------------------------------------
# MqttManager.connect — calls paho correctly when configured
# ---------------------------------------------------------------------------

def test_connect_creates_paho_client_with_version2() -> None:
    """connect() must pass CallbackAPIVersion.VERSION2 to paho.Client."""
    manager = MqttManager()
    settings = _make_settings()

    mock_client = MagicMock()
    mock_client.connect.return_value = None

    with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client) as mock_cls:
        with patch("app.integrations.mqtt.mqtt.CallbackAPIVersion"):
            manager.connect(settings)
            # Verify Client was instantiated with VERSION2
            mock_cls.assert_called_once()
            call_kwargs = mock_cls.call_args
            # First positional arg or 'callback_api_version' kwarg
            assert call_kwargs is not None

    # loop_start must have been called
    mock_client.loop_start.assert_called_once()
    # connect must have been called with the right host/port
    mock_client.connect.assert_called_once_with(
        host="broker.test", port=1883, keepalive=60
    )


def test_connect_sets_credentials_when_username_provided() -> None:
    manager = MqttManager()
    settings = _make_settings(mqtt_username="user", mqtt_password="s3cr3t")

    mock_client = MagicMock()
    mock_client.connect.return_value = None

    with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
        manager.connect(settings)

    mock_client.username_pw_set.assert_called_once_with(
        username="user", password="s3cr3t"
    )


def test_connect_skips_credentials_when_username_empty() -> None:
    manager = MqttManager()
    settings = _make_settings(mqtt_username="", mqtt_password="")

    mock_client = MagicMock()
    mock_client.connect.return_value = None

    with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
        manager.connect(settings)

    mock_client.username_pw_set.assert_not_called()


def test_connect_calls_tls_set_when_tls_enabled() -> None:
    manager = MqttManager()
    settings = _make_settings(mqtt_tls_enabled=True)

    mock_client = MagicMock()
    mock_client.connect.return_value = None

    with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
        manager.connect(settings)

    mock_client.tls_set.assert_called_once()


# ---------------------------------------------------------------------------
# MqttManager.connect — connection failure does not raise
# ---------------------------------------------------------------------------

def test_connect_does_not_raise_on_connection_failure() -> None:
    manager = MqttManager()
    settings = _make_settings()

    mock_client = MagicMock()
    mock_client.connect.side_effect = OSError("Connection refused")

    with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
        # Must not raise — failures are caught and logged
        manager.connect(settings)

    # loop_stop should have been called to clean up the thread
    mock_client.loop_stop.assert_called()
    # Client reference should not be stored
    assert manager._client is None


# ---------------------------------------------------------------------------
# MqttManager.disconnect
# ---------------------------------------------------------------------------

def test_disconnect_stops_loop_and_clears_client() -> None:
    manager = MqttManager()
    settings = _make_settings()

    mock_client = MagicMock()
    mock_client.connect.return_value = None

    with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
        manager.connect(settings)
        manager._client = mock_client  # ensure it's set

    manager.disconnect()

    mock_client.disconnect.assert_called()
    mock_client.loop_stop.assert_called()
    assert manager._client is None


def test_disconnect_is_noop_when_not_connected() -> None:
    manager = MqttManager()
    # Should not raise when called before any connect
    manager.disconnect()


# ---------------------------------------------------------------------------
# MqttManager.publish
# ---------------------------------------------------------------------------

def test_publish_passes_topic_payload_retain_to_paho() -> None:
    manager = MqttManager()
    settings = _make_settings()

    mock_client = MagicMock()
    mock_client.connect.return_value = None

    with patch("app.integrations.mqtt.mqtt.Client", return_value=mock_client):
        manager.connect(settings)
        # Simulate connected state
        manager._connected = True
        manager._client = mock_client

    manager.publish("test/topic", '{"key": "value"}', retain=True, qos=1)

    mock_client.publish.assert_called_once_with(
        "test/topic", payload='{"key": "value"}', qos=1, retain=True
    )


def test_publish_is_noop_when_not_connected() -> None:
    manager = MqttManager()
    # No connect — should silently skip
    manager.publish("topic", "payload", retain=False)
    # No exception raised


def test_publish_does_not_raise_on_paho_error() -> None:
    manager = MqttManager()
    mock_client = MagicMock()
    mock_client.publish.side_effect = RuntimeError("network error")
    manager._client = mock_client
    manager._connected = True

    # Must not raise
    manager.publish("topic", "payload")


# ---------------------------------------------------------------------------
# MqttManager.reconnect
# ---------------------------------------------------------------------------

def test_reconnect_stops_old_client_and_starts_new_one() -> None:
    """reconnect() must tear down the existing client and establish a fresh one."""
    manager = MqttManager()
    settings = _make_settings()

    # Simulate an already-connected client
    old_client = MagicMock()
    manager._client = old_client
    manager._connected = True

    new_client = MagicMock()
    new_client.connect.return_value = None

    with patch("app.integrations.mqtt.mqtt.Client", return_value=new_client):
        manager.reconnect(settings)

    # Old client must have been stopped
    old_client.disconnect.assert_called()
    old_client.loop_stop.assert_called()

    # New client must have been connected
    new_client.loop_start.assert_called_once()
    new_client.connect.assert_called_once_with(
        host="broker.test", port=1883, keepalive=60
    )


# ---------------------------------------------------------------------------
# POST /api/config/mqtt/test — session + CSRF guards
# ---------------------------------------------------------------------------

_MQTT_TEST_URL = "/api/config/mqtt/test"


def test_mqtt_test_unauthenticated_returns_401(client: TestClient) -> None:
    response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "any-token"})
    assert response.status_code == 401


def test_mqtt_test_authenticated_missing_csrf_returns_403(client: TestClient) -> None:
    _login(client)
    response = client.post(_MQTT_TEST_URL)
    assert response.status_code == 403


def test_mqtt_test_authenticated_empty_csrf_returns_403(client: TestClient) -> None:
    _login(client)
    response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": ""})
    assert response.status_code == 403


# ---------------------------------------------------------------------------
# POST /api/config/mqtt/test — three-state responses
# ---------------------------------------------------------------------------

_RUN_MQTT_TEST_PATH = "app.api.routes.api.config._run_mqtt_test"


def test_mqtt_test_success_returns_200(client: TestClient) -> None:
    _login(client)
    with patch(_RUN_MQTT_TEST_PATH, return_value=None) as mock_run:
        response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "any-token"})

    mock_run.assert_called_once()
    assert response.status_code == 200
    body = response.json()
    assert body["result"] == "success"
    assert "message" in body


def test_mqtt_test_config_error_returns_400(client: TestClient) -> None:
    _login(client)
    from app.api.routes.api.config import _MqttConfigurationError

    with patch(_RUN_MQTT_TEST_PATH, side_effect=_MqttConfigurationError("Host not configured")):
        response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "any-token"})

    assert response.status_code == 400
    body = response.json()
    assert body["result"] == "config-error"
    assert "Host not configured" in body["message"]


def test_mqtt_test_connection_error_returns_502(client: TestClient) -> None:
    _login(client)
    from app.api.routes.api.config import _MqttConnectionError

    with patch(_RUN_MQTT_TEST_PATH, side_effect=_MqttConnectionError("connection refused")):
        response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "any-token"})

    assert response.status_code == 502
    body = response.json()
    assert body["result"] == "failed"
    assert "connection refused" in body["message"]


def test_mqtt_test_response_does_not_echo_password(client: TestClient) -> None:
    """MQTT password must not appear in any test response body."""
    _login(client)
    from app.api.routes.api.config import _MqttConnectionError

    with patch(_RUN_MQTT_TEST_PATH, side_effect=_MqttConnectionError("error: [redacted]")):
        response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "token"})

    assert response.status_code == 502
    # Actual password "s3cr3t" must not appear
    assert "s3cr3t" not in response.text


def test_mqtt_test_empty_host_config_error(client: TestClient) -> None:
    """When broker host is empty, endpoint should return 400 config-error."""
    _login(client)

    # The real _run_mqtt_test raises _MqttConfigurationError for empty host.
    # We don't mock it here — we let the real implementation run.
    # The default settings have mqtt_broker_host="" so this should 400.
    response = client.post(_MQTT_TEST_URL, headers={"X-CSRF-Token": "any-token"})
    assert response.status_code == 400
    body = response.json()
    assert body["result"] == "config-error"


# ---------------------------------------------------------------------------
# _run_mqtt_test unit tests (lower-level, mock paho entirely)
# ---------------------------------------------------------------------------

def test_run_mqtt_test_raises_config_error_when_host_empty() -> None:
    from app.api.routes.api.config import _run_mqtt_test, _MqttConfigurationError

    settings = _make_settings(mqtt_broker_host="")
    with pytest.raises(_MqttConfigurationError, match="host"):
        _run_mqtt_test(settings)


def test_run_mqtt_test_raises_connection_error_on_os_error() -> None:
    from app.api.routes.api.config import _run_mqtt_test, _MqttConnectionError

    settings = _make_settings()

    mock_client = MagicMock()
    mock_client.connect.side_effect = OSError("Connection refused")

    # _run_mqtt_test imports paho.mqtt.client locally inside the function body,
    # so we patch via the real module path (paho.mqtt.client.Client).
    with patch("paho.mqtt.client.Client", return_value=mock_client):
        with pytest.raises(_MqttConnectionError, match="Cannot reach broker"):
            _run_mqtt_test(settings)

    # loop_stop must be called in the finally block
    mock_client.loop_stop.assert_called()


def test_run_mqtt_test_raises_connection_error_on_timeout() -> None:
    """If connected_event never fires, should raise _MqttConnectionError."""
    from app.api.routes.api.config import _run_mqtt_test, _MqttConnectionError

    settings = _make_settings()

    mock_client = MagicMock()
    mock_client.connect.return_value = None

    class _NeverSetEvent:
        def wait(self, timeout=None):
            return False  # simulate timeout

        def set(self):
            pass

    def _make_event():
        return _NeverSetEvent()

    with patch("paho.mqtt.client.Client", return_value=mock_client):
        with patch("threading.Event", side_effect=_make_event):
            with pytest.raises(_MqttConnectionError, match="timed out"):
                _run_mqtt_test(settings)
