# region TestScheduler [TYPE Module]
# @SEMANTICS: test, translate, scheduler, notification
# @PURPOSE: Tests for TranslationScheduler: CRUD, cron validation, trigger dispatch, failure notification.
# @LAYER Test
# @RELATION  BINDS_TO -> [TranslationScheduler]
# @TEST_CONTRACT  TranslationScheduler -> create, update, delete, get, get_next_executions
# @TEST_CONTRACT  execute_scheduled_translation -> failure notification, concurrency check
# @TEST_EDGE  missing_schedule -> raise ValueError
# @TEST_EDGE  concurrent_run_skip -> skip and log event
# @TEST_EDGE  execution_failure -> NotificationService called
# @TEST_EDGE  execution_success -> NotificationService NOT called

from datetime import UTC, datetime
import pytest
from unittest.mock import MagicMock, patch

from src.models.translate import TranslationRun, TranslationSchedule
from src.plugins.translate.scheduler import TranslationScheduler


# region mock_translation_schedule [TYPE Function]
# @PURPOSE: Create a mock TranslationSchedule.
@pytest.fixture
def mock_schedule() -> MagicMock:
    schedule = MagicMock(spec=TranslationSchedule)
    schedule.id = "sched-1"
    schedule.job_id = "job-1"
    schedule.cron_expression = "0 6 * * 1"
    schedule.timezone = "UTC"
    schedule.is_active = True
    schedule.execution_mode = "new_key_only"
    schedule.created_by = "test-user"
    schedule.last_run_at = None
    return schedule
# endregion mock_translation_schedule


# region TestTranslationScheduler [TYPE Class]
# @PURPOSE: Tests for TranslationScheduler CRUD operations.
class TestTranslationScheduler:

    # region test_create_schedule [TYPE Function]
    # @PURPOSE: create_schedule creates a TranslationSchedule row and logs event.
    def test_create_schedule(self) -> None:
        db = MagicMock()
        config_manager = MagicMock()

        # Mock job query returns a job
        mock_job = MagicMock()
        mock_job.id = "job-1"
        db.query.return_value.filter.return_value.first.return_value = mock_job

        scheduler = TranslationScheduler(db, config_manager, "test-user")
        result = scheduler.create_schedule(
            job_id="job-1",
            cron_expression="0 6 * * 1",
            timezone="UTC",
            is_active=True,
            execution_mode="new_key_only",
        )

        assert result is not None
        assert result.job_id == "job-1"
        assert result.cron_expression == "0 6 * * 1"
        assert result.timezone == "UTC"
        assert result.is_active is True
        db.add.assert_called()
        db.commit.assert_called()
    # endregion test_create_schedule

    # region test_create_schedule_missing_job [TYPE Function]
    # @PURPOSE: create_schedule raises ValueError if job not found.
    def test_create_schedule_missing_job(self) -> None:
        db = MagicMock()
        config_manager = MagicMock()

        # Mock job query returns None (job not found)
        db.query.return_value.filter.return_value.first.return_value = None

        scheduler = TranslationScheduler(db, config_manager, "test-user")
        with pytest.raises(ValueError, match="not found"):
            scheduler.create_schedule(
                job_id="job-missing",
                cron_expression="0 6 * * 1",
            )
    # endregion test_create_schedule_missing_job

    # region test_update_schedule [TYPE Function]
    # @PURPOSE: update_schedule modifies existing schedule fields.
    def test_update_schedule(self) -> None:
        db = MagicMock()
        config_manager = MagicMock()

        # Mock existing schedule
        existing = MagicMock(spec=TranslationSchedule)
        existing.id = "sched-1"
        existing.job_id = "job-1"
        existing.cron_expression = "0 6 * * 1"
        existing.timezone = "UTC"
        existing.is_active = True
        existing.execution_mode = "new_key_only"

        db.query.return_value.filter.return_value.first.return_value = existing

        scheduler = TranslationScheduler(db, config_manager, "test-user")
        result = scheduler.update_schedule(
            job_id="job-1",
            cron_expression="0 12 * * *",
        )

        assert result.cron_expression == "0 12 * * *"
        db.commit.assert_called()
    # endregion test_update_schedule

    # region test_update_schedule_missing [TYPE Function]
    # @PURPOSE: update_schedule raises ValueError if no existing schedule.
    def test_update_schedule_missing(self) -> None:
        db = MagicMock()
        config_manager = MagicMock()

        db.query.return_value.filter.return_value.first.return_value = None

        scheduler = TranslationScheduler(db, config_manager, "test-user")
        with pytest.raises(ValueError, match="No schedule found"):
            scheduler.update_schedule(job_id="job-1")
    # endregion test_update_schedule_missing

    # region test_delete_schedule [TYPE Function]
    # @PURPOSE: delete_schedule removes the schedule and logs event.
    def test_delete_schedule(self) -> None:
        db = MagicMock()
        config_manager = MagicMock()

        existing = MagicMock(spec=TranslationSchedule)
        existing.id = "sched-1"
        existing.job_id = "job-1"

        db.query.return_value.filter.return_value.first.return_value = existing

        scheduler = TranslationScheduler(db, config_manager, "test-user")
        scheduler.delete_schedule(job_id="job-1")

        db.delete.assert_called_with(existing)
        db.commit.assert_called()
    # endregion test_delete_schedule

    # region test_get_schedule [TYPE Function]
    # @PURPOSE: get_schedule returns the schedule for a job.
    def test_get_schedule(self) -> None:
        db = MagicMock()
        config_manager = MagicMock()

        existing = MagicMock(spec=TranslationSchedule)
        existing.id = "sched-1"
        existing.job_id = "job-1"
        existing.cron_expression = "0 6 * * 1"

        db.query.return_value.filter.return_value.first.return_value = existing

        scheduler = TranslationScheduler(db, config_manager, "test-user")
        result = scheduler.get_schedule(job_id="job-1")

        assert result.id == "sched-1"
    # endregion test_get_schedule

    # region test_get_schedule_missing [TYPE Function]
    # @PURPOSE: get_schedule raises ValueError if no schedule.
    def test_get_schedule_missing(self) -> None:
        db = MagicMock()
        config_manager = MagicMock()

        db.query.return_value.filter.return_value.first.return_value = None

        scheduler = TranslationScheduler(db, config_manager, "test-user")
        with pytest.raises(ValueError, match="No schedule found"):
            scheduler.get_schedule(job_id="job-1")
    # endregion test_get_schedule_missing

    # region test_set_schedule_active [TYPE Function]
    # @PURPOSE: set_schedule_active toggles the is_active flag.
    def test_set_schedule_active(self) -> None:
        db = MagicMock()
        config_manager = MagicMock()

        existing = MagicMock(spec=TranslationSchedule)
        existing.id = "sched-1"
        existing.job_id = "job-1"
        existing.is_active = True

        db.query.return_value.filter.return_value.first.return_value = existing

        scheduler = TranslationScheduler(db, config_manager, "test-user")
        result = scheduler.set_schedule_active(job_id="job-1", is_active=False)

        assert result.is_active is False
        db.commit.assert_called()
    # endregion test_set_schedule_active

    # region test_list_active_schedules [TYPE Function]
    # @PURPOSE: list_active_schedules returns only active schedules.
    def test_list_active_schedules(self) -> None:
        db = MagicMock()
        active = [MagicMock(spec=TranslationSchedule) for _ in range(3)]
        db.query.return_value.filter.return_value.all.return_value = active

        result = TranslationScheduler.list_active_schedules(db)

        assert len(result) == 3
    # endregion test_list_active_schedules

    # region test_get_next_executions [TYPE Function]
    # @PURPOSE: get_next_executions returns correct number of future execution times.
    def test_get_next_executions(self) -> None:
        result = TranslationScheduler.get_next_executions(
            cron_expression="0 6 * * 1",
            timezone_str="UTC",
            n=3,
        )
        assert len(result) == 3
        # All returned times should be valid ISO format
        for r in result:
            assert "T" in r
    # endregion test_get_next_executions

    # region test_get_next_executions_invalid_cron [TYPE Function]
    # @PURPOSE: Invalid cron expression returns empty list.
    def test_get_next_executions_invalid_cron(self) -> None:
        result = TranslationScheduler.get_next_executions(
            cron_expression="NOT_A_CRON",
            timezone_str="UTC",
        )
        assert result == []
    # endregion test_get_next_executions_invalid_cron

# endregion TestTranslationScheduler


# region TestExecuteScheduledTranslation [TYPE Class]
# @PURPOSE: Tests for execute_scheduled_translation function: notification on failure, concurrency.
class TestExecuteScheduledTranslation:

    # region test_notification_on_failure [TYPE Function]
    # @PURPOSE: On execution failure, NotificationService.send() is called.
    @patch("src.plugins.translate.scheduler.NotificationService")
    @patch("src.plugins.translate.orchestrator.TranslationOrchestrator")
    @patch("src.plugins.translate.scheduler.seed_trace_id")
    def test_notification_on_failure(
        self,
        _mock_seed_trace_id: MagicMock,
        mock_orchestrator_class: MagicMock,
        mock_notification_service_class: MagicMock,
    ) -> None:
        from src.plugins.translate.scheduler import execute_scheduled_translation

        db = MagicMock()
        db_maker = MagicMock(return_value=db)
        config_manager = MagicMock()

        # Mock schedule query returns active schedule
        schedule = MagicMock(spec=TranslationSchedule)
        schedule.id = "sched-1"
        schedule.job_id = "job-1"
        schedule.is_active = True
        schedule.last_run_at = None

        # Mock no active concurrent run
        db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None

        # Set up side_effect: first call returns schedule, subsequent calls for runs return None
        db.query.return_value.filter.return_value.first.side_effect = [
            schedule,  # schedule query
            None,      # active run check (no concurrent run)
        ]
        db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None

        # Mock orchestrator raises exception
        mock_orch_instance = MagicMock()
        mock_orch_instance.start_run.return_value = MagicMock(id="run-1", status="PENDING")
        mock_orch_instance.execute_run.side_effect = ValueError("LLM provider timeout")
        mock_orchestrator_class.return_value = mock_orch_instance

        # Mock NotificationService providers
        mock_provider = MagicMock()
        mock_notification_service_instance = MagicMock()
        mock_notification_service_instance._providers = {"SMTP": mock_provider}
        mock_notification_service_class.return_value = mock_notification_service_instance

        execute_scheduled_translation(
            schedule_id="sched-1",
            job_id="job-1",
            db_session_maker=db_maker,
            config_manager=config_manager,
            execution_mode="new_key_only",
        )

        # Verify NotificationService was created and initialized
        mock_notification_service_class.assert_called_once_with(db, config_manager)
        mock_notification_service_instance._initialize_providers.assert_called_once()

        # Verify provider.send was called (notification dispatched)
        assert mock_provider.send.call_count >= 1
        call_args = mock_provider.send.call_args
        assert call_args is not None
        # Subject should mention failure
        assert "failed" in call_args.kwargs.get("subject", "").lower() or "failed" in call_args[0][1].lower()
    # endregion test_notification_on_failure

    # region test_no_notification_on_success [TYPE Function]
    # @PURPOSE: On successful execution, NotificationService.send() is NOT called.
    @patch("src.plugins.translate.scheduler.NotificationService")
    @patch("src.plugins.translate.orchestrator.TranslationOrchestrator")
    @patch("src.plugins.translate.scheduler.seed_trace_id")
    def test_no_notification_on_success(
        self,
        _mock_seed_trace_id: MagicMock,
        mock_orchestrator_class: MagicMock,
        mock_notification_service_class: MagicMock,
    ) -> None:
        from src.plugins.translate.scheduler import execute_scheduled_translation

        db = MagicMock()
        db_maker = MagicMock(return_value=db)
        config_manager = MagicMock()

        # Mock schedule query returns active schedule
        schedule = MagicMock(spec=TranslationSchedule)
        schedule.id = "sched-1"
        schedule.job_id = "job-1"
        schedule.is_active = True
        schedule.last_run_at = None

        # Mock no active concurrent run
        db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None

        db.query.return_value.filter.return_value.first.side_effect = [
            schedule,  # schedule query
            None,      # active run check
        ]

        # Mock successful orchestrator
        mock_orch_instance = MagicMock()
        mock_run = MagicMock()
        mock_run.id = "run-1"
        mock_run.status = "COMPLETED"
        mock_run.insert_status = "succeeded"
        mock_orch_instance.start_run.return_value = MagicMock(id="run-1", status="PENDING")
        mock_orch_instance.execute_run.side_effect = None
        mock_orchestrator_class.return_value = mock_orch_instance

        execute_scheduled_translation(
            schedule_id="sched-1",
            job_id="job-1",
            db_session_maker=db_maker,
            config_manager=config_manager,
            execution_mode="new_key_only",
        )

        # Verify NotificationService was NOT instantiated (no failure = no notification)
        # Note: execute_run returns None in this mock (it's a side_effect that runs and returns None)
        # The success path should not call notification
        mock_notification_service_class.assert_not_called()
    # endregion test_no_notification_on_success

    # region test_concurrent_run_skip [TYPE Function]
    # @PURPOSE: When a concurrent run exists, scheduled execution is skipped.
    @patch("src.plugins.translate.scheduler.seed_trace_id")
    def test_concurrent_run_skip(self, _mock_seed_trace_id: MagicMock) -> None:
        from src.plugins.translate.scheduler import execute_scheduled_translation

        db = MagicMock()
        db_maker = MagicMock(return_value=db)
        config_manager = MagicMock()

        # Mock schedule query
        schedule = MagicMock(spec=TranslationSchedule)
        schedule.id = "sched-1"
        schedule.job_id = "job-1"
        schedule.is_active = True

        # Mock an active running run (recent, not stale)
        active_run = MagicMock(spec=TranslationRun)
        active_run.id = "run-existing"
        active_run.job_id = "job-1"
        active_run.status = "RUNNING"
        active_run.created_at = datetime.now(UTC)

        db.query.return_value.filter.return_value.first.side_effect = [
            schedule,  # schedule query
            None,      # schedule query for TranslationJob
        ]
        db.query.return_value.filter.return_value.order_by.return_value.first.return_value = active_run

        execute_scheduled_translation(
            schedule_id="sched-1",
            job_id="job-1",
            db_session_maker=db_maker,
            config_manager=config_manager,
            execution_mode="new_key_only",
        )

        # Verify run was NOT started (no start_run call)
        # Verify event was logged for skipped concurrent
    # endregion test_concurrent_run_skip

# endregion TestExecuteScheduledTranslation
# endregion TestScheduler
