"""Tests for the async job scheduler."""

import asyncio
from datetime import datetime, timedelta, timezone
from unittest.mock import patch

import pytest

from fliiq.runtime.scheduler.models import JobDefinition, JobState, TriggerConfig
from fliiq.runtime.scheduler.scheduler import Scheduler, compute_next_fire, parse_interval


def _make_job(name: str = "test-job", trigger_type: str = "every", schedule: str = "1s", **kw):
    return JobDefinition(
        name=name,
        trigger=TriggerConfig(type=trigger_type, schedule=schedule, **kw),
        prompt="test prompt",
    )


# --- parse_interval ---


def test_parse_interval_seconds():
    assert parse_interval("30s") == timedelta(seconds=30)


def test_parse_interval_minutes():
    assert parse_interval("30m") == timedelta(minutes=30)


def test_parse_interval_hours():
    assert parse_interval("2h") == timedelta(hours=2)


def test_parse_interval_days():
    assert parse_interval("1d") == timedelta(days=1)


def test_parse_interval_invalid():
    with pytest.raises(ValueError, match="Invalid interval"):
        parse_interval("abc")


# --- compute_next_fire ---


def test_compute_next_fire_cron():
    now = datetime(2026, 2, 9, 8, 0, tzinfo=timezone.utc)
    job = _make_job(trigger_type="cron", schedule="0 9 * * *")
    next_fire = compute_next_fire(job, now)
    assert next_fire.hour == 9
    assert next_fire.day == 9  # Same day, 9am is after 8am


def test_compute_next_fire_every_never_run():
    now = datetime(2026, 2, 9, 8, 0, tzinfo=timezone.utc)
    job = _make_job(trigger_type="every", schedule="1h")
    next_fire = compute_next_fire(job, now)
    assert next_fire == now  # Never run → fire immediately


def test_compute_next_fire_every_with_last_run():
    now = datetime(2026, 2, 9, 10, 0, tzinfo=timezone.utc)
    job = _make_job(trigger_type="every", schedule="1h")
    job.state.last_run_at = datetime(2026, 2, 9, 9, 30, tzinfo=timezone.utc)
    next_fire = compute_next_fire(job, now)
    assert next_fire == datetime(2026, 2, 9, 10, 30, tzinfo=timezone.utc)


def test_compute_next_fire_at_future():
    now = datetime(2026, 2, 9, 8, 0, tzinfo=timezone.utc)
    job = _make_job(trigger_type="at", schedule="2026-02-09T12:00:00+00:00")
    next_fire = compute_next_fire(job, now)
    assert next_fire.hour == 12


def test_compute_next_fire_at_past_catchup():
    now = datetime(2026, 2, 9, 15, 0, tzinfo=timezone.utc)
    job = _make_job(trigger_type="at", schedule="2026-02-09T12:00:00+00:00")
    next_fire = compute_next_fire(job, now)
    assert next_fire == now  # Past due → fire now


# --- Scheduler ---


async def test_fires_every_job():
    fired = []

    async def on_fire(job):
        fired.append(job.name)

    scheduler = Scheduler(on_fire=on_fire)
    job = _make_job(trigger_type="every", schedule="1s")

    await scheduler.start([job])
    await asyncio.sleep(2.5)
    await scheduler.stop()

    assert len(fired) >= 2


async def test_at_fires_once():
    fired = []

    async def on_fire(job):
        fired.append(job.name)

    scheduler = Scheduler(on_fire=on_fire)
    at_time = (datetime.now(timezone.utc) + timedelta(seconds=0.5)).isoformat()
    job = _make_job(trigger_type="at", schedule=at_time)

    await scheduler.start([job])
    await asyncio.sleep(2)
    await scheduler.stop()

    assert len(fired) == 1


async def test_skips_webhook_jobs():
    fired = []

    async def on_fire(job):
        fired.append(job.name)

    scheduler = Scheduler(on_fire=on_fire)
    job = JobDefinition(
        name="webhook-job",
        trigger=TriggerConfig(type="webhook", source="github"),
        prompt="test",
    )

    await scheduler.start([job])
    assert scheduler.scheduled_count == 0
    await scheduler.stop()
    assert len(fired) == 0


async def test_skips_disabled_jobs():
    fired = []

    async def on_fire(job):
        fired.append(job.name)

    scheduler = Scheduler(on_fire=on_fire)
    job = _make_job()
    job.enabled = False

    await scheduler.start([job])
    assert scheduler.scheduled_count == 0
    await scheduler.stop()


async def test_add_job_hot():
    fired = []

    async def on_fire(job):
        fired.append(job.name)

    scheduler = Scheduler(on_fire=on_fire)
    await scheduler.start([])
    assert scheduler.scheduled_count == 0

    job = _make_job(trigger_type="every", schedule="1s")
    scheduler.add_job(job)
    assert scheduler.scheduled_count == 1

    await asyncio.sleep(1.5)
    await scheduler.stop()

    assert len(fired) >= 1


async def test_remove_job():
    fired = []

    async def on_fire(job):
        fired.append(job.name)

    scheduler = Scheduler(on_fire=on_fire)
    job = _make_job(trigger_type="every", schedule="5s")  # Long interval

    await scheduler.start([job])
    assert scheduler.scheduled_count == 1

    scheduler.remove_job("test-job")
    assert scheduler.scheduled_count == 0

    await asyncio.sleep(0.5)
    await scheduler.stop()

    assert len(fired) == 0


async def test_stop_cancels_all():
    fired = []

    async def on_fire(job):
        fired.append(job.name)

    scheduler = Scheduler(on_fire=on_fire)
    jobs = [_make_job(name=f"job-{i:02d}", trigger_type="every", schedule="5s") for i in range(3)]

    await scheduler.start(jobs)
    assert scheduler.scheduled_count == 3

    await scheduler.stop()
    assert scheduler.scheduled_count == 0

    await asyncio.sleep(0.5)
    assert len(fired) == 0


async def test_catchup_past_due():
    fired = []

    async def on_fire(job):
        fired.append(job.name)

    scheduler = Scheduler(on_fire=on_fire)
    job = _make_job(trigger_type="every", schedule="1h")
    # Last run was 2 hours ago — past due, should catch up immediately
    job.state = JobState(
        last_run_at=datetime.now(timezone.utc) - timedelta(hours=2),
        run_count=1,
    )

    await scheduler.start([job])
    await asyncio.sleep(0.5)
    await scheduler.stop()

    assert len(fired) >= 1


# --- Retry ---


async def test_retry_on_transient_failure():
    """on_fire fails twice then succeeds — 3 calls total, correct backoff."""
    call_count = 0
    done = asyncio.Event()
    backoff_delays = []

    async def on_fire(job):
        nonlocal call_count
        call_count += 1
        if call_count <= 2:
            raise RuntimeError("Request timed out.")
        done.set()

    _real_sleep = asyncio.sleep

    async def _fast_sleep(delay):
        if delay >= 30:
            backoff_delays.append(delay)
        await _real_sleep(0)

    scheduler = Scheduler(on_fire=on_fire)
    job = _make_job(trigger_type="every", schedule="10s")

    with patch.object(asyncio, "sleep", side_effect=_fast_sleep):
        await scheduler.start([job])
        await asyncio.wait_for(done.wait(), timeout=5.0)
        await scheduler.stop()

    assert call_count == 3
    assert backoff_delays == [30, 60]


async def test_retry_exhausted():
    """on_fire fails all 3 attempts — no crash, loop continues."""
    call_count = 0
    done = asyncio.Event()

    async def on_fire(job):
        nonlocal call_count
        call_count += 1
        if call_count >= 3:
            done.set()
        raise RuntimeError("API unavailable")

    _real_sleep = asyncio.sleep

    async def _fast_sleep(delay):
        await _real_sleep(0)

    scheduler = Scheduler(on_fire=on_fire)
    job = _make_job(trigger_type="every", schedule="10s")

    with patch.object(asyncio, "sleep", side_effect=_fast_sleep):
        await scheduler.start([job])
        await asyncio.wait_for(done.wait(), timeout=5.0)
        await scheduler.stop()

    assert call_count == 3


async def test_startup_retry_on_error_status():
    """Job with last_status='error' fires immediately on daemon start."""
    fired_at = []

    async def on_fire(job):
        fired_at.append(datetime.now(timezone.utc))

    scheduler = Scheduler(on_fire=on_fire)
    job = _make_job(trigger_type="every", schedule="1h")
    job.state = JobState(
        last_run_at=datetime.now(timezone.utc) - timedelta(minutes=5),
        last_status="error",
        run_count=3,
    )

    start = datetime.now(timezone.utc)
    await scheduler.start([job])
    await asyncio.sleep(0.5)
    await scheduler.stop()

    assert len(fired_at) >= 1
    # Should have fired within 0.5s, not waited for the 55min remaining
    assert (fired_at[0] - start).total_seconds() < 1.0
