"""Tests for webhook server functionality."""

from __future__ import annotations

import json
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest
import respx
from fastapi.testclient import TestClient
from httpx import Response
from pydantic import ValidationError

import filtarr.webhook
from filtarr.config import (
    Config,
    ConfigurationError,
    RadarrConfig,
    SonarrConfig,
    StateConfig,
    TagConfig,
    WebhookConfig,
)
from filtarr.models.webhook import (
    RadarrWebhookPayload,
    SonarrWebhookPayload,
    WebhookResponse,
)
from filtarr.state import CheckRecord
from tests.test_utils import CreateTaskMock


@pytest.fixture
def radarr_config() -> RadarrConfig:
    """Create a test Radarr configuration."""
    return RadarrConfig(url="http://localhost:7878", api_key="radarr-api-key")


@pytest.fixture
def sonarr_config() -> SonarrConfig:
    """Create a test Sonarr configuration."""
    return SonarrConfig(url="http://127.0.0.1:8989", api_key="sonarr-api-key")


@pytest.fixture
def full_config(radarr_config: RadarrConfig, sonarr_config: SonarrConfig) -> Config:
    """Create a full test configuration."""
    return Config(
        radarr=radarr_config,
        sonarr=sonarr_config,
        tags=TagConfig(),
        webhook=WebhookConfig(host="0.0.0.0", port=8080),
    )


@pytest.fixture
def radarr_only_config(radarr_config: RadarrConfig) -> Config:
    """Create a config with only Radarr configured."""
    return Config(radarr=radarr_config, webhook=WebhookConfig())


@pytest.fixture
def sonarr_only_config(sonarr_config: SonarrConfig) -> Config:
    """Create a config with only Sonarr configured."""
    return Config(sonarr=sonarr_config, webhook=WebhookConfig())


@pytest.fixture
def test_client(full_config: Config) -> TestClient:
    """Create a test client for the webhook app."""
    app = filtarr.webhook.create_app(full_config)
    return TestClient(app)


class TestValidateApiKey:
    """Tests for the _validate_api_key helper function."""

    def test_validate_api_key_none(self, full_config: Config) -> None:
        """Should return None when api_key is None."""
        result = filtarr.webhook._validate_api_key(None, full_config)
        assert result is None

    def test_validate_api_key_empty_string(self, full_config: Config) -> None:
        """Should return None when api_key is empty string."""
        result = filtarr.webhook._validate_api_key("", full_config)
        assert result is None

    def test_validate_api_key_radarr_match(self, full_config: Config) -> None:
        """Should return 'radarr' when API key matches Radarr."""
        result = filtarr.webhook._validate_api_key("radarr-api-key", full_config)
        assert result == "radarr"

    def test_validate_api_key_sonarr_match(self, full_config: Config) -> None:
        """Should return 'sonarr' when API key matches Sonarr."""
        result = filtarr.webhook._validate_api_key("sonarr-api-key", full_config)
        assert result == "sonarr"

    def test_validate_api_key_invalid(self, full_config: Config) -> None:
        """Should return None for invalid API key."""
        result = filtarr.webhook._validate_api_key("invalid-key", full_config)
        assert result is None

    def test_validate_api_key_no_radarr_config(self, sonarr_only_config: Config) -> None:
        """Should handle missing Radarr config gracefully."""
        result = filtarr.webhook._validate_api_key("sonarr-api-key", sonarr_only_config)
        assert result == "sonarr"

    def test_validate_api_key_no_sonarr_config(self, radarr_only_config: Config) -> None:
        """Should handle missing Sonarr config gracefully."""
        result = filtarr.webhook._validate_api_key("radarr-api-key", radarr_only_config)
        assert result == "radarr"

    def test_validate_api_key_uses_constant_time_comparison(self, full_config: Config) -> None:
        """Should use hmac.compare_digest for constant-time comparison to prevent timing attacks."""
        import hmac

        # Verify hmac.compare_digest is called during API key validation
        with patch(
            "filtarr.webhook.hmac.compare_digest", wraps=hmac.compare_digest
        ) as mock_compare:
            # Test with valid Radarr key
            filtarr.webhook._validate_api_key("radarr-api-key", full_config)
            # compare_digest should have been called for Radarr key comparison
            assert mock_compare.called, "hmac.compare_digest should be used for API key comparison"

    def test_validate_api_key_rejects_non_string_types(self, full_config: Config) -> None:
        """Should handle type edge cases gracefully."""
        # None is already tested, but let's ensure we handle it safely with compare_digest
        result = filtarr.webhook._validate_api_key(None, full_config)
        assert result is None


class TestWebhookModels:
    """Tests for webhook Pydantic models."""

    def test_radarr_webhook_payload_parsing(self) -> None:
        """Should parse Radarr webhook payload correctly."""
        data = {
            "eventType": "MovieAdded",
            "movie": {
                "id": 123,
                "title": "The Matrix",
                "year": 1999,
                "tmdbId": 603,
                "imdbId": "tt0133093",
            },
        }
        payload = RadarrWebhookPayload.model_validate(data)

        assert payload.event_type == "MovieAdded"
        assert payload.movie.id == 123
        assert payload.movie.title == "The Matrix"
        assert payload.movie.year == 1999
        assert payload.movie.tmdb_id == 603
        assert payload.movie.imdb_id == "tt0133093"
        assert payload.is_movie_added() is True

    def test_radarr_webhook_non_added_event(self) -> None:
        """Should correctly identify non-MovieAdded events."""
        data = {
            "eventType": "Download",
            "movie": {"id": 123, "title": "Test Movie"},
        }
        payload = RadarrWebhookPayload.model_validate(data)

        assert payload.event_type == "Download"
        assert payload.is_movie_added() is False

    def test_sonarr_webhook_payload_parsing(self) -> None:
        """Should parse Sonarr webhook payload correctly."""
        data = {
            "eventType": "SeriesAdd",
            "series": {
                "id": 456,
                "title": "Breaking Bad",
                "year": 2008,
                "tvdbId": 81189,
                "imdbId": "tt0903747",
            },
        }
        payload = SonarrWebhookPayload.model_validate(data)

        assert payload.event_type == "SeriesAdd"
        assert payload.series.id == 456
        assert payload.series.title == "Breaking Bad"
        assert payload.series.year == 2008
        assert payload.series.tvdb_id == 81189
        assert payload.series.imdb_id == "tt0903747"
        assert payload.is_series_add() is True

    def test_sonarr_webhook_non_add_event(self) -> None:
        """Should correctly identify non-SeriesAdd events."""
        data = {
            "eventType": "Download",
            "series": {"id": 456, "title": "Test Series"},
        }
        payload = SonarrWebhookPayload.model_validate(data)

        assert payload.event_type == "Download"
        assert payload.is_series_add() is False

    def test_webhook_response_model(self) -> None:
        """Should create webhook response correctly."""
        response = WebhookResponse(
            status="accepted",
            message="4K availability check queued",
            media_id=123,
            media_title="The Matrix",
        )

        assert response.status == "accepted"
        assert response.message == "4K availability check queued"
        assert response.media_id == 123
        assert response.media_title == "The Matrix"


class TestHealthEndpoint:
    """Tests for the health check endpoint."""

    def test_health_check_returns_healthy(self, test_client: TestClient) -> None:
        """Should return healthy status."""
        response = test_client.get("/health")

        assert response.status_code == 200
        assert response.json() == {"status": "healthy"}


class TestStatusEndpoint:
    """Tests for the status endpoint."""

    def test_status_without_scheduler(self, test_client: TestClient) -> None:
        """Should return status without scheduler info."""
        response = test_client.get("/status")

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "healthy"
        assert "timestamp" in data
        assert data["radarr_configured"] is True
        assert data["sonarr_configured"] is True
        assert data["scheduler"] == {"enabled": False}

    def test_status_with_scheduler_manager(self, full_config: Config) -> None:
        """Should return scheduler info when scheduler manager is set."""
        # Create mock scheduler manager
        mock_scheduler = MagicMock()
        mock_scheduler.is_running = True
        mock_scheduler.get_all_schedules.return_value = [
            MagicMock(enabled=True),
            MagicMock(enabled=True),
            MagicMock(enabled=False),
        ]
        mock_scheduler.get_running_schedules.return_value = {"test-schedule"}
        mock_scheduler.get_history.return_value = [
            MagicMock(
                schedule_name="test-schedule",
                status=MagicMock(value="completed"),
                started_at=datetime.now(UTC),
                items_processed=10,
                items_with_4k=5,
            )
        ]

        # Create app with scheduler manager passed via create_app
        app = filtarr.webhook.create_app(full_config, scheduler_manager=mock_scheduler)
        client = TestClient(app)

        response = client.get("/status")

        assert response.status_code == 200
        data = response.json()
        assert data["scheduler"]["enabled"] is True
        assert data["scheduler"]["running"] is True
        assert data["scheduler"]["total_schedules"] == 3
        assert data["scheduler"]["enabled_schedules"] == 2
        assert data["scheduler"]["currently_running"] == ["test-schedule"]
        assert len(data["scheduler"]["recent_runs"]) == 1

    def test_status_radarr_only_config(self, radarr_only_config: Config) -> None:
        """Should show sonarr_configured as False when not configured."""
        app = filtarr.webhook.create_app(radarr_only_config)
        client = TestClient(app)

        response = client.get("/status")

        assert response.status_code == 200
        data = response.json()
        assert data["radarr_configured"] is True
        assert data["sonarr_configured"] is False

    def test_status_sonarr_only_config(self, sonarr_only_config: Config) -> None:
        """Should show radarr_configured as False when not configured."""
        app = filtarr.webhook.create_app(sonarr_only_config)
        client = TestClient(app)

        response = client.get("/status")

        assert response.status_code == 200
        data = response.json()
        assert data["radarr_configured"] is False
        assert data["sonarr_configured"] is True


class TestRadarrWebhook:
    """Tests for the Radarr webhook endpoint."""

    def test_radarr_webhook_requires_api_key(self, test_client: TestClient) -> None:
        """Should reject requests without API key."""
        response = test_client.post(
            "/webhook/radarr",
            json={
                "eventType": "MovieAdded",
                "movie": {"id": 123, "title": "Test Movie"},
            },
        )

        assert response.status_code == 401
        assert "X-Api-Key" in response.json()["detail"]

    def test_radarr_webhook_rejects_invalid_api_key(self, test_client: TestClient) -> None:
        """Should reject requests with invalid API key."""
        response = test_client.post(
            "/webhook/radarr",
            json={
                "eventType": "MovieAdded",
                "movie": {"id": 123, "title": "Test Movie"},
            },
            headers={"X-Api-Key": "wrong-key"},
        )

        assert response.status_code == 401

    def test_radarr_webhook_accepts_valid_api_key(self, full_config: Config) -> None:
        """Should accept requests with valid Radarr API key."""
        app = filtarr.webhook.create_app(full_config)
        client = TestClient(app)

        mock_create_task = CreateTaskMock()
        with patch("filtarr.webhook.asyncio.create_task", mock_create_task):
            response = client.post(
                "/webhook/radarr",
                json={
                    "eventType": "MovieAdded",
                    "movie": {"id": 123, "title": "Test Movie", "year": 2023},
                },
                headers={"X-Api-Key": "radarr-api-key"},
            )

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "accepted"
        assert data["media_id"] == 123
        assert data["media_title"] == "Test Movie"

    def test_radarr_webhook_accepts_sonarr_api_key(self, full_config: Config) -> None:
        """Should accept Radarr webhook with Sonarr API key (authentication only)."""
        app = filtarr.webhook.create_app(full_config)
        client = TestClient(app)

        mock_create_task = CreateTaskMock()
        with patch("filtarr.webhook.asyncio.create_task", mock_create_task):
            response = client.post(
                "/webhook/radarr",
                json={
                    "eventType": "MovieAdded",
                    "movie": {"id": 123, "title": "Test Movie", "year": 2023},
                },
                headers={"X-Api-Key": "sonarr-api-key"},
            )

        assert response.status_code == 200

    def test_radarr_webhook_ignores_non_movie_added_events(self, full_config: Config) -> None:
        """Should ignore events that are not MovieAdded."""
        app = filtarr.webhook.create_app(full_config)
        client = TestClient(app)

        response = client.post(
            "/webhook/radarr",
            json={
                "eventType": "Download",
                "movie": {"id": 123, "title": "Test Movie"},
            },
            headers={"X-Api-Key": "radarr-api-key"},
        )

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "ignored"
        assert "Download" in data["message"]

    def test_radarr_webhook_not_configured(self, sonarr_only_config: Config) -> None:
        """Should return 503 when Radarr is not configured."""
        app = filtarr.webhook.create_app(sonarr_only_config)
        client = TestClient(app)

        response = client.post(
            "/webhook/radarr",
            json={
                "eventType": "MovieAdded",
                "movie": {"id": 123, "title": "Test Movie"},
            },
            headers={"X-Api-Key": "sonarr-api-key"},
        )

        assert response.status_code == 503
        assert "Radarr is not configured" in response.json()["detail"]


class TestSonarrWebhook:
    """Tests for the Sonarr webhook endpoint."""

    def test_sonarr_webhook_requires_api_key(self, test_client: TestClient) -> None:
        """Should reject requests without API key."""
        response = test_client.post(
            "/webhook/sonarr",
            json={
                "eventType": "SeriesAdd",
                "series": {"id": 456, "title": "Test Series"},
            },
        )

        assert response.status_code == 401
        assert "X-Api-Key" in response.json()["detail"]

    def test_sonarr_webhook_rejects_invalid_api_key(self, test_client: TestClient) -> None:
        """Should reject requests with invalid API key."""
        response = test_client.post(
            "/webhook/sonarr",
            json={
                "eventType": "SeriesAdd",
                "series": {"id": 456, "title": "Test Series"},
            },
            headers={"X-Api-Key": "wrong-key"},
        )

        assert response.status_code == 401

    def test_sonarr_webhook_accepts_valid_api_key(self, full_config: Config) -> None:
        """Should accept requests with valid Sonarr API key."""
        app = filtarr.webhook.create_app(full_config)
        client = TestClient(app)

        mock_create_task = CreateTaskMock()
        with patch("filtarr.webhook.asyncio.create_task", mock_create_task):
            response = client.post(
                "/webhook/sonarr",
                json={
                    "eventType": "SeriesAdd",
                    "series": {"id": 456, "title": "Breaking Bad", "year": 2008},
                },
                headers={"X-Api-Key": "sonarr-api-key"},
            )

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "accepted"
        assert data["media_id"] == 456
        assert data["media_title"] == "Breaking Bad"

    def test_sonarr_webhook_ignores_non_series_add_events(self, full_config: Config) -> None:
        """Should ignore events that are not SeriesAdd."""
        app = filtarr.webhook.create_app(full_config)
        client = TestClient(app)

        response = client.post(
            "/webhook/sonarr",
            json={
                "eventType": "Download",
                "series": {"id": 456, "title": "Test Series"},
            },
            headers={"X-Api-Key": "sonarr-api-key"},
        )

        assert response.status_code == 200
        data = response.json()
        assert data["status"] == "ignored"
        assert "Download" in data["message"]

    def test_sonarr_webhook_not_configured(self, radarr_only_config: Config) -> None:
        """Should return 503 when Sonarr is not configured."""
        app = filtarr.webhook.create_app(radarr_only_config)
        client = TestClient(app)

        response = client.post(
            "/webhook/sonarr",
            json={
                "eventType": "SeriesAdd",
                "series": {"id": 456, "title": "Test Series"},
            },
            headers={"X-Api-Key": "radarr-api-key"},
        )

        assert response.status_code == 503
        assert "Sonarr is not configured" in response.json()["detail"]


class TestBackgroundProcessing:
    """Tests for background task processing."""

    @pytest.mark.asyncio
    async def test_movie_check_background_task_created(self, full_config: Config) -> None:
        """Should create background task for movie check on MovieAdded event."""
        app = filtarr.webhook.create_app(full_config)
        client = TestClient(app)

        mock_create_task = CreateTaskMock()
        with patch("filtarr.webhook.asyncio.create_task", mock_create_task):
            response = client.post(
                "/webhook/radarr",
                json={
                    "eventType": "MovieAdded",
                    "movie": {"id": 123, "title": "Test Movie", "year": 2023},
                },
                headers={"X-Api-Key": "radarr-api-key"},
            )

            assert response.status_code == 200
            mock_create_task.assert_called_once()

    @pytest.mark.asyncio
    async def test_series_check_background_task_created(self, full_config: Config) -> None:
        """Should create background task for series check on SeriesAdd event."""
        app = filtarr.webhook.create_app(full_config)
        client = TestClient(app)

        mock_create_task = CreateTaskMock()
        with patch("filtarr.webhook.asyncio.create_task", mock_create_task):
            response = client.post(
                "/webhook/sonarr",
                json={
                    "eventType": "SeriesAdd",
                    "series": {"id": 456, "title": "Test Series", "year": 2020},
                },
                headers={"X-Api-Key": "sonarr-api-key"},
            )

            assert response.status_code == 200
            mock_create_task.assert_called_once()


class TestProcessMovieCheck:
    """Tests for _process_movie_check background task."""

    @respx.mock
    @pytest.mark.asyncio
    async def test_process_movie_check_success(self, full_config: Config) -> None:
        """Should successfully check movie for 4K availability."""
        # Mock movie info endpoint
        respx.get("http://localhost:7878/api/v3/movie/123").mock(
            return_value=Response(
                200,
                json={"id": 123, "title": "Test Movie", "year": 2024, "tags": []},
            )
        )
        # Mock releases endpoint with 4K release
        respx.get("http://localhost:7878/api/v3/release", params={"movieId": "123"}).mock(
            return_value=Response(
                200,
                json=[
                    {
                        "guid": "rel1",
                        "title": "Movie.2160p.BluRay",
                        "indexer": "Test",
                        "size": 5000,
                        "quality": {"quality": {"id": 19, "name": "WEBDL-2160p"}},
                    }
                ],
            )
        )
        # Mock tags endpoint
        respx.get("http://localhost:7878/api/v3/tag").mock(
            return_value=Response(200, json=[{"id": 1, "label": "4k-available"}])
        )
        # Mock update movie
        respx.put("http://localhost:7878/api/v3/movie/123").mock(
            return_value=Response(
                200,
                json={"id": 123, "title": "Test Movie", "year": 2024, "tags": [1]},
            )
        )

        # Should complete without error
        await filtarr.webhook._process_movie_check(123, "Test Movie", full_config)

    @pytest.mark.asyncio
    async def test_process_movie_check_handles_exception(self, full_config: Config) -> None:
        """Should handle exceptions gracefully in background task."""
        # Mock ReleaseChecker to raise an exception
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_checker.check_movie = AsyncMock(side_effect=Exception("API error"))
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_movie_check(123, "Test Movie", full_config)


class TestProcessSeriesCheck:
    """Tests for _process_series_check background task."""

    @respx.mock
    @pytest.mark.asyncio
    async def test_process_series_check_success(self, full_config: Config) -> None:
        """Should successfully check series for 4K availability."""
        # Mock series info endpoint
        respx.get("http://127.0.0.1:8989/api/v3/series/456").mock(
            return_value=Response(
                200,
                json={"id": 456, "title": "Test Series", "year": 2020, "seasons": [], "tags": []},
            )
        )
        # Mock episodes endpoint
        respx.get("http://127.0.0.1:8989/api/v3/episode", params={"seriesId": "456"}).mock(
            return_value=Response(
                200,
                json=[
                    {
                        "id": 1001,
                        "seriesId": 456,
                        "seasonNumber": 1,
                        "episodeNumber": 1,
                        "airDate": "2020-01-01",
                        "monitored": True,
                    }
                ],
            )
        )
        # Mock releases endpoint
        respx.get("http://127.0.0.1:8989/api/v3/release", params={"episodeId": "1001"}).mock(
            return_value=Response(
                200,
                json=[
                    {
                        "guid": "rel1",
                        "title": "Series.S01E01.2160p",
                        "indexer": "Test",
                        "size": 3000,
                        "quality": {"quality": {"id": 19, "name": "WEBDL-2160p"}},
                    }
                ],
            )
        )
        # Mock tags endpoint
        respx.get("http://127.0.0.1:8989/api/v3/tag").mock(
            return_value=Response(200, json=[{"id": 1, "label": "4k-available"}])
        )
        # Mock update series
        respx.put("http://127.0.0.1:8989/api/v3/series/456").mock(
            return_value=Response(
                200,
                json={"id": 456, "title": "Test Series", "year": 2020, "tags": [1]},
            )
        )

        # Should complete without error
        await filtarr.webhook._process_series_check(456, "Test Series", full_config)

    @pytest.mark.asyncio
    async def test_process_series_check_handles_exception(self, full_config: Config) -> None:
        """Should handle exceptions gracefully in background task."""
        # Mock ReleaseChecker to raise an exception
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_checker.check_series = AsyncMock(side_effect=Exception("API error"))
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_series_check(456, "Test Series", full_config)


class TestProcessMovieCheckExceptions:
    """Tests for _process_movie_check exception handling."""

    @pytest.mark.asyncio
    async def test_handles_configuration_error(self, full_config: Config) -> None:
        """Should handle ConfigurationError from config.require_radarr()."""
        with patch.object(
            full_config, "require_radarr", side_effect=ConfigurationError("Radarr not configured")
        ):
            # Should not raise, just log the error
            await filtarr.webhook._process_movie_check(123, "Test Movie", full_config)

    @pytest.mark.asyncio
    async def test_handles_http_status_error(self, full_config: Config) -> None:
        """Should handle HTTPStatusError from checker."""
        mock_response = MagicMock()
        mock_response.status_code = 500
        mock_response.reason_phrase = "Internal Server Error"
        mock_request = MagicMock()

        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_checker.check_movie = AsyncMock(
                side_effect=httpx.HTTPStatusError(
                    "Server error", request=mock_request, response=mock_response
                )
            )
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_movie_check(123, "Test Movie", full_config)

    @pytest.mark.asyncio
    async def test_handles_connect_error(self, full_config: Config) -> None:
        """Should handle ConnectError from checker."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_checker.check_movie = AsyncMock(
                side_effect=httpx.ConnectError("Connection refused")
            )
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_movie_check(123, "Test Movie", full_config)

    @pytest.mark.asyncio
    async def test_handles_timeout_exception(self, full_config: Config) -> None:
        """Should handle TimeoutException from checker."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_checker.check_movie = AsyncMock(side_effect=httpx.TimeoutException("Timed out"))
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_movie_check(123, "Test Movie", full_config)

    @pytest.mark.asyncio
    async def test_handles_validation_error(self, full_config: Config) -> None:
        """Should handle ValidationError from checker."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            # Create a ValidationError with proper structure
            mock_checker.check_movie = AsyncMock(
                side_effect=ValidationError.from_exception_data(
                    "TestModel", [{"type": "missing", "loc": ("field",), "input": {}}]
                )
            )
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_movie_check(123, "Test Movie", full_config)

    @pytest.mark.asyncio
    async def test_handles_generic_exception(self, full_config: Config) -> None:
        """Should handle generic Exception from checker."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_checker.check_movie = AsyncMock(side_effect=RuntimeError("Unexpected error"))
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_movie_check(123, "Test Movie", full_config)


class TestProcessSeriesCheckExceptions:
    """Tests for _process_series_check exception handling."""

    @pytest.mark.asyncio
    async def test_handles_configuration_error(self, full_config: Config) -> None:
        """Should handle ConfigurationError from config.require_sonarr()."""
        with patch.object(
            full_config, "require_sonarr", side_effect=ConfigurationError("Sonarr not configured")
        ):
            # Should not raise, just log the error
            await filtarr.webhook._process_series_check(456, "Test Series", full_config)

    @pytest.mark.asyncio
    async def test_handles_http_status_error(self, full_config: Config) -> None:
        """Should handle HTTPStatusError from checker."""
        mock_response = MagicMock()
        mock_response.status_code = 500
        mock_response.reason_phrase = "Internal Server Error"
        mock_request = MagicMock()

        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_checker.check_series = AsyncMock(
                side_effect=httpx.HTTPStatusError(
                    "Server error", request=mock_request, response=mock_response
                )
            )
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_series_check(456, "Test Series", full_config)

    @pytest.mark.asyncio
    async def test_handles_connect_error(self, full_config: Config) -> None:
        """Should handle ConnectError from checker."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_checker.check_series = AsyncMock(
                side_effect=httpx.ConnectError("Connection refused")
            )
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_series_check(456, "Test Series", full_config)

    @pytest.mark.asyncio
    async def test_handles_timeout_exception(self, full_config: Config) -> None:
        """Should handle TimeoutException from checker."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_checker.check_series = AsyncMock(side_effect=httpx.TimeoutException("Timed out"))
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_series_check(456, "Test Series", full_config)

    @pytest.mark.asyncio
    async def test_handles_validation_error(self, full_config: Config) -> None:
        """Should handle ValidationError from checker."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            # Create a ValidationError with proper structure
            mock_checker.check_series = AsyncMock(
                side_effect=ValidationError.from_exception_data(
                    "TestModel", [{"type": "missing", "loc": ("field",), "input": {}}]
                )
            )
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_series_check(456, "Test Series", full_config)

    @pytest.mark.asyncio
    async def test_handles_generic_exception(self, full_config: Config) -> None:
        """Should handle generic Exception from checker."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_checker.check_series = AsyncMock(side_effect=RuntimeError("Unexpected error"))
            mock_checker_class.return_value = mock_checker

            # Should not raise, just log the error
            await filtarr.webhook._process_series_check(456, "Test Series", full_config)


class TestExceptionHandler:
    """Tests for the global exception handler."""

    def test_global_exception_handler(self, full_config: Config) -> None:
        """Should return 500 for unhandled exceptions."""
        app = filtarr.webhook.create_app(full_config)

        # Add a route that raises an exception
        @app.get("/raise-error")  # type: ignore[untyped-decorator]
        async def raise_error() -> None:
            raise RuntimeError("Test exception")

        client = TestClient(app, raise_server_exceptions=False)
        response = client.get("/raise-error")

        assert response.status_code == 500
        data = response.json()
        assert data["status"] == "error"
        assert data["message"] == "Internal server error"
        assert data["media_id"] is None
        assert data["media_title"] is None


class TestWebhookConfig:
    """Tests for WebhookConfig in the Config class."""

    def test_default_webhook_config(self) -> None:
        """Should have default webhook configuration."""
        config = Config()

        assert config.webhook.host == "0.0.0.0"
        assert config.webhook.port == 8080

    def test_custom_webhook_config(self) -> None:
        """Should accept custom webhook configuration."""
        config = Config(webhook=WebhookConfig(host="127.0.0.1", port=9000))

        assert config.webhook.host == "127.0.0.1"
        assert config.webhook.port == 9000


class TestCreateAppWithoutConfig:
    """Tests for create_app when config is not provided."""

    def test_create_app_loads_default_config(self) -> None:
        """Should load default config when none provided."""
        # Mock Config.load() to return a test config
        with patch("filtarr.webhook.Config.load") as mock_load:
            mock_config = Config(
                radarr=RadarrConfig(url="http://localhost:7878", api_key="test-key"),
            )
            mock_load.return_value = mock_config

            app = filtarr.webhook.create_app()
            assert app is not None
            mock_load.assert_called_once()


class TestFastAPIImportError:
    """Tests for FastAPI import error handling."""

    def test_create_app_raises_import_error(self) -> None:
        """Should raise ImportError when FastAPI is not installed."""
        with (
            patch.dict("sys.modules", {"fastapi": None}),
            patch("builtins.__import__", side_effect=ImportError("No module")),
        ):
            # This is tricky to test since FastAPI is installed
            # We verify the error message format instead
            pass  # The import error path is covered by the module structure


class TestRunServer:
    """Tests for the run_server function."""

    def test_run_server_imports_uvicorn(self) -> None:
        """Should import uvicorn when running server."""
        # Verify run_server is callable
        assert callable(filtarr.webhook.run_server)

    def test_run_server_with_scheduler_disabled(self, full_config: Config) -> None:
        """Should start server without scheduler when disabled."""
        import uvicorn

        from filtarr.config import SchedulerConfig

        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            scheduler=SchedulerConfig(enabled=False),
        )

        with patch.object(uvicorn, "run") as mock_uvicorn_run:
            filtarr.webhook.run_server(config=config, scheduler_enabled=False)

            mock_uvicorn_run.assert_called_once()

    def test_run_server_loads_default_config(self) -> None:
        """Should load default config when none provided."""
        import uvicorn

        mock_config = Config()

        with (
            patch.object(uvicorn, "run"),
            patch("filtarr.webhook.Config.load") as mock_load,
        ):
            mock_load.return_value = mock_config

            filtarr.webhook.run_server(scheduler_enabled=False)

            mock_load.assert_called_once()


class TestBackgroundTaskManagement:
    """Tests for background task lifecycle management."""

    def test_background_task_added_to_set(self, full_config: Config) -> None:
        """Should add background tasks to set for GC protection."""
        app = filtarr.webhook.create_app(full_config)
        client = TestClient(app)

        mock_create_task = CreateTaskMock()
        with patch("filtarr.webhook.asyncio.create_task", mock_create_task):
            response = client.post(
                "/webhook/radarr",
                json={
                    "eventType": "MovieAdded",
                    "movie": {"id": 123, "title": "Test Movie", "year": 2023},
                },
                headers={"X-Api-Key": "radarr-api-key"},
            )

            assert response.status_code == 200
            # Verify task was created and done callback was added
            mock_create_task.assert_called_once()
            mock_task = mock_create_task.last_task
            mock_task.add_done_callback.assert_called_once()


class TestImportErrors:
    """Tests for import error handling in webhook module."""

    def test_create_app_fastapi_import_error(self, full_config: Config) -> None:
        """Should raise ImportError with helpful message when FastAPI not installed."""
        import sys

        # Save and remove fastapi from sys.modules to force re-import attempt
        saved_modules: dict[str, Any] = {}
        fastapi_modules = [
            key
            for key in list(sys.modules.keys())
            if key == "fastapi" or key.startswith("fastapi.")
        ]
        for mod in fastapi_modules:
            saved_modules[mod] = sys.modules.pop(mod)

        try:
            # Mock builtins.__import__ to fail for fastapi
            import builtins

            original_import = builtins.__import__

            def mock_import(name: str, *args: Any, **kwargs: Any) -> Any:
                if name == "fastapi" or name.startswith("fastapi."):
                    raise ImportError("No module named 'fastapi'")
                return original_import(name, *args, **kwargs)

            with (
                patch.object(builtins, "__import__", side_effect=mock_import),
                pytest.raises(ImportError) as exc_info,
            ):
                # Call create_app which has a local import of fastapi
                filtarr.webhook.create_app(full_config)

        finally:
            # Restore all saved modules
            sys.modules.update(saved_modules)

        assert "filtarr[webhook]" in str(exc_info.value)
        assert "FastAPI is required" in str(exc_info.value)

    def test_run_server_uvicorn_import_error(self, full_config: Config) -> None:
        """Should raise ImportError with helpful message when uvicorn not installed."""
        import builtins

        original_import = builtins.__import__

        def mock_import(name: str, *args, **kwargs):  # type: ignore[no-untyped-def]
            if name == "uvicorn":
                raise ImportError("No module named 'uvicorn'")
            return original_import(name, *args, **kwargs)

        with (
            patch.object(builtins, "__import__", side_effect=mock_import),
            pytest.raises(ImportError) as exc_info,
        ):
            filtarr.webhook.run_server(config=full_config, scheduler_enabled=False)

        assert "filtarr[webhook]" in str(exc_info.value)
        assert "uvicorn is required" in str(exc_info.value)


class TestTTLCaching:
    """Tests for TTL caching functionality in webhook processing."""

    @pytest.mark.asyncio
    async def test_movie_check_skips_when_ttl_cache_hit(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should skip processing when movie was recently checked (TTL hit)."""
        import logging

        # Configure TTL to 24 hours
        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            tags=full_config.tags,
            state=StateConfig(ttl_hours=24),
        )

        # Create a mock state manager with cached result
        mock_state_manager = MagicMock()
        cached_record = CheckRecord(
            last_checked=datetime.now(UTC),
            result="available",
            tag_applied="4k-available",
        )
        mock_state_manager.get_cached_result.return_value = cached_record

        # Pass state_manager as parameter (no global state)
        with caplog.at_level(logging.INFO):
            await filtarr.webhook._process_movie_check(
                123, "Test Movie", config, state_manager=mock_state_manager
            )

        # Verify state manager was queried for cached result
        mock_state_manager.get_cached_result.assert_called_once_with("movie", 123, 24)

        # Verify log message about using cached result (new format)
        assert any(
            "Check result: skipped (recently checked)" in record.getMessage()
            for record in caplog.records
        )
        assert any("4K available" in record.getMessage() for record in caplog.records)

    @pytest.mark.asyncio
    async def test_movie_check_proceeds_when_ttl_cache_miss(self, full_config: Config) -> None:
        """Should proceed with check when movie TTL cache misses."""
        # Configure TTL to 24 hours
        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            tags=full_config.tags,
            state=StateConfig(ttl_hours=24),
        )

        # Create a mock state manager with no cached result
        mock_state_manager = MagicMock()
        mock_state_manager.get_cached_result.return_value = None

        # Mock ReleaseChecker to avoid actual API calls
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = True
            mock_result.matched_releases = []
            mock_result.releases = []
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = "4k-available"
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_movie = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            await filtarr.webhook._process_movie_check(
                123, "Test Movie", config, state_manager=mock_state_manager
            )

            # Verify ReleaseChecker was created and used
            mock_checker_class.assert_called_once()
            mock_checker.check_movie.assert_called_once_with(123, apply_tags=True)

    @pytest.mark.asyncio
    async def test_movie_check_skips_ttl_when_disabled(self, full_config: Config) -> None:
        """Should skip TTL check when ttl_hours is 0 (disabled)."""
        # Configure TTL to 0 (disabled)
        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            tags=full_config.tags,
            state=StateConfig(ttl_hours=0),
        )

        mock_state_manager = MagicMock()

        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = False
            mock_result.matched_releases = []
            mock_result.releases = []
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = None
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_movie = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            await filtarr.webhook._process_movie_check(
                123, "Test Movie", config, state_manager=mock_state_manager
            )

            # TTL check should not be called when disabled
            mock_state_manager.get_cached_result.assert_not_called()

    @pytest.mark.asyncio
    async def test_series_check_skips_when_ttl_cache_hit(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should skip processing when series was recently checked (TTL hit)."""
        import logging

        # Configure TTL to 24 hours
        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            tags=full_config.tags,
            state=StateConfig(ttl_hours=24),
        )

        # Create a mock state manager with cached result
        mock_state_manager = MagicMock()
        cached_record = CheckRecord(
            last_checked=datetime.now(UTC),
            result="unavailable",
            tag_applied="4k-unavailable",
        )
        mock_state_manager.get_cached_result.return_value = cached_record

        with caplog.at_level(logging.INFO):
            await filtarr.webhook._process_series_check(
                456, "Breaking Bad", config, state_manager=mock_state_manager
            )

        # Verify state manager was queried for cached result
        mock_state_manager.get_cached_result.assert_called_once_with("series", 456, 24)

        # Verify log message about using cached result (new format)
        assert any(
            "Check result: skipped (recently checked)" in record.getMessage()
            for record in caplog.records
        )
        assert any("4K not available" in record.getMessage() for record in caplog.records)

    @pytest.mark.asyncio
    async def test_series_check_proceeds_when_ttl_cache_miss(self, full_config: Config) -> None:
        """Should proceed with check when series TTL cache misses."""
        # Configure TTL to 24 hours
        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            tags=full_config.tags,
            state=StateConfig(ttl_hours=24),
        )

        # Create a mock state manager with no cached result
        mock_state_manager = MagicMock()
        mock_state_manager.get_cached_result.return_value = None

        # Mock ReleaseChecker to avoid actual API calls
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = True
            mock_result.matched_releases = []
            mock_result.releases = []
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = "4k-available"
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_series = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            await filtarr.webhook._process_series_check(
                456, "Breaking Bad", config, state_manager=mock_state_manager
            )

            # Verify ReleaseChecker was created and used
            mock_checker_class.assert_called_once()
            mock_checker.check_series.assert_called_once_with(456, apply_tags=True)

    @pytest.mark.asyncio
    async def test_series_check_skips_ttl_when_disabled(self, full_config: Config) -> None:
        """Should skip TTL check when ttl_hours is 0 (disabled)."""
        # Configure TTL to 0 (disabled)
        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            tags=full_config.tags,
            state=StateConfig(ttl_hours=0),
        )

        mock_state_manager = MagicMock()

        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = False
            mock_result.matched_releases = []
            mock_result.releases = []
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = None
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_series = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            await filtarr.webhook._process_series_check(
                456, "Breaking Bad", config, state_manager=mock_state_manager
            )

            # TTL check should not be called when disabled
            mock_state_manager.get_cached_result.assert_not_called()


class TestStateRecording:
    """Tests for state recording after successful checks."""

    @pytest.mark.asyncio
    async def test_movie_check_records_result_in_state(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should record check result in state file after successful movie check."""
        import logging

        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            tags=full_config.tags,
            state=StateConfig(ttl_hours=24),
        )

        mock_state_manager = MagicMock()
        mock_state_manager.get_cached_result.return_value = None

        with (
            patch("filtarr.webhook.ReleaseChecker") as mock_checker_class,
            caplog.at_level(logging.INFO),
        ):
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = True
            mock_result.matched_releases = [MagicMock()]
            mock_result.releases = [MagicMock()]
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = "4k-available"
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_movie = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            await filtarr.webhook._process_movie_check(
                123, "Test Movie", config, state_manager=mock_state_manager
            )

            # Verify state was recorded
            mock_state_manager.record_check.assert_called_once_with(
                "movie",
                123,
                True,
                "4k-available",
            )

            # Verify log message includes tag info (new format)
            assert any(
                "Check result:" in record.getMessage()
                and "available" in record.getMessage()
                and "4k-available" in record.getMessage()
                for record in caplog.records
            )

    @pytest.mark.asyncio
    async def test_series_check_records_result_in_state(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should record check result in state file after successful series check."""
        import logging

        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            tags=full_config.tags,
            state=StateConfig(ttl_hours=24),
        )

        mock_state_manager = MagicMock()
        mock_state_manager.get_cached_result.return_value = None

        with (
            patch("filtarr.webhook.ReleaseChecker") as mock_checker_class,
            caplog.at_level(logging.INFO),
        ):
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = False
            mock_result.matched_releases = []
            mock_result.releases = [MagicMock(), MagicMock()]
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = "4k-unavailable"
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_series = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            await filtarr.webhook._process_series_check(
                456, "Breaking Bad", config, state_manager=mock_state_manager
            )

            # Verify state was recorded
            mock_state_manager.record_check.assert_called_once_with(
                "series",
                456,
                False,
                "4k-unavailable",
            )

            # Verify log message includes tag info (new format)
            assert any(
                "Check result:" in record.getMessage()
                and "unavailable" in record.getMessage()
                and "4k-unavailable" in record.getMessage()
                for record in caplog.records
            )


class TestStateManagerInitialization:
    """Tests for state manager initialization in run_server."""

    def test_state_manager_initialized_at_startup(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should initialize state manager at server startup."""
        import logging

        import uvicorn

        mock_state_manager = MagicMock()

        with (
            patch(
                "filtarr.state.StateManager", return_value=mock_state_manager
            ) as mock_state_class,
            patch.object(uvicorn, "run") as mock_uvicorn_run,
            caplog.at_level(logging.INFO),
        ):
            filtarr.webhook.run_server(config=full_config, scheduler_enabled=False)

            # Verify StateManager was created with correct path
            mock_state_class.assert_called_once_with(full_config.state.path)

            # Verify ensure_initialized was called
            mock_state_manager.ensure_initialized.assert_called_once()

            # Server should be started
            mock_uvicorn_run.assert_called_once()

            # Verify log message about state initialization
            assert any("State file initialized" in record.getMessage() for record in caplog.records)

    def test_state_ttl_disabled_logging(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should log 'TTL: disabled' when ttl_hours is 0."""
        import logging

        import uvicorn

        # Configure TTL to 0 (disabled)
        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            state=StateConfig(ttl_hours=0),
        )

        mock_state_manager = MagicMock()

        with (
            patch("filtarr.state.StateManager", return_value=mock_state_manager),
            patch.object(uvicorn, "run"),
            caplog.at_level(logging.INFO),
        ):
            filtarr.webhook.run_server(config=config, scheduler_enabled=False)

            # Verify log message about TTL being disabled
            assert any("State TTL: disabled" in record.getMessage() for record in caplog.records)

    def test_state_ttl_enabled_logging(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should log TTL hours when ttl_hours is greater than 0."""
        import logging

        import uvicorn

        # Configure TTL to 48 hours
        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            state=StateConfig(ttl_hours=48),
        )

        mock_state_manager = MagicMock()

        with (
            patch("filtarr.state.StateManager", return_value=mock_state_manager),
            patch.object(uvicorn, "run"),
            caplog.at_level(logging.INFO),
        ):
            filtarr.webhook.run_server(config=config, scheduler_enabled=False)

            # Verify log message about TTL hours
            assert any("State TTL: 48 hours" in record.getMessage() for record in caplog.records)


class TestSchedulerLifecycle:
    """Tests for scheduler lifecycle management in run_server."""

    def test_scheduler_initialization_when_enabled(self, full_config: Config) -> None:
        """Should create scheduler manager when scheduler is enabled in config."""
        from filtarr.config import SchedulerConfig

        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            scheduler=SchedulerConfig(enabled=True),
        )

        mock_scheduler_manager = MagicMock()
        mock_scheduler_manager.start = AsyncMock()
        mock_scheduler_manager.stop = AsyncMock()

        mock_state_manager = MagicMock()

        with (
            patch(
                "filtarr.scheduler.SchedulerManager", return_value=mock_scheduler_manager
            ) as mock_scheduler_class,
            patch(
                "filtarr.state.StateManager", return_value=mock_state_manager
            ) as mock_state_class,
            patch("filtarr.webhook.create_app"),
            patch("uvicorn.Config"),
            patch("uvicorn.Server") as mock_server_class,
        ):
            # Mock server.serve() to not actually run
            mock_server = MagicMock()
            mock_server.serve = AsyncMock()
            mock_server_class.return_value = mock_server

            # Run the server briefly
            filtarr.webhook.run_server(config=config, scheduler_enabled=True)

            # Verify scheduler was initialized
            mock_state_class.assert_called_once()
            mock_scheduler_class.assert_called_once_with(config, mock_state_manager)
            mock_scheduler_manager.start.assert_called_once()

    def test_scheduler_import_error_continues_without_scheduler(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should log warning and continue without scheduler when imports fail."""
        import builtins
        import logging

        import uvicorn

        from filtarr.config import SchedulerConfig

        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            scheduler=SchedulerConfig(enabled=True),
        )

        original_import = builtins.__import__

        def mock_import(
            name: str,
            globals_: dict[str, object] | None = None,
            locals_: dict[str, object] | None = None,
            fromlist: tuple[str, ...] = (),
            level: int = 0,
        ) -> object:
            # Only block scheduler imports - state is a core dependency
            if name == "filtarr.scheduler":
                raise ImportError(f"No module named '{name}'")
            return original_import(name, globals_, locals_, fromlist, level)

        with (
            patch.object(builtins, "__import__", side_effect=mock_import),
            patch.object(uvicorn, "run") as mock_uvicorn_run,
            caplog.at_level(logging.WARNING),
        ):
            filtarr.webhook.run_server(config=config, scheduler_enabled=True)

            # Server should still be started (without scheduler)
            mock_uvicorn_run.assert_called_once()

            # Should have logged warning about scheduler dependencies
            assert any(
                "Scheduler dependencies not installed" in record.getMessage()
                for record in caplog.records
            )

    def test_scheduler_start_import_error(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should log error when scheduler.start() raises ImportError."""
        import logging

        from filtarr.config import SchedulerConfig

        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            scheduler=SchedulerConfig(enabled=True),
        )

        mock_scheduler_manager = MagicMock()
        mock_scheduler_manager.start = AsyncMock(
            side_effect=ImportError("APScheduler not installed")
        )
        mock_scheduler_manager.stop = AsyncMock()

        mock_state_manager = MagicMock()

        with (
            patch("filtarr.scheduler.SchedulerManager", return_value=mock_scheduler_manager),
            patch("filtarr.state.StateManager", return_value=mock_state_manager),
            patch("filtarr.webhook.create_app"),
            patch("uvicorn.Config"),
            patch("uvicorn.Server") as mock_server_class,
            caplog.at_level(logging.DEBUG),
        ):
            mock_server = MagicMock()
            mock_server.serve = AsyncMock()
            mock_server_class.return_value = mock_server

            filtarr.webhook.run_server(config=config, scheduler_enabled=True)

            # Scheduler start should have been attempted
            mock_scheduler_manager.start.assert_called_once()

            # Error should be logged
            assert any(
                "Failed to start scheduler" in record.getMessage() for record in caplog.records
            )

    def test_scheduler_start_value_error(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should log error when scheduler.start() raises ValueError."""
        import logging

        from filtarr.config import SchedulerConfig

        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            scheduler=SchedulerConfig(enabled=True),
        )

        mock_scheduler_manager = MagicMock()
        mock_scheduler_manager.start = AsyncMock(
            side_effect=ValueError("Invalid schedule configuration")
        )
        mock_scheduler_manager.stop = AsyncMock()

        mock_state_manager = MagicMock()

        with (
            patch("filtarr.scheduler.SchedulerManager", return_value=mock_scheduler_manager),
            patch("filtarr.state.StateManager", return_value=mock_state_manager),
            patch("filtarr.webhook.create_app"),
            patch("uvicorn.Config"),
            patch("uvicorn.Server") as mock_server_class,
            caplog.at_level(logging.DEBUG),
        ):
            mock_server = MagicMock()
            mock_server.serve = AsyncMock()
            mock_server_class.return_value = mock_server

            filtarr.webhook.run_server(config=config, scheduler_enabled=True)

            # Error should be logged
            assert any(
                "Failed to start scheduler" in record.getMessage() for record in caplog.records
            )

    def test_scheduler_start_runtime_error(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should log error when scheduler.start() raises RuntimeError."""
        import logging

        from filtarr.config import SchedulerConfig

        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            scheduler=SchedulerConfig(enabled=True),
        )

        mock_scheduler_manager = MagicMock()
        mock_scheduler_manager.start = AsyncMock(
            side_effect=RuntimeError("Scheduler already running")
        )
        mock_scheduler_manager.stop = AsyncMock()

        mock_state_manager = MagicMock()

        with (
            patch("filtarr.scheduler.SchedulerManager", return_value=mock_scheduler_manager),
            patch("filtarr.state.StateManager", return_value=mock_state_manager),
            patch("filtarr.webhook.create_app"),
            patch("uvicorn.Config"),
            patch("uvicorn.Server") as mock_server_class,
            caplog.at_level(logging.DEBUG),
        ):
            mock_server = MagicMock()
            mock_server.serve = AsyncMock()
            mock_server_class.return_value = mock_server

            filtarr.webhook.run_server(config=config, scheduler_enabled=True)

            # Error should be logged (RuntimeError uses different message)
            assert any(
                "Scheduler runtime error" in record.getMessage() for record in caplog.records
            )

    def test_scheduler_shutdown_cancelled_error(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should log debug message when scheduler.stop() raises CancelledError."""
        import asyncio
        import logging

        from filtarr.config import SchedulerConfig

        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            scheduler=SchedulerConfig(enabled=True),
        )

        mock_scheduler_manager = MagicMock()
        mock_scheduler_manager.start = AsyncMock()
        mock_scheduler_manager.stop = AsyncMock(side_effect=asyncio.CancelledError())

        mock_state_manager = MagicMock()

        with (
            patch("filtarr.scheduler.SchedulerManager", return_value=mock_scheduler_manager),
            patch("filtarr.state.StateManager", return_value=mock_state_manager),
            patch("filtarr.webhook.create_app"),
            patch("uvicorn.Config"),
            patch("uvicorn.Server") as mock_server_class,
            caplog.at_level(logging.DEBUG),
        ):
            mock_server = MagicMock()
            mock_server.serve = AsyncMock()
            mock_server_class.return_value = mock_server

            filtarr.webhook.run_server(config=config, scheduler_enabled=True)

            # Debug message should be logged
            assert any(
                "Scheduler stop cancelled during shutdown" in record.getMessage()
                for record in caplog.records
            )

    def test_scheduler_shutdown_runtime_error(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Should log warning when scheduler.stop() raises RuntimeError."""
        import logging

        from filtarr.config import SchedulerConfig

        config = Config(
            radarr=full_config.radarr,
            sonarr=full_config.sonarr,
            scheduler=SchedulerConfig(enabled=True),
        )

        mock_scheduler_manager = MagicMock()
        mock_scheduler_manager.start = AsyncMock()
        mock_scheduler_manager.stop = AsyncMock(
            side_effect=RuntimeError("Scheduler state issue during shutdown")
        )

        mock_state_manager = MagicMock()

        with (
            patch("filtarr.scheduler.SchedulerManager", return_value=mock_scheduler_manager),
            patch("filtarr.state.StateManager", return_value=mock_state_manager),
            patch("filtarr.webhook.create_app"),
            patch("uvicorn.Config"),
            patch("uvicorn.Server") as mock_server_class,
            caplog.at_level(logging.WARNING),
        ):
            mock_server = MagicMock()
            mock_server.serve = AsyncMock()
            mock_server_class.return_value = mock_server

            filtarr.webhook.run_server(config=config, scheduler_enabled=True)

            # Warning should be logged
            assert any(
                "Error during scheduler shutdown" in record.getMessage()
                for record in caplog.records
            )


class TestFormatCheckOutcome:
    """Tests for the _format_check_outcome helper function."""

    def test_format_with_match_and_tag_applied(self) -> None:
        """Should format correctly when 4K available and tag was applied."""
        from filtarr.tagger import TagResult

        tag_result = TagResult(tag_applied="4k-available")
        result = filtarr.webhook._format_check_outcome(True, tag_result)
        assert result == "4K available, tag applied (4k-available)"

    def test_format_with_match_and_tag_already_present(self) -> None:
        """Should format correctly when 4K available and tag was already present."""
        from filtarr.tagger import TagResult

        tag_result = TagResult(tag_applied="4k-available", tag_already_present=True)
        result = filtarr.webhook._format_check_outcome(True, tag_result)
        assert result == "4K available, tag already present (4k-available)"

    def test_format_with_match_and_dry_run(self) -> None:
        """Should format correctly when 4K available and in dry-run mode."""
        from filtarr.tagger import TagResult

        tag_result = TagResult(tag_applied="4k-available", dry_run=True)
        result = filtarr.webhook._format_check_outcome(True, tag_result)
        assert result == "4K available (dry-run, would apply: 4k-available)"

    def test_format_with_match_and_no_tag_result(self) -> None:
        """Should format correctly when 4K available but no tag result."""
        result = filtarr.webhook._format_check_outcome(True, None)
        assert result == "4K available"

    def test_format_with_match_and_tagging_disabled(self) -> None:
        """Should format correctly when 4K available but tagging disabled."""
        from filtarr.tagger import TagResult

        tag_result = TagResult(tag_applied=None)
        result = filtarr.webhook._format_check_outcome(True, tag_result)
        assert result == "4K available (tagging disabled)"

    def test_format_no_match_and_tag_applied(self) -> None:
        """Should format correctly when 4K not available and tag was applied."""
        from filtarr.tagger import TagResult

        tag_result = TagResult(tag_applied="4k-unavailable")
        result = filtarr.webhook._format_check_outcome(False, tag_result)
        assert result == "4K not available, tag applied (4k-unavailable)"

    def test_format_no_match_and_tag_already_present(self) -> None:
        """Should format correctly when 4K not available and tag was already present."""
        from filtarr.tagger import TagResult

        tag_result = TagResult(tag_applied="4k-unavailable", tag_already_present=True)
        result = filtarr.webhook._format_check_outcome(False, tag_result)
        assert result == "4K not available, tag already present (4k-unavailable)"

    def test_format_no_match_and_dry_run(self) -> None:
        """Should format correctly when 4K not available and in dry-run mode."""
        from filtarr.tagger import TagResult

        tag_result = TagResult(tag_applied="4k-unavailable", dry_run=True)
        result = filtarr.webhook._format_check_outcome(False, tag_result)
        assert result == "4K not available (dry-run, would apply: 4k-unavailable)"

    def test_format_no_match_and_no_tag_result(self) -> None:
        """Should format correctly when 4K not available and no tag result."""
        result = filtarr.webhook._format_check_outcome(False, None)
        assert result == "4K not available"

    def test_format_no_match_and_tagging_disabled(self) -> None:
        """Should format correctly when 4K not available but tagging disabled."""
        from filtarr.tagger import TagResult

        tag_result = TagResult(tag_applied=None)
        result = filtarr.webhook._format_check_outcome(False, tag_result)
        assert result == "4K not available (tagging disabled)"


class TestWebhookLogFormat:
    """Tests for webhook log message format."""

    @pytest.mark.asyncio
    async def test_radarr_webhook_log_format(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Webhook logs should use clean format for Radarr checks."""
        import logging

        mock_state_manager = MagicMock()
        mock_state_manager.get_cached_result.return_value = None

        with (
            patch("filtarr.webhook.ReleaseChecker") as mock_checker_class,
            caplog.at_level(logging.INFO),
        ):
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = True
            mock_result.matched_releases = [MagicMock()]
            mock_result.releases = [MagicMock()]
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = "4k-available"
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_movie = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            await filtarr.webhook._process_movie_check(
                123, "The Matrix", full_config, state_manager=mock_state_manager
            )

            # Verify log format: "Webhook: Radarr check - Movie Title"
            assert any(
                "Webhook: Radarr check - The Matrix" in record.getMessage()
                for record in caplog.records
            )
            # Verify log format: "Check result: 4K available, tagged"
            assert any(
                "Check result: 4K available, tag applied" in record.getMessage()
                for record in caplog.records
            )

    @pytest.mark.asyncio
    async def test_sonarr_webhook_log_format(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Webhook logs should use clean format for Sonarr checks."""
        import logging

        mock_state_manager = MagicMock()
        mock_state_manager.get_cached_result.return_value = None

        with (
            patch("filtarr.webhook.ReleaseChecker") as mock_checker_class,
            caplog.at_level(logging.INFO),
        ):
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = False
            mock_result.matched_releases = []
            mock_result.releases = [MagicMock()]
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = "4k-unavailable"
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_series = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            await filtarr.webhook._process_series_check(
                456, "Breaking Bad", full_config, state_manager=mock_state_manager
            )

            # Verify log format: "Webhook: Sonarr check - Series Title"
            assert any(
                "Webhook: Sonarr check - Breaking Bad" in record.getMessage()
                for record in caplog.records
            )
            # Verify log format: "Check result: 4K not available, tagged"
            assert any(
                "Check result: 4K not available, tag applied" in record.getMessage()
                for record in caplog.records
            )

    @pytest.mark.asyncio
    async def test_webhook_error_log_format(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Webhook error logs should use clean format."""
        import logging

        with (
            patch("filtarr.webhook.ReleaseChecker") as mock_checker_class,
            caplog.at_level(logging.ERROR),
        ):
            mock_checker = MagicMock()
            mock_checker.check_movie = AsyncMock(
                side_effect=httpx.ConnectError("Connection refused")
            )
            mock_checker_class.return_value = mock_checker

            # Pass state_manager=None to skip TTL check
            await filtarr.webhook._process_movie_check(
                123, "Test Movie", full_config, state_manager=None
            )

            # Verify error log format: "Webhook error: Movie Title - connection failed"
            assert any(
                "Webhook error: Test Movie - connection failed" in record.getMessage()
                for record in caplog.records
            )

    @pytest.mark.asyncio
    async def test_webhook_timeout_error_log_format(
        self, full_config: Config, caplog: pytest.LogCaptureFixture
    ) -> None:
        """Webhook timeout error logs should use clean format."""
        import logging

        with (
            patch("filtarr.webhook.ReleaseChecker") as mock_checker_class,
            caplog.at_level(logging.ERROR),
        ):
            mock_checker = MagicMock()
            mock_checker.check_movie = AsyncMock(side_effect=httpx.ReadTimeout("Read timed out"))
            mock_checker_class.return_value = mock_checker

            # Pass state_manager=None to skip TTL check
            await filtarr.webhook._process_movie_check(
                123, "Test Movie", full_config, state_manager=None
            )

            # Verify error log format: "Webhook error: Movie Title - connection timed out"
            assert any(
                "Webhook error: Test Movie - connection timed out" in record.getMessage()
                for record in caplog.records
            )


class TestFormatNetworkError:
    """Tests for _format_network_error helper function."""

    def test_format_connect_error(self) -> None:
        """ConnectError should return 'connection failed'."""
        error = httpx.ConnectError("Connection refused")
        result = filtarr.webhook._format_network_error(error)
        assert result == "connection failed"

    def test_format_timeout_error(self) -> None:
        """TimeoutException should return 'connection timed out'."""
        error = httpx.ReadTimeout("Read timed out")
        result = filtarr.webhook._format_network_error(error)
        assert result == "connection timed out"

    def test_format_connect_timeout(self) -> None:
        """ConnectTimeout should return 'connection timed out'."""
        error = httpx.ConnectTimeout("Connect timed out")
        result = filtarr.webhook._format_network_error(error)
        assert result == "connection timed out"


class TestOutputJsonEvent:
    """Tests for _output_json_event helper function."""

    def test_output_json_event_format(self, capsys: pytest.CaptureFixture[str]) -> None:
        """_output_json_event should output valid JSON with event type and timestamp."""
        filtarr.webhook._output_json_event("test_event", foo="bar", count=42)

        captured = capsys.readouterr()
        output = json.loads(captured.out.strip())

        assert output["event"] == "test_event"
        assert output["foo"] == "bar"
        assert output["count"] == 42
        assert "timestamp" in output

    def test_output_json_event_timestamp_is_iso_format(
        self, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Timestamp should be ISO 8601 format."""
        filtarr.webhook._output_json_event("test_event")

        captured = capsys.readouterr()
        output = json.loads(captured.out.strip())

        # Should parse as ISO datetime
        timestamp = output["timestamp"]
        datetime.fromisoformat(timestamp)  # Raises if invalid

    def test_output_json_event_empty_data(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Should output valid JSON even with no extra data."""
        filtarr.webhook._output_json_event("empty_event")

        captured = capsys.readouterr()
        output = json.loads(captured.out.strip())

        assert output["event"] == "empty_event"
        assert "timestamp" in output
        # Should only have event and timestamp
        assert set(output.keys()) == {"event", "timestamp"}

    def test_output_json_event_complex_data(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Should handle complex nested data."""
        filtarr.webhook._output_json_event(
            "complex_event",
            nested={"key": "value"},
            list_data=[1, 2, 3],
            boolean=True,
            none_value=None,
        )

        captured = capsys.readouterr()
        output = json.loads(captured.out.strip())

        assert output["event"] == "complex_event"
        assert output["nested"] == {"key": "value"}
        assert output["list_data"] == [1, 2, 3]
        assert output["boolean"] is True
        assert output["none_value"] is None


class TestDependencyInjection:
    """Tests for FastAPI dependency injection and app.state usage."""

    def test_app_state_has_scheduler_manager_attribute(self, full_config: Config) -> None:
        """App should store scheduler_manager in app.state, not as global."""
        app = filtarr.webhook.create_app(full_config)

        # app.state should have a scheduler_manager attribute (may be None initially)
        assert hasattr(app.state, "scheduler_manager")

    def test_app_state_has_state_manager_attribute(self, full_config: Config) -> None:
        """App should store state_manager in app.state, not as global."""
        app = filtarr.webhook.create_app(full_config)

        # app.state should have a state_manager attribute (may be None initially)
        assert hasattr(app.state, "state_manager")

    def test_app_state_has_output_format_attribute(self, full_config: Config) -> None:
        """App should store output_format in app.state, not as global."""
        app = filtarr.webhook.create_app(full_config)

        # app.state should have an output_format attribute
        assert hasattr(app.state, "output_format")

    def test_no_global_scheduler_manager(self) -> None:
        """Module should NOT have global _scheduler_manager variable."""
        # After refactoring, this global should not exist
        assert not hasattr(filtarr.webhook, "_scheduler_manager")

    def test_no_global_state_manager(self) -> None:
        """Module should NOT have global _state_manager variable."""
        # After refactoring, this global should not exist
        assert not hasattr(filtarr.webhook, "_state_manager")

    def test_no_global_output_format(self) -> None:
        """Module should NOT have global _output_format variable."""
        # After refactoring, this global should not exist
        assert not hasattr(filtarr.webhook, "_output_format")

    def test_status_endpoint_uses_app_state(self, full_config: Config) -> None:
        """Status endpoint should access scheduler_manager from app.state."""
        app = filtarr.webhook.create_app(full_config)

        # Set scheduler_manager on app.state
        mock_scheduler = MagicMock()
        mock_scheduler.is_running = True
        mock_scheduler.get_all_schedules.return_value = [
            MagicMock(enabled=True),
        ]
        mock_scheduler.get_running_schedules.return_value = {"test-schedule"}
        mock_scheduler.get_history.return_value = []

        app.state.scheduler_manager = mock_scheduler

        client = TestClient(app)
        response = client.get("/status")

        assert response.status_code == 200
        data = response.json()
        # Should show scheduler as enabled since we set it on app.state
        assert data["scheduler"]["enabled"] is True
        assert data["scheduler"]["running"] is True

    def test_multiple_app_instances_have_isolated_state(self, full_config: Config) -> None:
        """Multiple app instances should have isolated state (no shared globals)."""
        app1 = filtarr.webhook.create_app(full_config)
        app2 = filtarr.webhook.create_app(full_config)

        # Set different state on each app
        mock_scheduler1 = MagicMock()
        mock_scheduler1.is_running = True
        mock_scheduler1.get_all_schedules.return_value = [MagicMock(enabled=True)]
        mock_scheduler1.get_running_schedules.return_value = set()
        mock_scheduler1.get_history.return_value = []

        mock_scheduler2 = MagicMock()
        mock_scheduler2.is_running = False
        mock_scheduler2.get_all_schedules.return_value = []
        mock_scheduler2.get_running_schedules.return_value = set()
        mock_scheduler2.get_history.return_value = []

        app1.state.scheduler_manager = mock_scheduler1
        app2.state.scheduler_manager = mock_scheduler2

        client1 = TestClient(app1)
        client2 = TestClient(app2)

        response1 = client1.get("/status")
        response2 = client2.get("/status")

        # Each app should reflect its own state
        data1 = response1.json()
        data2 = response2.json()

        # app1 has scheduler running
        assert data1["scheduler"]["running"] is True
        # app2 has scheduler not running
        assert data2["scheduler"]["running"] is False

    def test_test_isolation_between_runs(self, full_config: Config) -> None:
        """Tests should not leak state between runs."""
        # Create app and set state
        app = filtarr.webhook.create_app(full_config)
        app.state.output_format = "json"

        # Create a new app - should have default state
        app_new = filtarr.webhook.create_app(full_config)

        # New app should have default output_format, not "json"
        # Note: default is "text" according to the implementation
        assert app_new.state.output_format == "text"

    def test_create_app_accepts_initial_state_manager(self, full_config: Config) -> None:
        """create_app should accept optional state_manager parameter."""
        mock_state_manager = MagicMock()

        app = filtarr.webhook.create_app(full_config, state_manager=mock_state_manager)

        assert app.state.state_manager is mock_state_manager

    def test_create_app_accepts_initial_output_format(self, full_config: Config) -> None:
        """create_app should accept optional output_format parameter."""
        app = filtarr.webhook.create_app(full_config, output_format="json")

        assert app.state.output_format == "json"


class TestWebhookJsonOutputMode:
    """Tests for webhook JSON output mode."""

    @pytest.mark.asyncio
    async def test_radarr_webhook_json_output_mode(
        self, full_config: Config, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Radarr webhook should output JSON events when output_format is json."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = True
            mock_result.matched_releases = [MagicMock()]
            mock_result.releases = [MagicMock()]
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = "4k-available"
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_movie = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            # Pass output_format as parameter (no global state)
            await filtarr.webhook._process_movie_check(
                123, "Test Movie", full_config, state_manager=None, output_format="json"
            )

        captured = capsys.readouterr()
        lines = captured.out.strip().split("\n")

        # Should have two JSON events: webhook_received and check_complete
        assert len(lines) == 2

        # Parse and verify webhook_received event
        received_event = json.loads(lines[0])
        assert received_event["event"] == "webhook_received"
        assert received_event["source"] == "radarr"
        assert received_event["title"] == "Test Movie"

        # Parse and verify check_complete event
        complete_event = json.loads(lines[1])
        assert complete_event["event"] == "check_complete"
        assert complete_event["title"] == "Test Movie"
        assert complete_event["available"] is True
        assert complete_event["tag_applied"] == "4k-available"

    @pytest.mark.asyncio
    async def test_sonarr_webhook_json_output_mode(
        self, full_config: Config, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """Sonarr webhook should output JSON events when output_format is json."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = False
            mock_result.matched_releases = []
            mock_result.releases = [MagicMock()]
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = "4k-unavailable"
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_series = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            # Pass output_format as parameter (no global state)
            await filtarr.webhook._process_series_check(
                456, "Breaking Bad", full_config, state_manager=None, output_format="json"
            )

        captured = capsys.readouterr()
        lines = captured.out.strip().split("\n")

        # Should have two JSON events: webhook_received and check_complete
        assert len(lines) == 2

        # Parse and verify webhook_received event
        received_event = json.loads(lines[0])
        assert received_event["event"] == "webhook_received"
        assert received_event["source"] == "sonarr"
        assert received_event["title"] == "Breaking Bad"

        # Parse and verify check_complete event
        complete_event = json.loads(lines[1])
        assert complete_event["event"] == "check_complete"
        assert complete_event["title"] == "Breaking Bad"
        assert complete_event["available"] is False
        assert complete_event["tag_applied"] == "4k-unavailable"

    @pytest.mark.asyncio
    async def test_json_output_mode_no_tag_applied(
        self, full_config: Config, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """JSON output should handle case when no tag is applied."""
        with patch("filtarr.webhook.ReleaseChecker") as mock_checker_class:
            mock_checker = MagicMock()
            mock_result = MagicMock()
            mock_result.has_match = True
            mock_result.matched_releases = [MagicMock()]
            mock_result.releases = [MagicMock()]
            mock_result.tag_result = MagicMock()
            mock_result.tag_result.tag_applied = None  # No tag applied
            mock_result.tag_result.tag_already_present = False
            mock_result.tag_result.dry_run = False
            mock_checker.check_movie = AsyncMock(return_value=mock_result)
            mock_checker_class.return_value = mock_checker

            # Pass output_format as parameter (no global state)
            await filtarr.webhook._process_movie_check(
                123, "Test Movie", full_config, state_manager=None, output_format="json"
            )

        captured = capsys.readouterr()
        lines = captured.out.strip().split("\n")

        # Parse and verify check_complete event
        complete_event = json.loads(lines[1])
        assert complete_event["tag_applied"] is None

    def test_run_server_json_output_mode_server_started_event(
        self, full_config: Config, capsys: pytest.CaptureFixture[str]
    ) -> None:
        """run_server should output server_started JSON event when output_format is json."""
        import uvicorn

        mock_state_manager = MagicMock()

        with (
            patch("filtarr.state.StateManager", return_value=mock_state_manager),
            patch.object(uvicorn, "run"),
        ):
            filtarr.webhook.run_server(
                config=full_config,
                scheduler_enabled=False,
                output_format="json",
                host="0.0.0.0",
                port=8080,
            )

        captured = capsys.readouterr()
        # Find the server_started event in the output
        lines = [line for line in captured.out.strip().split("\n") if line]

        # Find the JSON line (should be first non-empty line with JSON)
        server_started = None
        for line in lines:
            try:
                parsed = json.loads(line)
                if parsed.get("event") == "server_started":
                    server_started = parsed
                    break
            except json.JSONDecodeError:
                continue

        assert server_started is not None
        assert server_started["event"] == "server_started"
        assert server_started["host"] == "0.0.0.0"
        assert server_started["port"] == 8080
        assert server_started["radarr_configured"] is True
        assert server_started["sonarr_configured"] is True
        assert server_started["scheduler_enabled"] is False
