"""
Behavioral tests for scheduling patterns.

These tests verify the logic of scheduling patterns like cron expressions,
interval calculation, job queuing, and time-based execution
without making actual scheduler calls.
"""

import calendar
import time
from dataclasses import dataclass
from datetime import datetime, timedelta
from enum import Enum


class TestCronParsing:
    """Tests for cron expression parsing."""

    def test_parse_simple_cron(self):
        """Test parsing simple cron expression."""

        def parse_cron(expression):
            parts = expression.split()
            if len(parts) != 5:
                return None
            return {
                "minute": parts[0],
                "hour": parts[1],
                "day_of_month": parts[2],
                "month": parts[3],
                "day_of_week": parts[4],
            }

        parsed = parse_cron("0 12 * * *")
        assert parsed["minute"] == "0"
        assert parsed["hour"] == "12"
        assert parsed["day_of_month"] == "*"

    def test_expand_cron_wildcard(self):
        """Test expanding cron wildcard."""

        def expand_field(field, min_val, max_val):
            if field == "*":
                return list(range(min_val, max_val + 1))
            if "," in field:
                return [int(x) for x in field.split(",")]
            if "-" in field:
                start, end = field.split("-")
                return list(range(int(start), int(end) + 1))
            if "/" in field:
                base, step = field.split("/")
                if base == "*":
                    return list(range(min_val, max_val + 1, int(step)))
                return list(range(int(base), max_val + 1, int(step)))
            return [int(field)]

        assert expand_field("*", 0, 59) == list(range(60))
        assert expand_field("0,15,30,45", 0, 59) == [0, 15, 30, 45]
        assert expand_field("1-5", 1, 7) == [1, 2, 3, 4, 5]
        assert expand_field("*/15", 0, 59) == [0, 15, 30, 45]
        assert expand_field("10", 0, 59) == [10]

    def test_parse_cron_step(self):
        """Test parsing cron step values."""

        def parse_step(field):
            if "/" not in field:
                return None, None
            base, step = field.split("/")
            return base, int(step)

        base, step = parse_step("*/5")
        assert base == "*"
        assert step == 5

        base, step = parse_step("0/10")
        assert base == "0"
        assert step == 10

    def test_validate_cron_field(self):
        """Test validating cron field values."""

        def validate_field(field, min_val, max_val):
            if field == "*":
                return True
            try:
                for part in (
                    field.replace("-", ",").replace("/", ",").split(",")
                ):
                    if part != "*":
                        val = int(part)
                        if val < min_val or val > max_val:
                            return False
                return True
            except ValueError:
                return False

        assert validate_field("0", 0, 59) is True
        assert validate_field("60", 0, 59) is False
        assert validate_field("0-30", 0, 59) is True
        assert validate_field("invalid", 0, 59) is False

    def test_cron_aliases(self):
        """Test cron expression aliases."""

        def expand_alias(expression):
            aliases = {
                "@yearly": "0 0 1 1 *",
                "@annually": "0 0 1 1 *",
                "@monthly": "0 0 1 * *",
                "@weekly": "0 0 * * 0",
                "@daily": "0 0 * * *",
                "@midnight": "0 0 * * *",
                "@hourly": "0 * * * *",
            }
            return aliases.get(expression, expression)

        assert expand_alias("@daily") == "0 0 * * *"
        assert expand_alias("@hourly") == "0 * * * *"
        assert expand_alias("0 12 * * *") == "0 12 * * *"


class TestIntervalScheduling:
    """Tests for interval-based scheduling."""

    def test_calculate_next_run(self):
        """Test calculating next run time from interval."""

        def next_run(last_run, interval_seconds):
            if last_run is None:
                return time.time()
            return last_run + interval_seconds

        now = time.time()
        last = now - 100
        next_time = next_run(last, 60)
        assert next_time == last + 60

        # No last run - should run now
        assert next_run(None, 60) >= now

    def test_parse_interval_string(self):
        """Test parsing interval string to seconds."""

        def parse_interval(interval_str):
            units = {
                "s": 1,
                "m": 60,
                "h": 3600,
                "d": 86400,
                "w": 604800,
            }
            if not interval_str:
                return None
            unit = interval_str[-1].lower()
            if unit not in units:
                return int(interval_str)
            value = int(interval_str[:-1])
            return value * units[unit]

        assert parse_interval("30s") == 30
        assert parse_interval("5m") == 300
        assert parse_interval("2h") == 7200
        assert parse_interval("1d") == 86400
        assert parse_interval("60") == 60

    def test_interval_with_jitter(self):
        """Test adding jitter to interval."""

        def add_jitter(interval, max_jitter_percent=10):
            import random

            jitter_range = interval * max_jitter_percent / 100
            jitter = random.uniform(-jitter_range, jitter_range)
            return max(0, interval + jitter)

        # Can't test randomness deterministically, but can verify bounds
        interval = 100
        for _ in range(100):
            result = add_jitter(interval, max_jitter_percent=10)
            assert 90 <= result <= 110

    def test_align_to_interval(self):
        """Test aligning time to interval boundary."""

        def align_to_interval(timestamp, interval_seconds):
            return (timestamp // interval_seconds) * interval_seconds

        # Align to minute boundary
        ts = 1704067230  # Some arbitrary timestamp
        aligned = align_to_interval(ts, 60)
        assert aligned % 60 == 0
        assert aligned <= ts

    def test_is_overdue(self):
        """Test checking if scheduled task is overdue."""

        def is_overdue(scheduled_time, grace_period=0):
            return time.time() > scheduled_time + grace_period

        now = time.time()
        assert is_overdue(now - 100) is True
        assert is_overdue(now + 100) is False
        assert is_overdue(now - 50, grace_period=100) is False


class TestJobQueuing:
    """Tests for job queue patterns."""

    def test_priority_queue_ordering(self):
        """Test priority queue job ordering."""

        def sort_by_priority(jobs):
            # Higher priority first, then by scheduled time
            return sorted(
                jobs, key=lambda j: (-j["priority"], j["scheduled_at"])
            )

        jobs = [
            {"id": 1, "priority": 1, "scheduled_at": 100},
            {"id": 2, "priority": 3, "scheduled_at": 100},
            {"id": 3, "priority": 2, "scheduled_at": 50},
        ]
        sorted_jobs = sort_by_priority(jobs)
        assert sorted_jobs[0]["id"] == 2  # Highest priority
        assert sorted_jobs[1]["id"] == 3  # Medium priority
        assert sorted_jobs[2]["id"] == 1  # Lowest priority

    def test_fifo_queue_ordering(self):
        """Test FIFO queue job ordering."""

        def sort_fifo(jobs):
            return sorted(jobs, key=lambda j: j["enqueued_at"])

        jobs = [
            {"id": 1, "enqueued_at": 300},
            {"id": 2, "enqueued_at": 100},
            {"id": 3, "enqueued_at": 200},
        ]
        sorted_jobs = sort_fifo(jobs)
        assert [j["id"] for j in sorted_jobs] == [2, 3, 1]

    def test_delay_queue(self):
        """Test delay queue - jobs with future execution."""

        def get_ready_jobs(jobs, current_time):
            return [j for j in jobs if j["run_at"] <= current_time]

        now = 1000
        jobs = [
            {"id": 1, "run_at": 900},  # Past - ready
            {"id": 2, "run_at": 1000},  # Now - ready
            {"id": 3, "run_at": 1100},  # Future - not ready
        ]
        ready = get_ready_jobs(jobs, now)
        assert len(ready) == 2
        assert ready[0]["id"] == 1
        assert ready[1]["id"] == 2

    def test_job_deduplication(self):
        """Test job deduplication by key."""

        def deduplicate_jobs(jobs, key_field="dedup_key"):
            seen = set()
            result = []
            for job in jobs:
                key = job.get(key_field)
                if key is None or key not in seen:
                    result.append(job)
                    if key is not None:
                        seen.add(key)
            return result

        jobs = [
            {"id": 1, "dedup_key": "sync_user_1"},
            {"id": 2, "dedup_key": "sync_user_2"},
            {"id": 3, "dedup_key": "sync_user_1"},  # Duplicate
        ]
        deduped = deduplicate_jobs(jobs)
        assert len(deduped) == 2
        assert deduped[0]["id"] == 1
        assert deduped[1]["id"] == 2

    def test_rate_limited_queue(self):
        """Test rate-limited job release."""

        def get_jobs_within_rate(jobs, max_per_second, window_seconds=1):
            max_jobs = max_per_second * window_seconds
            return jobs[:max_jobs]

        jobs = [{"id": i} for i in range(100)]
        released = get_jobs_within_rate(jobs, max_per_second=10)
        assert len(released) == 10


class TestTimeCalculations:
    """Tests for time calculation patterns."""

    def test_next_day_of_week(self):
        """Test calculating next occurrence of day of week."""

        def next_day_of_week(target_weekday, from_date=None):
            if from_date is None:
                from_date = datetime.now()
            current_weekday = from_date.weekday()
            days_ahead = target_weekday - current_weekday
            if days_ahead <= 0:
                days_ahead += 7
            return from_date + timedelta(days=days_ahead)

        # From Monday, next Friday is 4 days ahead
        monday = datetime(2024, 1, 1)  # This is a Monday
        next_friday = next_day_of_week(4, monday)  # 4 = Friday
        assert next_friday.weekday() == 4
        assert (next_friday - monday).days == 4

    def test_next_month_day(self):
        """Test calculating next occurrence of day of month."""

        def next_month_day(target_day, from_date=None):
            if from_date is None:
                from_date = datetime.now()
            if from_date.day < target_day:
                # Later this month
                try:
                    return from_date.replace(day=target_day)
                except ValueError:
                    # Day doesn't exist in this month
                    pass
            # Next month
            if from_date.month == 12:
                next_month = from_date.replace(
                    year=from_date.year + 1, month=1, day=1
                )
            else:
                next_month = from_date.replace(month=from_date.month + 1, day=1)
            days_in_month = calendar.monthrange(
                next_month.year, next_month.month
            )[1]
            day = min(target_day, days_in_month)
            return next_month.replace(day=day)

        # From Jan 5, next 15th is Jan 15
        jan_5 = datetime(2024, 1, 5)
        next_15th = next_month_day(15, jan_5)
        assert next_15th.day == 15
        assert next_15th.month == 1

        # From Jan 20, next 15th is Feb 15
        jan_20 = datetime(2024, 1, 20)
        next_15th = next_month_day(15, jan_20)
        assert next_15th.day == 15
        assert next_15th.month == 2

    def test_business_days_calculation(self):
        """Test calculating business days."""

        def add_business_days(from_date, days):
            current = from_date
            added = 0
            while added < days:
                current += timedelta(days=1)
                if current.weekday() < 5:  # Monday = 0, Friday = 4
                    added += 1
            return current

        monday = datetime(2024, 1, 1)  # Monday
        result = add_business_days(monday, 5)
        assert result.weekday() == 0  # Should be next Monday
        assert (result - monday).days == 7  # 7 calendar days

    def test_time_until_next(self):
        """Test calculating time until next occurrence."""

        def time_until(target_time, current_time=None):
            if current_time is None:
                current_time = time.time()
            diff = target_time - current_time
            return max(0, diff)

        now = time.time()
        future = now + 3600
        past = now - 100

        assert time_until(future, now) == 3600
        assert time_until(past, now) == 0  # Past returns 0

    def test_round_to_time_unit(self):
        """Test rounding time to units."""

        def round_to_minute(dt):
            return dt.replace(second=0, microsecond=0)

        def round_to_hour(dt):
            return dt.replace(minute=0, second=0, microsecond=0)

        dt = datetime(2024, 1, 1, 12, 34, 56)
        assert round_to_minute(dt) == datetime(2024, 1, 1, 12, 34, 0)
        assert round_to_hour(dt) == datetime(2024, 1, 1, 12, 0, 0)


class TestSchedulerState:
    """Tests for scheduler state management."""

    def test_job_state_transitions(self):
        """Test job state transitions."""

        class JobState(Enum):
            PENDING = "pending"
            SCHEDULED = "scheduled"
            RUNNING = "running"
            COMPLETED = "completed"
            FAILED = "failed"
            CANCELLED = "cancelled"

        def can_transition(from_state, to_state):
            valid_transitions = {
                JobState.PENDING: {JobState.SCHEDULED, JobState.CANCELLED},
                JobState.SCHEDULED: {JobState.RUNNING, JobState.CANCELLED},
                JobState.RUNNING: {JobState.COMPLETED, JobState.FAILED},
                JobState.FAILED: {JobState.SCHEDULED},  # Retry
            }
            return to_state in valid_transitions.get(from_state, set())

        assert can_transition(JobState.PENDING, JobState.SCHEDULED) is True
        assert can_transition(JobState.PENDING, JobState.RUNNING) is False
        assert can_transition(JobState.RUNNING, JobState.COMPLETED) is True
        assert can_transition(JobState.COMPLETED, JobState.RUNNING) is False

    def test_track_job_execution(self):
        """Test tracking job execution history."""

        @dataclass
        class JobExecution:
            job_id: str
            started_at: float
            completed_at: float = None
            success: bool = None
            error: str = None

            @property
            def duration(self):
                if self.completed_at is None:
                    return None
                return self.completed_at - self.started_at

        execution = JobExecution(job_id="job1", started_at=1000)
        assert execution.duration is None

        execution.completed_at = 1005
        execution.success = True
        assert execution.duration == 5

    def test_calculate_next_retry(self):
        """Test calculating next retry time with backoff."""

        def next_retry_time(attempt, base_delay=60, max_delay=3600):
            delay = base_delay * (2 ** (attempt - 1))
            delay = min(delay, max_delay)
            return time.time() + delay

        now = time.time()
        retry1 = next_retry_time(1)
        retry2 = next_retry_time(2)
        retry3 = next_retry_time(3)

        assert retry1 >= now + 60
        assert retry2 >= now + 120
        assert retry3 >= now + 240

    def test_job_timeout_check(self):
        """Test checking if job has timed out."""

        def is_timed_out(started_at, timeout_seconds):
            return time.time() > started_at + timeout_seconds

        now = time.time()
        assert is_timed_out(now - 100, timeout_seconds=60) is True
        assert is_timed_out(now - 30, timeout_seconds=60) is False

    def test_max_concurrent_jobs(self):
        """Test checking concurrent job limit."""

        def can_start_job(running_jobs, max_concurrent):
            return len(running_jobs) < max_concurrent

        running = [{"id": 1}, {"id": 2}]
        assert can_start_job(running, max_concurrent=3) is True
        assert can_start_job(running, max_concurrent=2) is False


class TestRecurrencePatterns:
    """Tests for recurrence pattern handling."""

    def test_parse_recurrence_rule(self):
        """Test parsing recurrence rule."""

        def parse_rrule(rule_string):
            parts = rule_string.split(";")
            rule = {}
            for part in parts:
                if "=" in part:
                    key, value = part.split("=")
                    if key == "FREQ":
                        rule["frequency"] = value.lower()
                    elif key == "INTERVAL":
                        rule["interval"] = int(value)
                    elif key == "COUNT":
                        rule["count"] = int(value)
                    elif key == "BYDAY":
                        rule["by_day"] = value.split(",")
            return rule

        rule = parse_rrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR")
        assert rule["frequency"] == "weekly"
        assert rule["interval"] == 2
        assert rule["by_day"] == ["MO", "WE", "FR"]

    def test_daily_recurrence(self):
        """Test daily recurrence calculation."""

        def next_daily(from_date, interval=1):
            return from_date + timedelta(days=interval)

        start = datetime(2024, 1, 1)
        next_date = next_daily(start, interval=2)
        assert next_date == datetime(2024, 1, 3)

    def test_weekly_recurrence(self):
        """Test weekly recurrence calculation."""

        def next_weekly(from_date, interval=1):
            return from_date + timedelta(weeks=interval)

        start = datetime(2024, 1, 1)
        next_date = next_weekly(start, interval=2)
        assert next_date == datetime(2024, 1, 15)

    def test_monthly_recurrence(self):
        """Test monthly recurrence calculation."""

        def next_monthly(from_date, interval=1):
            year = from_date.year
            month = from_date.month + interval
            while month > 12:
                month -= 12
                year += 1
            day = min(from_date.day, calendar.monthrange(year, month)[1])
            return from_date.replace(year=year, month=month, day=day)

        start = datetime(2024, 1, 31)
        next_date = next_monthly(start, interval=1)
        assert next_date.month == 2
        assert next_date.day == 29  # Feb 2024 has 29 days

    def test_recurrence_end_condition(self):
        """Test recurrence end conditions."""

        def should_continue(
            occurrences, max_count=None, end_date=None, current_date=None
        ):
            if max_count is not None and occurrences >= max_count:
                return False
            if end_date is not None and current_date is not None:
                if current_date > end_date:
                    return False
            return True

        assert should_continue(5, max_count=10) is True
        assert should_continue(10, max_count=10) is False

        end = datetime(2024, 12, 31)
        assert (
            should_continue(5, end_date=end, current_date=datetime(2024, 6, 1))
            is True
        )
        assert (
            should_continue(5, end_date=end, current_date=datetime(2025, 1, 1))
            is False
        )


class TestTimezones:
    """Tests for timezone handling in scheduling."""

    def test_utc_offset_calculation(self):
        """Test UTC offset calculation."""

        def apply_offset(utc_time, offset_hours):
            return utc_time + timedelta(hours=offset_hours)

        utc = datetime(2024, 1, 1, 12, 0, 0)
        # EST is UTC-5
        est = apply_offset(utc, -5)
        assert est.hour == 7

        # JST is UTC+9
        jst = apply_offset(utc, 9)
        assert jst.hour == 21

    def test_dst_aware_scheduling(self):
        """Test DST-aware scheduling."""

        def is_dst_transition_day(date, dst_transitions):
            date_str = date.strftime("%Y-%m-%d")
            return date_str in dst_transitions

        dst_transitions = {"2024-03-10", "2024-11-03"}  # US DST dates

        assert (
            is_dst_transition_day(datetime(2024, 3, 10), dst_transitions)
            is True
        )
        assert (
            is_dst_transition_day(datetime(2024, 3, 11), dst_transitions)
            is False
        )

    def test_timezone_conversion(self):
        """Test timezone name to offset conversion."""

        def get_offset(timezone_name):
            offsets = {
                "UTC": 0,
                "EST": -5,
                "PST": -8,
                "JST": 9,
                "GMT": 0,
                "CET": 1,
            }
            return offsets.get(timezone_name, 0)

        assert get_offset("EST") == -5
        assert get_offset("JST") == 9
        assert get_offset("unknown") == 0

    def test_schedule_in_local_time(self):
        """Test scheduling in local time."""

        def to_utc(local_time, utc_offset):
            return local_time - timedelta(hours=utc_offset)

        # Schedule for 9am EST
        local_9am = datetime(2024, 1, 1, 9, 0, 0)
        utc_time = to_utc(local_9am, utc_offset=-5)
        assert utc_time.hour == 14  # 9am EST = 2pm UTC

    def test_cross_day_timezone_handling(self):
        """Test handling schedule that crosses day boundary."""

        def adjust_for_timezone(utc_time, offset):
            adjusted = utc_time + timedelta(hours=offset)
            day_diff = adjusted.day - utc_time.day
            return adjusted, day_diff

        # 11pm UTC -> next day in JST (+9)
        utc_11pm = datetime(2024, 1, 1, 23, 0, 0)
        jst_time, day_diff = adjust_for_timezone(utc_11pm, 9)
        assert jst_time.hour == 8
        assert day_diff == 1


class TestScheduleConflicts:
    """Tests for schedule conflict detection."""

    def test_detect_overlap(self):
        """Test detecting schedule overlaps."""

        def has_overlap(schedule1, schedule2):
            # Each schedule has start and end times
            start1, end1 = schedule1
            start2, end2 = schedule2
            return start1 < end2 and start2 < end1

        # Overlapping
        assert has_overlap((100, 200), (150, 250)) is True
        # Non-overlapping
        assert has_overlap((100, 200), (200, 300)) is False
        # One contains other
        assert has_overlap((100, 300), (150, 200)) is True

    def test_find_next_available_slot(self):
        """Test finding next available time slot."""

        def find_slot(existing_schedules, duration, start_from):
            existing_schedules = sorted(existing_schedules, key=lambda x: x[0])
            current = start_from

            for start, end in existing_schedules:
                if current + duration <= start:
                    return current  # Found a gap
                current = max(current, end)

            return current  # After all existing schedules

        schedules = [(100, 150), (200, 250)]
        # Find a 40-minute slot - fits before first schedule (0+40=40 <= 100)
        slot = find_slot(schedules, duration=40, start_from=0)
        assert slot == 0  # Can fit before first schedule

        # Find a 60-minute slot - fits before first schedule (0+60=60 <= 100)
        slot = find_slot(schedules, duration=60, start_from=0)
        assert slot == 0  # Still fits before first schedule

        # Find a 110-minute slot - doesn't fit anywhere except after all
        slot = find_slot(schedules, duration=110, start_from=0)
        assert slot == 250  # Must be after all schedules

    def test_merge_overlapping_schedules(self):
        """Test merging overlapping schedules."""

        def merge_schedules(schedules):
            if not schedules:
                return []
            sorted_schedules = sorted(schedules, key=lambda x: x[0])
            merged = [sorted_schedules[0]]

            for start, end in sorted_schedules[1:]:
                last_start, last_end = merged[-1]
                if start <= last_end:
                    merged[-1] = (last_start, max(last_end, end))
                else:
                    merged.append((start, end))
            return merged

        schedules = [(100, 200), (150, 250), (300, 400)]
        merged = merge_schedules(schedules)
        assert len(merged) == 2
        assert merged[0] == (100, 250)
        assert merged[1] == (300, 400)

    def test_conflict_resolution_priority(self):
        """Test resolving conflicts by priority."""

        def resolve_conflict(schedules):
            # Higher priority wins
            sorted_by_priority = sorted(
                schedules, key=lambda x: x.get("priority", 0), reverse=True
            )
            result = []
            occupied = []

            for schedule in sorted_by_priority:
                start, end = schedule["start"], schedule["end"]
                has_conflict = any(s < end and e > start for s, e in occupied)
                if not has_conflict:
                    result.append(schedule)
                    occupied.append((start, end))

            return result

        schedules = [
            {"id": 1, "start": 100, "end": 200, "priority": 1},
            {
                "id": 2,
                "start": 150,
                "end": 250,
                "priority": 2,
            },  # Higher priority
            {"id": 3, "start": 300, "end": 400, "priority": 1},
        ]
        resolved = resolve_conflict(schedules)
        assert len(resolved) == 2
        assert resolved[0]["id"] == 2  # Higher priority kept
        assert resolved[1]["id"] == 3  # No conflict


class TestMaintenanceWindows:
    """Tests for maintenance window handling."""

    def test_is_in_maintenance_window(self):
        """Test checking if time is in maintenance window."""

        def is_maintenance_time(current_time, windows):
            for window in windows:
                start_hour, end_hour = window
                current_hour = current_time.hour
                if start_hour <= end_hour:
                    if start_hour <= current_hour < end_hour:
                        return True
                else:  # Crosses midnight
                    if current_hour >= start_hour or current_hour < end_hour:
                        return True
            return False

        # Maintenance window 2am-4am
        windows = [(2, 4)]
        assert is_maintenance_time(datetime(2024, 1, 1, 3, 0), windows) is True
        assert is_maintenance_time(datetime(2024, 1, 1, 5, 0), windows) is False

        # Window crossing midnight 23:00-02:00
        windows = [(23, 2)]
        assert (
            is_maintenance_time(datetime(2024, 1, 1, 23, 30), windows) is True
        )
        assert is_maintenance_time(datetime(2024, 1, 1, 1, 0), windows) is True
        assert (
            is_maintenance_time(datetime(2024, 1, 1, 10, 0), windows) is False
        )

    def test_delay_until_after_maintenance(self):
        """Test delaying job until after maintenance."""

        def delay_if_maintenance(scheduled_time, maintenance_end):
            if scheduled_time < maintenance_end:
                return maintenance_end
            return scheduled_time

        maintenance_end = datetime(2024, 1, 1, 4, 0)
        scheduled = datetime(2024, 1, 1, 3, 0)
        delayed = delay_if_maintenance(scheduled, maintenance_end)
        assert delayed == maintenance_end

    def test_skip_maintenance_days(self):
        """Test skipping certain days for maintenance."""

        def is_maintenance_day(date, maintenance_days):
            return date.weekday() in maintenance_days

        # Sunday maintenance (weekday 6)
        maintenance_days = {6}
        assert (
            is_maintenance_day(datetime(2024, 1, 7), maintenance_days) is True
        )
        assert (
            is_maintenance_day(datetime(2024, 1, 8), maintenance_days) is False
        )

    def test_blackout_periods(self):
        """Test blackout period handling."""

        def is_blackout(date, blackout_periods):
            for start, end in blackout_periods:
                if start <= date <= end:
                    return True
            return False

        blackouts = [
            (datetime(2024, 12, 23), datetime(2024, 12, 26)),  # Christmas
            (datetime(2024, 12, 31), datetime(2025, 1, 2)),  # New Year
        ]

        assert is_blackout(datetime(2024, 12, 24), blackouts) is True
        assert is_blackout(datetime(2024, 12, 15), blackouts) is False
