"""Tests for the TipSharks background job scheduler."""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest
from fastapi.testclient import TestClient

from apps.backend.api.main import app, get_db, scheduler
from packages.core.common.scheduler import TipSharksScheduler

# ═══════════════════════════════════════════════════════════════════
# Scheduler unit tests
# ═══════════════════════════════════════════════════════════════════


class TestTipSharksScheduler:
    """Tests for the TipSharksScheduler class."""

    @pytest.fixture(autouse=True)
    def setup_scheduler(self):
        """Create a fresh scheduler for each test."""
        sched = TipSharksScheduler()
        yield sched
        if sched.running:
            sched.shutdown(wait=False)

    @pytest.mark.asyncio
    async def test_start_stop(self, setup_scheduler):
        """Test scheduler start and shutdown."""
        sched = setup_scheduler
        assert not sched.running

        sched.start()
        assert sched.running

        sched.shutdown(wait=False)
        assert not sched.running

    @pytest.mark.asyncio
    async def test_double_start_is_idempotent(self, setup_scheduler):
        """Test that starting twice does not error."""
        sched = setup_scheduler
        sched.start()
        sched.start()  # Should log warning, not raise
        assert sched.running
        sched.shutdown(wait=False)

    @pytest.mark.asyncio
    async def test_double_shutdown_is_idempotent(self, setup_scheduler):
        """Test that shutting down twice does not error."""
        sched = setup_scheduler
        sched.start()
        sched.shutdown(wait=False)
        sched.shutdown(wait=False)  # Should log warning, not raise
        assert not sched.running

    @pytest.mark.asyncio
    async def test_add_ingest_job(self, setup_scheduler):
        """Test adding an ingest job."""
        sched = setup_scheduler
        sched.start()

        job_id = sched.add_ingest_job(
            date_from="2024-01-01",
            date_to="2024-01-31",
            category="H",
            source="tab",
            cron_expr="0 6 * * *",
            job_id="test_ingest",
        )

        assert job_id == "test_ingest"
        jobs = sched.list_jobs()
        assert len(jobs) == 1
        assert jobs[0]["id"] == "test_ingest"
        assert "Ingest" in jobs[0]["name"]

        sched.shutdown(wait=False)

    @pytest.mark.asyncio
    async def test_add_recompute_job(self, setup_scheduler):
        """Test adding a recompute job."""
        sched = setup_scheduler
        sched.start()

        job_id = sched.add_recompute_job(
            date_from="2024-01-01",
            date_to="2024-12-31",
            clear=True,
            cron_expr="0 2 * * 0",
            job_id="test_recompute",
        )

        assert job_id == "test_recompute"
        jobs = sched.list_jobs()
        assert len(jobs) == 1
        assert jobs[0]["id"] == "test_recompute"
        assert "Recompute" in jobs[0]["name"]

        sched.shutdown(wait=False)

    @pytest.mark.asyncio
    async def test_add_scrape_job(self, setup_scheduler):
        """Test adding a scrape job."""
        sched = setup_scheduler
        sched.start()

        job_id = sched.add_scrape_job(
            urls=["010101rs.htm"],
            club_codes=["01"],
            cron_expr="0 4 * * 0",
            job_id="test_scrape",
        )

        assert job_id == "test_scrape"
        jobs = sched.list_jobs()
        assert len(jobs) == 1
        assert jobs[0]["id"] == "test_scrape"

        sched.shutdown(wait=False)

    @pytest.mark.asyncio
    async def test_list_jobs_empty(self, setup_scheduler):
        """Test listing jobs when no jobs are scheduled."""
        sched = setup_scheduler
        sched.start()
        jobs = sched.list_jobs()
        assert jobs == []
        sched.shutdown(wait=False)

    @pytest.mark.asyncio
    async def test_remove_job(self, setup_scheduler):
        """Test removing a scheduled job."""
        sched = setup_scheduler
        sched.start()

        sched.add_ingest_job(
            cron_expr="0 6 * * *",
            job_id="test_remove",
        )

        assert len(sched.list_jobs()) == 1

        result = sched.remove_job("test_remove")
        assert result is True
        assert len(sched.list_jobs()) == 0

        sched.shutdown(wait=False)

    @pytest.mark.asyncio
    async def test_remove_nonexistent_job(self, setup_scheduler):
        """Test removing a job that does not exist."""
        sched = setup_scheduler
        sched.start()
        result = sched.remove_job("nonexistent")
        assert result is False
        sched.shutdown(wait=False)

    @pytest.mark.asyncio
    async def test_get_job(self, setup_scheduler):
        """Test getting a single job by ID."""
        sched = setup_scheduler
        sched.start()

        sched.add_ingest_job(
            cron_expr="0 6 * * *",
            job_id="test_get",
        )

        job = sched.get_job("test_get")
        assert job is not None
        assert job["id"] == "test_get"

        assert sched.get_job("nonexistent") is None

        sched.shutdown(wait=False)

    @pytest.mark.asyncio
    async def test_load_default_jobs(self, setup_scheduler):
        """Test loading default jobs from settings."""
        sched = setup_scheduler
        sched.start()

        job_ids = sched.load_default_jobs()
        assert len(job_ids) >= 2  # ingest + recompute at minimum
        assert "ingest_daily" in job_ids
        assert "recompute_weekly" in job_ids

        jobs = sched.list_jobs()
        assert len(jobs) >= 2

        sched.shutdown(wait=False)

    @pytest.mark.asyncio
    async def test_replace_existing_job(self, setup_scheduler):
        """Test that adding a job with same ID replaces the existing one."""
        sched = setup_scheduler
        sched.start()

        sched.add_ingest_job(
            cron_expr="0 6 * * *",
            job_id="replace_test",
        )
        sched.add_ingest_job(
            cron_expr="0 12 * * *",
            job_id="replace_test",
        )

        jobs = sched.list_jobs()
        assert len(jobs) == 1  # Replaced, not duplicated

        sched.shutdown(wait=False)


# ═══════════════════════════════════════════════════════════════════
# API endpoint tests
# ═══════════════════════════════════════════════════════════════════


class TestSchedulerAdminEndpoints:
    """Tests for the scheduler admin API endpoints."""

    @pytest.fixture(autouse=True)
    def setup_scheduler_in_app(self):
        """Ensure scheduler is stopped before each test."""
        if scheduler.running:
            scheduler.shutdown(wait=False)
        yield
        if scheduler.running:
            scheduler.shutdown(wait=False)

    @pytest.fixture
    def client(self, mock_db_session):
        """Create test client with mock DB session."""

        def override_get_db():
            yield mock_db_session

        app.dependency_overrides[get_db] = override_get_db
        with TestClient(app) as test_client:
            yield test_client
        app.dependency_overrides.clear()

    @pytest.fixture
    def mock_db_session(self):
        """Create mock database session."""
        return MagicMock()

    def _auth_header(self):
        """Return admin auth header."""
        return {"Authorization": "Bearer change_me_in_production"}

    # ── GET /v1/admin/jobs ────────────────────────────────────────

    def test_list_jobs_requires_auth(self, client):
        """Test that listing jobs requires admin token."""
        response = client.get("/v1/admin/jobs")
        assert response.status_code == 403  # No auth header

    def test_list_jobs_with_auth(self, client):
        """Test listing jobs with valid admin token."""
        scheduler.start()
        scheduler.add_ingest_job(cron_expr="0 6 * * *", job_id="api_test_ingest")

        response = client.get("/v1/admin/jobs", headers=self._auth_header())
        assert response.status_code == 200
        data = response.json()
        assert "jobs" in data
        assert data["total"] >= 1
        job_ids = [j["id"] for j in data["jobs"]]
        assert "api_test_ingest" in job_ids

        scheduler.remove_job("api_test_ingest")
        scheduler.shutdown(wait=False)

    def test_list_jobs_with_bad_token(self, client):
        """Test that listing jobs with invalid token returns 401."""
        response = client.get(
            "/v1/admin/jobs",
            headers={"Authorization": "Bearer bad_token"},
        )
        assert response.status_code == 401

    # ── POST /v1/admin/jobs ───────────────────────────────────────

    def test_add_ingest_job_endpoint(self, client):
        """Test adding an ingest job via API."""
        scheduler.start()

        response = client.post(
            "/v1/admin/jobs",
            headers=self._auth_header(),
            json={
                "job_type": "ingest",
                "cron_expr": "0 6 * * *",
                "job_id": "api_test_add_ingest",
                "category": "H",
                "source": "tab",
            },
        )
        assert response.status_code == 201
        data = response.json()
        assert data["job_id"] == "api_test_add_ingest"

        # Verify it was added
        job = scheduler.get_job("api_test_add_ingest")
        assert job is not None

        scheduler.remove_job("api_test_add_ingest")
        scheduler.shutdown(wait=False)

    def test_add_recompute_job_endpoint(self, client):
        """Test adding a recompute job via API."""
        scheduler.start()

        response = client.post(
            "/v1/admin/jobs",
            headers=self._auth_header(),
            json={
                "job_type": "recompute",
                "cron_expr": "0 2 * * 0",
                "job_id": "api_test_add_recompute",
                "clear": True,
            },
        )
        assert response.status_code == 201
        data = response.json()
        assert data["job_id"] == "api_test_add_recompute"

        job = scheduler.get_job("api_test_add_recompute")
        assert job is not None

        scheduler.remove_job("api_test_add_recompute")
        scheduler.shutdown(wait=False)

    def test_add_scrape_job_endpoint(self, client):
        """Test adding a scrape job via API."""
        scheduler.start()

        response = client.post(
            "/v1/admin/jobs",
            headers=self._auth_header(),
            json={
                "job_type": "scrape",
                "cron_expr": "0 4 * * 0",
                "job_id": "api_test_add_scrape",
                "club_codes": ["01", "02"],
            },
        )
        assert response.status_code == 201
        data = response.json()
        assert data["job_id"] == "api_test_add_scrape"

        job = scheduler.get_job("api_test_add_scrape")
        assert job is not None

        scheduler.remove_job("api_test_add_scrape")
        scheduler.shutdown(wait=False)

    def test_add_job_unknown_type(self, client):
        """Test adding a job with unknown type returns 400."""
        response = client.post(
            "/v1/admin/jobs",
            headers=self._auth_header(),
            json={
                "job_type": "unknown_type",
                "cron_expr": "0 6 * * *",
            },
        )
        assert response.status_code == 400

    def test_add_job_requires_auth(self, client):
        """Test that adding a job requires admin token."""
        response = client.post(
            "/v1/admin/jobs",
            json={"job_type": "ingest", "cron_expr": "0 6 * * *"},
        )
        assert response.status_code == 403

    # ── DELETE /v1/admin/jobs/{job_id} ────────────────────────────

    def test_remove_job_endpoint(self, client):
        """Test removing a job via API."""
        scheduler.start()
        scheduler.add_ingest_job(cron_expr="0 6 * * *", job_id="api_test_remove")

        response = client.delete(
            "/v1/admin/jobs/api_test_remove",
            headers=self._auth_header(),
        )
        assert response.status_code == 200
        data = response.json()
        assert data["removed"] is True

        # Verify it was removed
        assert scheduler.get_job("api_test_remove") is None

        scheduler.shutdown(wait=False)

    def test_remove_nonexistent_job_endpoint(self, client):
        """Test removing a nonexistent job returns 404."""
        response = client.delete(
            "/v1/admin/jobs/nonexistent_job",
            headers=self._auth_header(),
        )
        assert response.status_code == 404

    def test_remove_job_requires_auth(self, client):
        """Test that removing a job requires admin token."""
        response = client.delete("/v1/admin/jobs/some_job")
        assert response.status_code == 403


# ═══════════════════════════════════════════════════════════════════
# Background job function tests
# ═══════════════════════════════════════════════════════════════════


class TestBackgroundJobFunctions:
    """Tests for the background job runner functions."""

    @patch("packages.core.common.scheduler.get_session")
    @patch("packages.core.storage.ingestion.IngestionService")
    def test_run_ingest(self, mock_ingestion_service, mock_get_session):
        """Test that _run_ingest calls IngestionService correctly."""
        from packages.core.common.scheduler import _run_ingest

        # Setup mock session context manager
        mock_session = MagicMock()
        mock_get_session.return_value.__enter__.return_value = mock_session

        # Setup mock service
        mock_service = MagicMock()
        mock_service.stats = {"errors": 2}
        mock_ingestion_service.return_value = mock_service

        # Mock the asyncio.run that wraps ingest_date_range
        with patch(
            "packages.core.common.scheduler.asyncio.run",
            return_value=(10, 50, 200),
        ):
            result = _run_ingest(
                date_from="2024-01-01",
                date_to="2024-01-31",
                category="H",
                source="tab",
            )

        assert result["meetings"] == 10
        assert result["races"] == 50
        assert result["starters"] == 200
        assert result["errors"] == 2
        mock_get_session.assert_called_once()

    @patch("packages.core.common.scheduler.get_session")
    @patch("packages.core.ratings.recompute.recompute_ratings")
    def test_run_recompute(self, mock_recompute, mock_get_session):
        """Test that _run_recompute calls recompute_ratings correctly."""
        from packages.core.common.scheduler import _run_recompute

        mock_session = MagicMock()
        mock_get_session.return_value.__enter__.return_value = mock_session
        mock_recompute.return_value = 500

        result = _run_recompute(
            date_from="2024-01-01",
            date_to="2024-12-31",
            clear=True,
        )

        assert result["snapshots_created"] == 500
        mock_recompute.assert_called_once()
        mock_get_session.assert_called_once()

    @patch("packages.core.ratings.recompute.recompute_ratings")
    def test_run_recompute_handles_exception(self, mock_recompute):
        """Test that _run_recompute handles exceptions gracefully."""
        from packages.core.common.scheduler import _run_recompute

        mock_recompute.side_effect = ValueError("DB error")

        result = _run_recompute(
            date_from="2024-01-01",
            date_to="2024-12-31",
        )

        assert result["snapshots_created"] == 0

    @patch("packages.core.storage.ingestion.IngestionService")
    def test_run_ingest_handles_exception(self, mock_ingestion_service):
        """Test that _run_ingest handles exceptions gracefully."""
        from packages.core.common.scheduler import _run_ingest

        mock_service = MagicMock()
        mock_ingestion_service.return_value = mock_service

        with patch(
            "packages.core.common.scheduler.asyncio.run",
            side_effect=ValueError("API error"),
        ):
            result = _run_ingest(
                date_from="2024-01-01",
                date_to="2024-01-31",
            )

        assert result["errors"] == 1
