"""Tests for background worker, WSManager, and worker status endpoint."""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock

import pytest
from app.models import Test, TestStatus, User, UserTier
from app.worker import Worker
from app.ws import WSManager
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession

# ---------------------------------------------------------------------------
# WSManager unit tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_ws_manager_connect_and_broadcast() -> None:
    """WSManager broadcasts to connected clients."""
    manager = WSManager()

    mock_ws = AsyncMock()
    mock_ws.accept = AsyncMock()
    mock_ws.send_json = AsyncMock()

    await manager.connect(1, mock_ws)
    await manager.broadcast(1, {"type": "test_start", "test_id": 1})

    mock_ws.accept.assert_called_once()
    mock_ws.send_json.assert_called_once_with({"type": "test_start", "test_id": 1})


@pytest.mark.asyncio
async def test_ws_manager_disconnect() -> None:
    """WSManager removes disconnected clients."""
    manager = WSManager()

    mock_ws = AsyncMock()
    mock_ws.accept = AsyncMock()

    await manager.connect(1, mock_ws)
    manager.disconnect(1, mock_ws)

    # Broadcast should not send to disconnected client
    mock_ws.send_json = AsyncMock()
    await manager.broadcast(1, {"type": "test_start"})
    mock_ws.send_json.assert_not_called()


@pytest.mark.asyncio
async def test_ws_manager_broadcast_no_connections() -> None:
    """WSManager handles broadcast when no clients are connected."""
    manager = WSManager()
    # Should not raise
    await manager.broadcast(999, {"type": "test_start"})


@pytest.mark.asyncio
async def test_ws_manager_dead_connection_cleanup() -> None:
    """WSManager removes dead connections on broadcast failure."""
    manager = WSManager()

    mock_ws = AsyncMock()
    mock_ws.accept = AsyncMock()
    mock_ws.send_json = AsyncMock(side_effect=Exception("connection closed"))

    await manager.connect(1, mock_ws)
    await manager.broadcast(1, {"type": "test_start"})

    # Dead connection should be cleaned up
    assert 1 not in manager._connections


@pytest.mark.asyncio
async def test_ws_manager_multiple_clients() -> None:
    """WSManager broadcasts to multiple clients."""
    manager = WSManager()

    ws1 = AsyncMock()
    ws1.accept = AsyncMock()
    ws1.send_json = AsyncMock()

    ws2 = AsyncMock()
    ws2.accept = AsyncMock()
    ws2.send_json = AsyncMock()

    await manager.connect(1, ws1)
    await manager.connect(1, ws2)

    await manager.broadcast(1, {"type": "step_done"})

    ws1.send_json.assert_called_once_with({"type": "step_done"})
    ws2.send_json.assert_called_once_with({"type": "step_done"})


# ---------------------------------------------------------------------------
# Worker unit tests
# ---------------------------------------------------------------------------


def test_worker_initial_state() -> None:
    """Worker starts in stopped state."""
    w = Worker()
    assert not w.is_running
    assert w.active_count == 0


def test_claim_next_uses_for_update_on_postgres(monkeypatch: pytest.MonkeyPatch) -> None:
    """_claim_next applies FOR UPDATE when not using SQLite."""
    from app import config

    # Verify SQLite skips FOR UPDATE (default)
    assert config.settings.database_url.startswith("sqlite")

    # Verify the method exists and handles the flag
    w = Worker()
    # Worker's _claim_next checks settings.database_url at call time
    # Just verify the code path is reachable
    assert hasattr(w, "_claim_next")


# ---------------------------------------------------------------------------
# Worker status endpoint
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_worker_status_endpoint(client: AsyncClient) -> None:
    """GET /api/worker/status returns worker info."""
    resp = await client.get("/api/worker/status")
    assert resp.status_code == 200
    data = resp.json()
    assert "running" in data
    assert "active_tests" in data
    assert "max_concurrent" in data


# ---------------------------------------------------------------------------
# Health check still works
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_health_check(client: AsyncClient) -> None:
    """GET /health still works after worker integration."""
    resp = await client.get("/health")
    assert resp.status_code == 200
    assert resp.json() == {"status": "ok"}


# ---------------------------------------------------------------------------
# Per-user concurrency tests
# ---------------------------------------------------------------------------


@pytest.fixture
def _real_concurrent_limits(monkeypatch: pytest.MonkeyPatch) -> None:
    """Per-user concurrency tests — use real tier limits."""
    from app import config

    monkeypatch.setattr(config.settings, "concurrent_limit_free", 1)
    monkeypatch.setattr(config.settings, "concurrent_limit_pro", 3)
    monkeypatch.setattr(config.settings, "concurrent_limit_team", 5)


@pytest.mark.asyncio
@pytest.mark.usefixtures("_real_concurrent_limits")
async def test_claim_skips_user_at_limit(db_session: AsyncSession) -> None:
    """Free user (limit=1) with 1 RUNNING → next QUEUED test is NOT claimed."""
    monkeypatch_session = db_session

    # Create user + tests
    user = User(id="user-a", email="a@test.com", tier=UserTier.FREE)
    monkeypatch_session.add(user)
    monkeypatch_session.add(
        Test(id=1, user_id="user-a", target_url="http://a.com",
             status=TestStatus.RUNNING)
    )
    monkeypatch_session.add(
        Test(id=2, user_id="user-a", target_url="http://a.com",
             status=TestStatus.QUEUED)
    )
    await monkeypatch_session.commit()

    # Patch async_session to use test session
    import app.worker as worker_mod

    original_session = worker_mod.async_session

    from contextlib import asynccontextmanager

    @asynccontextmanager
    async def _mock_session():
        yield monkeypatch_session

    worker_mod.async_session = _mock_session  # type: ignore[assignment]
    try:
        w = Worker()
        result = await w._claim_next()
        assert result is None  # User A at limit, no eligible test
    finally:
        worker_mod.async_session = original_session


@pytest.mark.asyncio
@pytest.mark.usefixtures("_real_concurrent_limits")
async def test_claim_serves_other_user_independently(db_session: AsyncSession) -> None:
    """User A at limit → User B's QUEUED test is still claimed."""
    monkeypatch_session = db_session

    # User A: Free (limit=1), already RUNNING 1
    monkeypatch_session.add(User(id="user-a", email="a@test.com", tier=UserTier.FREE))
    monkeypatch_session.add(
        Test(id=1, user_id="user-a", target_url="http://a.com",
             status=TestStatus.RUNNING)
    )
    monkeypatch_session.add(
        Test(id=2, user_id="user-a", target_url="http://a.com",
             status=TestStatus.QUEUED)
    )

    # User B: Free (limit=1), QUEUED 1
    monkeypatch_session.add(User(id="user-b", email="b@test.com", tier=UserTier.FREE))
    monkeypatch_session.add(
        Test(id=3, user_id="user-b", target_url="http://b.com",
             status=TestStatus.QUEUED)
    )
    await monkeypatch_session.commit()

    import app.worker as worker_mod

    original_session = worker_mod.async_session

    from contextlib import asynccontextmanager

    @asynccontextmanager
    async def _mock_session():
        yield monkeypatch_session

    worker_mod.async_session = _mock_session  # type: ignore[assignment]
    try:
        w = Worker()
        result = await w._claim_next()
        assert result is not None
        test_id, status = result
        assert test_id == 3  # User B's test, not User A's
        assert status == TestStatus.QUEUED
    finally:
        worker_mod.async_session = original_session


@pytest.mark.asyncio
@pytest.mark.usefixtures("_real_concurrent_limits")
async def test_pro_user_three_concurrent(db_session: AsyncSession) -> None:
    """Pro user (limit=3) can have 3 tests running concurrently."""
    monkeypatch_session = db_session

    monkeypatch_session.add(User(id="user-pro", email="pro@test.com", tier=UserTier.PRO))
    # 2 already RUNNING
    monkeypatch_session.add(
        Test(id=1, user_id="user-pro", target_url="http://pro.com",
             status=TestStatus.RUNNING)
    )
    monkeypatch_session.add(
        Test(id=2, user_id="user-pro", target_url="http://pro.com",
             status=TestStatus.RUNNING)
    )
    # 1 QUEUED — should be claimed (2/3 < 3)
    monkeypatch_session.add(
        Test(id=3, user_id="user-pro", target_url="http://pro.com",
             status=TestStatus.QUEUED)
    )
    await monkeypatch_session.commit()

    import app.worker as worker_mod

    original_session = worker_mod.async_session

    from contextlib import asynccontextmanager

    @asynccontextmanager
    async def _mock_session():
        yield monkeypatch_session

    worker_mod.async_session = _mock_session  # type: ignore[assignment]
    try:
        w = Worker()
        result = await w._claim_next()
        assert result is not None
        test_id, _ = result
        assert test_id == 3
    finally:
        worker_mod.async_session = original_session


@pytest.mark.asyncio
@pytest.mark.usefixtures("_real_concurrent_limits")
async def test_global_max_concurrent_guard(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Worker poll loop respects global max_concurrent even if per-user has room."""
    from app import config

    monkeypatch.setattr(config.settings, "max_concurrent", 2)

    w = Worker()
    w._active = 2  # Already at global max

    # _poll_loop guard: self._active < settings.max_concurrent
    # Directly test the guard condition
    assert not (w._active < config.settings.max_concurrent)


@pytest.mark.asyncio
async def test_middleware_stuck_cleanup(db_session: AsyncSession) -> None:
    """Stuck tests are auto-cleaned before concurrent check in middleware."""
    from app import config

    monkeypatch_session = db_session

    user = User(id="user-stuck", email="stuck@test.com", tier=UserTier.FREE)
    monkeypatch_session.add(user)

    # A stuck test (updated long ago)
    stuck_time = datetime.now(UTC) - timedelta(minutes=10)
    monkeypatch_session.add(
        Test(id=1, user_id="user-stuck", target_url="http://stuck.com",
             status=TestStatus.RUNNING, updated_at=stuck_time)
    )
    await monkeypatch_session.commit()

    # Import and call the stuck cleanup logic directly
    from app.middleware import get_active_count

    # Before cleanup — test is active
    active_before = await get_active_count("user-stuck", monkeypatch_session)
    assert active_before == 1

    # Simulate stuck cleanup (same logic as middleware)
    from sqlalchemy import select as sa_select

    stuck_cutoff = datetime.now(UTC) - timedelta(
        minutes=config.settings.stuck_timeout_minutes
    )
    stuck_q = (
        sa_select(Test).where(
            Test.user_id == "user-stuck",
            Test.status.in_(
                [TestStatus.GENERATING, TestStatus.QUEUED, TestStatus.RUNNING]
            ),
            Test.updated_at < stuck_cutoff,
        )
    )
    stuck_result = await monkeypatch_session.execute(stuck_q)
    for t in stuck_result.scalars().all():
        t.status = TestStatus.FAILED
        t.error_message = "Auto-cleaned: test stuck"
        t.updated_at = datetime.now(UTC)
    await monkeypatch_session.commit()

    # After cleanup — no active tests
    active_after = await get_active_count("user-stuck", monkeypatch_session)
    assert active_after == 0
