# #region Test.TranslationScheduler [C:3] [TYPE Module] [SEMANTICS test,scheduler,cron]
# @BRIEF Tests for scheduler.py — TranslationScheduler.
# @RELATION BINDS_TO -> [TranslationScheduler]
# @TEST_EDGE: job_not_found -> ValueError
# @TEST_EDGE: schedule_not_found -> ValueError
# @TEST_EDGE: invalid_cron -> empty next_executions

import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))

import pytest
from unittest.mock import MagicMock, patch

from datetime import UTC, datetime, timedelta

from src.models.translate import TranslationJob, TranslationSchedule
from src.plugins.translate.scheduler import (
    TranslationScheduler,
    execute_scheduled_translation,
)
from .conftest import JOB_ID, db_session


class TestCreateSchedule:
    """TranslationScheduler.create_schedule."""

    def test_creates_schedule(self, db_session):
        """Creates a schedule for a job."""
        scheduler = TranslationScheduler(db_session, MagicMock(), "test-user")
        schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *")
        assert schedule.job_id == JOB_ID
        assert schedule.cron_expression == "0 0 * * *"
        assert schedule.is_active is True
        assert schedule.created_by == "test-user"

    def test_job_not_found(self, db_session):
        """Non-existent job raises ValueError."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        with pytest.raises(ValueError, match="not found"):
            scheduler.create_schedule("nonexistent", "0 0 * * *")

    def test_custom_mode(self, db_session):
        """Custom execution_mode."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        schedule = scheduler.create_schedule(
            JOB_ID, "0 0 * * *", execution_mode="new_key_only"
        )
        assert schedule.execution_mode == "new_key_only"


class TestUpdateSchedule:
    """TranslationScheduler.update_schedule."""

    def test_update_cron(self, db_session):
        """Update cron expression."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        scheduler.create_schedule(JOB_ID, "0 0 * * *")
        updated = scheduler.update_schedule(JOB_ID, cron_expression="0 30 * * *")
        assert updated.cron_expression == "0 30 * * *"

    def test_update_timezone(self, db_session):
        """Update timezone."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        scheduler.create_schedule(JOB_ID, "0 0 * * *")
        updated = scheduler.update_schedule(JOB_ID, timezone_str="Europe/Moscow")
        assert updated.timezone == "Europe/Moscow"

    def test_update_is_active(self, db_session):
        """Update is_active."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        scheduler.create_schedule(JOB_ID, "0 0 * * *")
        updated = scheduler.update_schedule(JOB_ID, is_active=False)
        assert updated.is_active is False

    def test_update_execution_mode(self, db_session):
        """Update execution_mode."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        scheduler.create_schedule(JOB_ID, "0 0 * * *")
        updated = scheduler.update_schedule(JOB_ID, execution_mode="new_key_only")
        assert updated.execution_mode == "new_key_only"

    def test_not_found(self, db_session):
        """Non-existent schedule raises ValueError."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        with pytest.raises(ValueError, match="No schedule found"):
            scheduler.update_schedule(JOB_ID)


class TestDeleteSchedule:
    """TranslationScheduler.delete_schedule."""

    def test_delete(self, db_session):
        """Delete existing schedule."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        scheduler.create_schedule(JOB_ID, "0 0 * * *")
        scheduler.delete_schedule(JOB_ID)
        assert db_session.query(TranslationSchedule).filter(
            TranslationSchedule.job_id == JOB_ID
        ).first() is None

    def test_not_found(self, db_session):
        """Non-existent schedule raises ValueError."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        with pytest.raises(ValueError, match="No schedule found"):
            scheduler.delete_schedule(JOB_ID)


class TestSetScheduleActive:
    """TranslationScheduler.set_schedule_active."""

    def test_disable(self, db_session):
        """Disable schedule."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        scheduler.create_schedule(JOB_ID, "0 0 * * *")
        updated = scheduler.set_schedule_active(JOB_ID, False)
        assert updated.is_active is False

    def test_enable(self, db_session):
        """Enable schedule."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=False)
        updated = scheduler.set_schedule_active(JOB_ID, True)
        assert updated.is_active is True

    def test_not_found(self, db_session):
        """Non-existent raises ValueError."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        with pytest.raises(ValueError, match="No schedule found"):
            scheduler.set_schedule_active(JOB_ID, True)


class TestGetSchedule:
    """TranslationScheduler.get_schedule."""

    def test_get(self, db_session):
        """Get existing schedule."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        scheduler.create_schedule(JOB_ID, "0 0 * * *")
        schedule = scheduler.get_schedule(JOB_ID)
        assert schedule.job_id == JOB_ID

    def test_not_found(self, db_session):
        """Non-existent raises ValueError."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        with pytest.raises(ValueError, match="No schedule found"):
            scheduler.get_schedule(JOB_ID)


class TestListActiveSchedules:
    """TranslationScheduler.list_active_schedules."""

    def test_list_active(self, db_session):
        """Returns only active schedules."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)

        # Create another job with inactive schedule
        job2 = TranslationJob(id="job-2", name="Job 2", status="ACTIVE",
                              source_dialect="en", target_dialect="fr")
        db_session.add(job2)
        db_session.commit()
        scheduler.create_schedule("job-2", "0 0 * * *", is_active=False)

        active = TranslationScheduler.list_active_schedules(db_session)
        assert len(active) == 1
        assert active[0].job_id == JOB_ID


class TestGetNextExecutions:
    """TranslationScheduler.get_next_executions."""

    def test_valid_cron(self):
        """Valid cron returns future times."""
        results = TranslationScheduler.get_next_executions("0 0 * * *", "UTC", 3)
        assert len(results) == 3
        for r in results:
            assert isinstance(r, str)

    def test_invalid_cron(self):
        """Invalid cron returns empty list."""
        results = TranslationScheduler.get_next_executions("invalid-cron", "UTC", 3)
        assert results == []

    def test_custom_count(self, db_session):
        """Custom N returns that many results."""
        results = TranslationScheduler.get_next_executions("*/5 * * * *", "UTC", 5)
        assert len(results) == 5

    def test_default_count(self, db_session):
        """Default n=3."""
        results = TranslationScheduler.get_next_executions("*/5 * * * *", "UTC")
        assert len(results) == 3


class TestExecuteScheduledTranslation:
    """execute_scheduled_translation — APScheduler job handler."""

    def _make_db_session_maker(self, db_session):
        def maker():
            return db_session
        return maker

    def test_schedule_inactive(self, db_session):
        """Inactive schedule returns early."""
        scheduler = TranslationScheduler(db_session, MagicMock())
        schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=False)
        db_session.commit()

        maker = self._make_db_session_maker(db_session)

        with patch("src.plugins.translate.scheduler.seed_trace_id"):
            execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())
            # Should return early without error

    def test_schedule_not_found(self, db_session):
        """Non-existent schedule returns early."""
        maker = self._make_db_session_maker(db_session)

        with patch("src.plugins.translate.scheduler.seed_trace_id"):
            execute_scheduled_translation("nonexistent", JOB_ID, maker, MagicMock())
            # Should return early

    def test_concurrent_run_skips(self, db_session):
        """Active concurrent run causes scheduled execution to be skipped."""
        from src.plugins.translate.scheduler import TranslationScheduler
        from src.models.translate import TranslationRun

        scheduler = TranslationScheduler(db_session, MagicMock())
        schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
        db_session.commit()

        # Create a PENDING run to simulate concurrency
        # Use datetime.now() (naive) — SQLite stores naive datetimes
        pending_run = TranslationRun(
            id="concurrent-run", job_id=JOB_ID, status="PENDING",
            created_at=datetime.now(),
        )
        db_session.add(pending_run)
        db_session.commit()

        maker = self._make_db_session_maker(db_session)

        with (
            patch("src.plugins.translate.scheduler.seed_trace_id"),
            patch("src.plugins.translate.scheduler.UTC", None),  # SQLite stores naive datetimes
            patch("src.plugins.translate.scheduler.TranslationEventLog") as MockEventLog,
        ):
            mock_event_log = MagicMock()
            MockEventLog.return_value = mock_event_log

            execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())

            # Should have logged skipped event
            mock_event_log.log_event.assert_called_once()

    def test_stale_concurrent_run_cleared(self, db_session):
        """Stale PENDING run older than 1h is cleared and execution proceeds."""
        from src.plugins.translate.scheduler import TranslationScheduler
        from src.models.translate import TranslationRun

        scheduler = TranslationScheduler(db_session, MagicMock())
        schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
        db_session.commit()

        # Create a stale PENDING run (older than 1h)
        # Use datetime.now() (naive) — SQLite stores naive datetimes
        stale_time = datetime.now() - timedelta(hours=2)
        stale_run = TranslationRun(
            id="stale-run", job_id=JOB_ID, status="PENDING",
            created_at=stale_time,
        )
        db_session.add(stale_run)
        db_session.commit()

        maker = self._make_db_session_maker(db_session)

        # Prevent session close so we can still read stale_run after the call
        original_close = db_session.close
        db_session.close = MagicMock()
        db_session.expire_on_commit = False
        try:
            with (
                patch("src.plugins.translate.scheduler.seed_trace_id"),
                patch("src.plugins.translate.scheduler.UTC", None),  # SQLite stores naive datetimes
                patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
            ):
                mock_orch = MagicMock()
                mock_run = MagicMock()
                mock_run.id = "new-run"
                mock_run.status = "COMPLETED"
                mock_run.insert_status = "succeeded"
                mock_orch.start_run.return_value = mock_run
                MockOrch.return_value = mock_orch

                execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())

                # Stale run should be marked FAILED
                assert stale_run.status == "FAILED"
                assert "Stale" in stale_run.error_message
        finally:
            db_session.close = original_close
            db_session.expire_on_commit = True

    def test_execution_new_key_only(self, db_session):
        """execution_mode=new_key_only with fresh baseline."""
        from src.plugins.translate.scheduler import TranslationScheduler
        from src.models.translate import TranslationRun

        scheduler = TranslationScheduler(db_session, MagicMock())
        schedule = scheduler.create_schedule(
            JOB_ID, "0 0 * * *", is_active=True, execution_mode="new_key_only"
        )
        db_session.commit()

        # Recent successful run (baseline not expired)
        # Use datetime.now() (naive) — SQLite stores naive datetimes
        recent = datetime.now()
        recent_run = TranslationRun(
            id="recent-run", job_id=JOB_ID, status="COMPLETED",
            insert_status="succeeded", created_at=recent,
        )
        db_session.add(recent_run)
        db_session.commit()

        maker = self._make_db_session_maker(db_session)

        with (
            patch("src.plugins.translate.scheduler.seed_trace_id"),
            patch("src.plugins.translate.scheduler.UTC", None),  # SQLite stores naive datetimes
            patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
        ):
            mock_orch = MagicMock()
            mock_run = MagicMock()
            mock_run.id = "new-run"
            mock_run.status = "COMPLETED"
            mock_run.insert_status = "succeeded"
            mock_orch.start_run.return_value = mock_run
            MockOrch.return_value = mock_orch

            execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock(), execution_mode="new_key_only")

            # Should set trigger_type to "new_key_only" because baseline is fresh
            assert mock_run.trigger_type == "new_key_only"

    def test_execution_baseline_expired(self, db_session):
        """Baseline older than 90d triggers full translation even in new_key_only mode."""
        from src.plugins.translate.scheduler import TranslationScheduler
        from src.models.translate import TranslationRun

        scheduler = TranslationScheduler(db_session, MagicMock())
        schedule = scheduler.create_schedule(
            JOB_ID, "0 0 * * *", is_active=True, execution_mode="new_key_only"
        )
        db_session.commit()

        # Very old successful run (baseline expired > 90 days)
        # Use datetime.now() (naive) — SQLite stores naive datetimes
        old_time = datetime.now() - timedelta(days=100)
        old_run = TranslationRun(
            id="old-run", job_id=JOB_ID, status="COMPLETED",
            insert_status="succeeded", created_at=old_time,
        )
        db_session.add(old_run)
        db_session.commit()

        maker = self._make_db_session_maker(db_session)

        with (
            patch("src.plugins.translate.scheduler.seed_trace_id"),
            patch("src.plugins.translate.scheduler.UTC", None),  # SQLite stores naive datetimes
            patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
        ):
            mock_orch = MagicMock()
            mock_run = MagicMock()
            mock_run.id = "new-run"
            mock_run.status = "COMPLETED"
            mock_run.insert_status = "succeeded"
            mock_orch.start_run.return_value = mock_run
            MockOrch.return_value = mock_orch

            execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock(), execution_mode="new_key_only")

            # Should fall back to "scheduled" because baseline expired
            assert mock_run.trigger_type == "scheduled"

    def test_execution_failure_sends_notification(self, db_session):
        """Execution failure triggers notification and marks run as FAILED."""
        from src.plugins.translate.scheduler import TranslationScheduler
        from src.models.translate import TranslationRun

        scheduler = TranslationScheduler(db_session, MagicMock())
        schedule = scheduler.create_schedule(JOB_ID, "0 0 * * *", is_active=True)
        db_session.commit()

        maker = self._make_db_session_maker(db_session)

        with (
            patch("src.plugins.translate.scheduler.seed_trace_id"),
            patch("src.plugins.translate.scheduler.UTC", None),  # SQLite stores naive datetimes
            patch("src.plugins.translate.orchestrator.TranslationOrchestrator") as MockOrch,
            patch(
                "src.plugins.translate.scheduler.NotificationService",
            ) as MockNotif,
        ):
            mock_orch = MagicMock()
            mock_run = MagicMock()
            mock_run.id = "fail-run"
            mock_run.status = "PENDING"
            mock_run.insert_status = None
            mock_orch.start_run.return_value = mock_run
            mock_orch.execute_run.side_effect = RuntimeError("LLM call failed")
            MockOrch.return_value = mock_orch

            # Provider mock — send must return a real coroutine for asyncio.run()
            async def _dummy_send(*args, **kwargs):
                pass
            mock_provider = MagicMock()
            mock_provider.send = MagicMock(wraps=_dummy_send)
            mock_notif = MagicMock()
            mock_notif._providers = {"email": mock_provider}
            MockNotif.return_value = mock_notif

            execute_scheduled_translation(schedule.id, JOB_ID, maker, MagicMock())

            # Notification should be sent
            mock_provider.send.assert_called()
            # Run should be marked FAILED
            assert mock_run.status == "FAILED"
            assert mock_run.error_message == "LLM call failed"

    def test_get_next_executions_break_on_none(self):
        """get_next_executions breaks when cron trigger returns None."""
        from src.plugins.translate.scheduler import TranslationScheduler

        # Use a cron that's unlikely to have many future fires
        # or mock the trigger to return None after one fire
        results = TranslationScheduler.get_next_executions(
            "0 0 30 2 5", "UTC", 10  # Feb 30 doesn't exist, trigger may produce fewer
        )
        # Should not crash; returns what it can
        assert isinstance(results, list)
# #endregion Test.TranslationScheduler
