"""
Tests für den MQTT-Reconnect-Mechanismus (Issue #105) — der Client hing nach
einem Drucker-Reconnect fest, weil zwei unabhängige Fehlerpfade (der Reader-
Thread-Keepalive und publish()'s eigener Reconnect-Trigger) unkoordiniert
parallel liefen, UND weil der Poll-Loop einen None-Rückgabewert von publish()
(statt einer Exception) nie als "Verbindung tot" erkannte.
"""
import threading
import time

import pytest

from kobrax_client import KobraXClient


def _client(**overrides):
    kwargs = dict(
        host="192.168.1.100", username="u", password="p",
        mode_id="20030", device_id="abc123", port=9883,
        client_id="test",
    )
    kwargs.update(overrides)
    return KobraXClient(**kwargs)


def test_is_connected_false_when_no_socket():
    c = _client()
    assert c.is_connected() is False


def test_is_connected_true_when_socket_present():
    c = _client()
    c._sock = object()  # any truthy stand-in for a real socket
    assert c.is_connected() is True


def test_reconnect_concurrent_calls_only_run_do_connect_once():
    """Two threads calling _reconnect() at the same time must not both run
    _do_connect() - only one handshake should happen; the second caller waits
    for the first instead of racing it (Issue #105)."""
    c = _client()
    c._running = True
    do_connect_calls = []
    call_lock = threading.Lock()
    release_event = threading.Event()

    def fake_do_connect():
        with call_lock:
            do_connect_calls.append(1)
        # Simulate a slow handshake so the second _reconnect() call has time
        # to observe the lock as already held.
        release_event.wait(timeout=2.0)
        c._sock = object()

    c._do_connect = fake_do_connect

    results = []

    def run():
        results.append(c._reconnect())

    t1 = threading.Thread(target=run)
    t2 = threading.Thread(target=run)
    t1.start()
    time.sleep(0.05)  # let t1 acquire the lock and enter _do_connect first
    t2.start()
    time.sleep(0.1)
    release_event.set()  # let the in-flight handshake finish
    t1.join(timeout=3)
    t2.join(timeout=3)

    assert len(do_connect_calls) == 1
    assert results == [True, True]


def test_reconnect_second_waiter_returns_after_first_completes():
    c = _client()
    c._running = True

    def fake_do_connect():
        time.sleep(0.1)
        c._sock = object()

    c._do_connect = fake_do_connect

    t1 = threading.Thread(target=c._reconnect)
    t1.start()
    time.sleep(0.02)
    # Second call while the first is still mid-handshake.
    result = c._reconnect()
    t1.join(timeout=3)

    assert result is True
    assert c._sock is not None


def test_reconnect_non_blocking_returns_immediately_while_reconnect_in_progress():
    """The poll-loop path (publish/publish_web) must NOT block while the reader
    thread's persistent reconnect is running its multi-minute backoff loop -
    it has to return so the poll loop can flip kobra_state to "offline".
    A printer unplugged mid-connection otherwise left the dashboard stuck on
    the last known state indefinitely (Issue #103 follow-up)."""
    c = _client()
    c._running = True
    started = threading.Event()
    release = threading.Event()

    def slow_persistent_do_connect():
        started.set()
        # Simulate the printer still being gone: never succeeds until released.
        release.wait(timeout=5.0)
        raise OSError("still unreachable")

    c._do_connect = slow_persistent_do_connect

    # First reconnect (reader-thread style): persistent, holds the lock, stuck
    # in backoff.
    t1 = threading.Thread(target=lambda: c._reconnect(persist=True), daemon=True)
    t1.start()
    assert started.wait(timeout=2.0)

    # Poll-loop style call must return basically instantly, not block on t1.
    t0 = time.time()
    result = c._reconnect(wait_if_in_progress=False, persist=False)
    elapsed = time.time() - t0

    assert elapsed < 0.5, f"non-blocking reconnect blocked for {elapsed:.2f}s"
    assert result is False  # socket is down while the other reconnect churns

    release.set()  # let the daemon thread unwind


def test_reconnect_one_shot_does_not_loop_on_failure():
    """persist=False must attempt the handshake at most once and return,
    instead of entering the backoff loop (which would block the caller)."""
    c = _client()
    c._running = True
    attempts = []

    def failing_do_connect():
        attempts.append(1)
        raise OSError("unreachable")

    c._do_connect = failing_do_connect

    t0 = time.time()
    result = c._reconnect(persist=False)
    elapsed = time.time() - t0

    assert result is False
    assert len(attempts) == 1  # exactly one attempt, no backoff retries
    assert elapsed < 0.5
