from datetime import UTC, datetime, timedelta

import pytest

from dobby.core.queue import QueueManager
from dobby.models import (
    Priority,
    Project,
    ProjectPolicies,
    Schedule,
    ScheduleType,
    Task,
    TaskStatus,
)
from dobby.store.sqlite import SqliteStore


@pytest.fixture
async def queue(tmp_path):
    store = SqliteStore(str(tmp_path / "test.db"))
    await store.initialize()
    qm = QueueManager(store)
    yield qm
    await store.close()


async def test_next_task_returns_highest_priority(queue):
    await queue.store.create_task(Task(project="a", description="low", priority=Priority.LOW))
    await queue.store.create_task(Task(project="b", description="urgent", priority=Priority.URGENT))
    task = await queue.next_task()
    assert task.priority == Priority.URGENT


async def test_next_task_respects_project_lock(queue):
    await queue.store.create_task(
        Task(project="spare", description="first", priority=Priority.HIGH)
    )
    await queue.store.create_task(
        Task(project="spare", description="second", priority=Priority.NORMAL)
    )
    await queue.store.create_task(
        Task(project="memagent", description="third", priority=Priority.LOW)
    )
    queue.lock_project("spare")
    task = await queue.next_task()
    assert task.project == "memagent"


async def test_next_task_returns_none_when_empty(queue):
    task = await queue.next_task()
    assert task is None


async def test_next_task_returns_none_when_all_locked(queue):
    await queue.store.create_task(Task(project="spare", description="task"))
    queue.lock_project("spare")
    task = await queue.next_task()
    assert task is None


async def test_lock_unlock_project(queue):
    queue.lock_project("spare")
    assert queue.is_locked("spare")
    queue.unlock_project("spare")
    assert not queue.is_locked("spare")


async def test_cron_task_is_due(queue):
    task = Task(
        project="spare",
        description="Hourly",
        schedule=Schedule(type=ScheduleType.CRON, expr="0 * * * *"),
    )
    await queue.store.create_task(task)
    due = await queue.get_due_scheduled_tasks()
    assert isinstance(due, list)


async def test_interval_task_is_due(queue):
    task = Task(
        project="spare",
        description="Every 30m",
        schedule=Schedule(type=ScheduleType.INTERVAL, every="30m"),
    )
    await queue.store.create_task(task)
    due = await queue.get_due_scheduled_tasks()
    assert len(due) >= 1


async def test_interval_task_not_due_after_recent_run(queue):
    task = Task(
        project="spare",
        description="Every 30m",
        schedule=Schedule(type=ScheduleType.INTERVAL, every="30m"),
    )
    tid = await queue.store.create_task(task)
    recent = datetime.now(UTC) - timedelta(minutes=5)
    await queue.store.update_task(tid, last_run_at=recent.isoformat())
    due = await queue.get_due_scheduled_tasks()
    assert due == []


async def test_next_dispatchable_task_includes_due_scheduled(queue):
    await queue.store.create_task(
        Task(project="a", description="pending low", priority=Priority.LOW)
    )
    scheduled = Task(
        project="b",
        description="scheduled urgent",
        priority=Priority.URGENT,
        schedule=Schedule(type=ScheduleType.INTERVAL, every="30m"),
    )
    await queue.store.create_task(scheduled)
    task = await queue.next_dispatchable_task()
    assert task.description == "scheduled urgent"


async def test_after_task_becomes_pending_when_dep_completes(queue):
    dep = Task(project="spare", description="training")
    dep_id = await queue.store.create_task(dep)
    follower = Task(
        project="spare",
        description="eval after training",
        schedule=Schedule(type=ScheduleType.AFTER, depends_on=dep_id),
    )
    fid = await queue.store.create_task(follower)
    await queue.check_dependencies()
    f = await queue.store.get_task(fid)
    assert f.status != TaskStatus.PENDING
    # Must go through valid transitions: pending -> running -> completed
    await queue.store.update_task(dep_id, status=TaskStatus.RUNNING.value)
    await queue.store.update_task(dep_id, status=TaskStatus.COMPLETED.value)
    await queue.check_dependencies()
    f = await queue.store.get_task(fid)
    assert f.status == TaskStatus.PENDING


# ---------------------------------------------------------------------------
# compute_next_run
# ---------------------------------------------------------------------------

async def test_compute_next_run_cron(queue):
    task = Task(
        project="spare",
        description="Hourly",
        schedule=Schedule(type=ScheduleType.CRON, expr="0 * * * *"),
    )
    next_run = queue.compute_next_run(task)
    assert next_run is not None
    assert next_run > task.created_at


async def test_compute_next_run_interval_no_last_run(queue):
    task = Task(
        project="spare",
        description="Every 30m",
        schedule=Schedule(type=ScheduleType.INTERVAL, every="30m"),
    )
    # No last_run_at — returns None (due immediately)
    next_run = queue.compute_next_run(task)
    assert next_run is None


async def test_compute_next_run_interval_with_last_run(queue):
    task = Task(
        project="spare",
        description="Every 30m",
        schedule=Schedule(type=ScheduleType.INTERVAL, every="30m"),
    )
    now = datetime.now(UTC)
    task.last_run_at = now
    next_run = queue.compute_next_run(task)
    assert next_run is not None
    assert (next_run - now).total_seconds() == pytest.approx(1800, abs=1)


async def test_compute_next_run_once_returns_none(queue):
    task = Task(project="spare", description="One-off")
    assert queue.compute_next_run(task) is None


async def test_due_scheduled_tasks_persists_next_run_at(queue):
    """get_due_scheduled_tasks should persist next_run_at for tasks that don't have it."""
    task = Task(
        project="spare",
        description="Hourly cron",
        schedule=Schedule(type=ScheduleType.CRON, expr="0 * * * *"),
    )
    tid = await queue.store.create_task(task)
    # Initially no next_run_at
    t = await queue.store.get_task(tid)
    assert t.next_run_at is None
    # After checking due tasks, next_run_at should be set
    await queue.get_due_scheduled_tasks()
    t = await queue.store.get_task(tid)
    assert t.next_run_at is not None


# ---------------------------------------------------------------------------
# Cross-project dependency resolution
# ---------------------------------------------------------------------------

async def test_cross_project_dependency(queue):
    """A task in project B can depend on a task in project A."""
    dep = Task(project="project-a", description="training run")
    dep_id = await queue.store.create_task(dep)
    follower = Task(
        project="project-b",
        description="eval after training",
        schedule=Schedule(type=ScheduleType.AFTER, depends_on=dep_id),
    )
    fid = await queue.store.create_task(follower)
    # Before dep completes, follower stays ACTIVE
    await queue.check_dependencies()
    f = await queue.store.get_task(fid)
    assert f.status == TaskStatus.ACTIVE
    # Complete the dep (valid transitions)
    await queue.store.update_task(dep_id, status=TaskStatus.RUNNING.value)
    await queue.store.update_task(dep_id, status=TaskStatus.COMPLETED.value)
    await queue.check_dependencies()
    f = await queue.store.get_task(fid)
    assert f.status == TaskStatus.PENDING


async def test_failed_dep_holds_downstream(queue):
    """When a dependency fails, downstream tasks stay ACTIVE (held, not auto-failed)."""
    dep = Task(project="spare", description="build")
    dep_id = await queue.store.create_task(dep)
    follower = Task(
        project="spare",
        description="deploy after build",
        schedule=Schedule(type=ScheduleType.AFTER, depends_on=dep_id),
    )
    fid = await queue.store.create_task(follower)
    # Fail the dep
    await queue.store.update_task(dep_id, status=TaskStatus.RUNNING.value)
    await queue.store.update_task(dep_id, status=TaskStatus.FAILED.value)
    await queue.check_dependencies()
    f = await queue.store.get_task(fid)
    assert f.status == TaskStatus.ACTIVE  # held, not failed


# ---------------------------------------------------------------------------
# max_concurrent via project policies
# ---------------------------------------------------------------------------

async def test_max_concurrent_allows_multiple(queue):
    """When max_concurrent > 1, multiple tasks can be dispatched."""
    await queue.store.register_project(
        Project(
            name="multi",
            repo="/path/multi",
            policies=ProjectPolicies(max_concurrent=2),
        )
    )
    await queue.store.create_task(Task(project="multi", description="A"))
    await queue.store.create_task(Task(project="multi", description="B"))
    # Lock once (simulate one running task)
    queue.lock_project("multi")
    # Should still allow another task (max_concurrent=2)
    task = await queue.next_task()
    assert task is not None
    assert task.project == "multi"
    # Lock again (2 running)
    queue.lock_project("multi")
    task = await queue.next_task()
    assert task is None  # at capacity


async def test_running_count_tracking(queue):
    queue.lock_project("spare")
    assert queue.running_count("spare") == 1
    queue.lock_project("spare")
    assert queue.running_count("spare") == 2
    queue.unlock_project("spare")
    assert queue.running_count("spare") == 1
    assert queue.is_locked("spare")  # still locked
    queue.unlock_project("spare")
    assert queue.running_count("spare") == 0
    assert not queue.is_locked("spare")
