"""Schedule validation + next-run computation. Shared between company creation/monitor-config updates (this phase) and the Celery Beat dynamic schedule sync (Phase 5) so both paths agree on what a valid schedule is and when it next fires. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from croniter import CroniterBadCronError, croniter from app.core.errors import ValidationAppError from app.models.enums import FREQUENCY_MINUTES, MonitoringFrequency def validate_and_compute_next_run( *, frequency_type: MonitoringFrequency, interval_minutes: int | None, cron_expression: str | None, tz_name: str, minimum_interval_minutes: int, from_time: datetime | None = None, ) -> datetime: now = from_time or datetime.now(UTC) try: tzinfo = ZoneInfo(tz_name) except ZoneInfoNotFoundError as exc: raise ValidationAppError(f"Unknown timezone: {tz_name}") from exc if frequency_type == MonitoringFrequency.CUSTOM: return _compute_custom_next_run( interval_minutes=interval_minutes, cron_expression=cron_expression, tzinfo=tzinfo, minimum_interval_minutes=minimum_interval_minutes, now=now, ) frequency_minutes = FREQUENCY_MINUTES[frequency_type] if frequency_minutes < minimum_interval_minutes: raise ValidationAppError( f"{frequency_type.value} monitoring is more frequent than the minimum allowed " f"interval of {minimum_interval_minutes} minutes" ) return now + timedelta(minutes=frequency_minutes) def _compute_custom_next_run( *, interval_minutes: int | None, cron_expression: str | None, tzinfo: ZoneInfo, minimum_interval_minutes: int, now: datetime, ) -> datetime: if interval_minutes is not None: if interval_minutes < minimum_interval_minutes: raise ValidationAppError( f"Custom interval must be at least {minimum_interval_minutes} minutes" ) return now + timedelta(minutes=interval_minutes) if cron_expression: try: base = now.astimezone(tzinfo) next_local = croniter(cron_expression, base).get_next(datetime) except (CroniterBadCronError, ValueError) as exc: raise ValidationAppError(f"Invalid cron expression: {cron_expression}") from exc next_utc = next_local.astimezone(UTC) if (next_utc - now) < timedelta(minutes=minimum_interval_minutes): raise ValidationAppError( "Custom schedule resolves to less than the minimum allowed interval of " f"{minimum_interval_minutes} minutes" ) return next_utc raise ValidationAppError("Custom frequency requires either interval_minutes or cron_expression")