import json
from unittest.mock import patch

import pytest

import dobby.core.api as api_module
from dobby.core.api import EventBus, create_app
from dobby.models import Artifact, Result, TaskStatus
from dobby.store.sqlite import SqliteStore


@pytest.fixture
async def client(tmp_path, aiohttp_client):
    store = SqliteStore(str(tmp_path / "test.db"))
    await store.initialize()
    results_dir = str(tmp_path / "results")
    app = create_app(store, results_dir=results_dir)
    client = await aiohttp_client(app)
    yield client
    await store.close()


@pytest.fixture
async def auth_client(tmp_path, aiohttp_client):
    """Client with API key auth enabled."""
    store = SqliteStore(str(tmp_path / "auth_test.db"))
    await store.initialize()
    results_dir = str(tmp_path / "results")
    app = create_app(store, results_dir=results_dir, api_key="test-secret-key")
    client = await aiohttp_client(app)
    yield client
    await store.close()


@pytest.fixture
async def sse_client(tmp_path, aiohttp_client):
    """Client with event bus for SSE testing."""
    store = SqliteStore(str(tmp_path / "sse_test.db"))
    await store.initialize()
    results_dir = str(tmp_path / "results")
    event_bus = EventBus()
    app = create_app(store, results_dir=results_dir, event_bus=event_bus)
    client = await aiohttp_client(app)
    yield client, event_bus
    await store.close()


async def test_health(client):
    resp = await client.get("/health")
    assert resp.status == 200
    data = await resp.json()
    assert data["status"] == "ok"


async def test_create_task(client):
    resp = await client.post("/tasks", json={
        "project": "spare",
        "description": "Test task",
        "exit_criteria": "PR ready",
    })
    assert resp.status == 201
    data = await resp.json()
    assert data["id"].startswith("t-")
    assert data["status"] == "pending"


async def test_list_tasks(client):
    await client.post("/tasks", json={"project": "spare", "description": "A"})
    await client.post("/tasks", json={"project": "memagent", "description": "B"})
    resp = await client.get("/tasks")
    assert resp.status == 200
    data = await resp.json()
    assert data["total"] == 2
    assert len(data["items"]) == 2


async def test_list_tasks_filter_project(client):
    await client.post("/tasks", json={"project": "spare", "description": "A"})
    await client.post("/tasks", json={"project": "memagent", "description": "B"})
    resp = await client.get("/tasks?project=spare")
    data = await resp.json()
    assert data["total"] == 1
    assert data["items"][0]["project"] == "spare"


async def test_get_task(client):
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]
    resp = await client.get(f"/tasks/{task_id}")
    assert resp.status == 200
    data = await resp.json()
    assert data["project"] == "spare"


async def test_get_task_not_found(client):
    resp = await client.get("/tasks/t-nonexistent")
    assert resp.status == 404


async def test_patch_task(client):
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]
    resp = await client.patch(f"/tasks/{task_id}", json={"priority": "high"})
    assert resp.status == 200
    data = await resp.json()
    assert data["priority"] == "high"


async def test_cancel_task(client):
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]
    resp = await client.patch(f"/tasks/{task_id}", json={"status": "cancelled"})
    assert resp.status == 200


async def test_retry_task(client):
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]
    # Must go through RUNNING -> FAILED before retry
    await client.patch(f"/tasks/{task_id}", json={"status": "running"})
    await client.patch(f"/tasks/{task_id}", json={"status": "failed"})
    resp = await client.post(f"/tasks/{task_id}/retry")
    assert resp.status == 201
    data = await resp.json()
    assert data["status"] == "pending"


async def test_register_project(client):
    resp = await client.post("/projects", json={
        "name": "spare",
        "repo": "/path/to/spare",
        "context": "RL framework",
    })
    assert resp.status == 201


async def test_list_projects(client):
    await client.post("/projects", json={"name": "a", "repo": "/a"})
    await client.post("/projects", json={"name": "b", "repo": "/b"})
    resp = await client.get("/projects")
    data = await resp.json()
    assert len(data) == 2


async def test_get_project(client):
    await client.post("/projects", json={"name": "spare", "repo": "/path"})
    resp = await client.get("/projects/spare")
    assert resp.status == 200
    data = await resp.json()
    assert data["name"] == "spare"


async def test_get_project_not_found(client):
    resp = await client.get("/projects/nope")
    assert resp.status == 404


async def test_get_task_events(client):
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]
    # Trigger a transition to generate events
    await client.patch(f"/tasks/{task_id}", json={"status": "running"})
    resp = await client.get(f"/tasks/{task_id}/events")
    assert resp.status == 200
    events = await resp.json()
    assert len(events) >= 2  # created + running
    assert events[0]["detail"] == "created"
    assert events[1]["status"] == "running"


async def test_get_task_events_not_found(client):
    resp = await client.get("/tasks/t-nonexistent/events")
    assert resp.status == 404


async def test_patch_task_invalid_transition(client):
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]
    # PENDING -> COMPLETED is illegal
    resp = await client.patch(f"/tasks/{task_id}", json={"status": "completed"})
    assert resp.status == 409


async def test_stats(client):
    await client.post("/tasks", json={"project": "spare", "description": "A"})
    task_resp = await client.post("/tasks", json={"project": "spare", "description": "B"})
    task_id = (await task_resp.json())["id"]
    await client.app["store"].store_result(
        Result(
            task_id=task_id,
            status="completed",
            summary="Done",
            cost={"input_tokens": 10, "output_tokens": 5, "total_tokens": 15, "cost_usd": 0.42},
        )
    )
    resp = await client.get("/stats")
    assert resp.status == 200
    data = await resp.json()
    assert "pending" in data
    assert "running" in data
    assert data["total_cost_usd"] == 0.42
    assert data["total_tokens"] == 15


# ---------------------------------------------------------------------------
# Pagination and filtering
# ---------------------------------------------------------------------------

async def test_list_tasks_pagination(client):
    for i in range(5):
        await client.post("/tasks", json={"project": "spare", "description": f"Task {i}"})
    resp = await client.get("/tasks?limit=2&offset=1")
    data = await resp.json()
    assert data["total"] == 5
    assert len(data["items"]) == 2
    assert data["offset"] == 1
    assert data["limit"] == 2


async def test_list_tasks_search(client):
    await client.post("/tasks", json={"project": "spare", "description": "Add warmup scheduler"})
    await client.post("/tasks", json={"project": "spare", "description": "Fix broken tests"})
    resp = await client.get("/tasks?search=warmup")
    data = await resp.json()
    assert data["total"] == 1
    assert "warmup" in data["items"][0]["description"]


async def test_list_results_pagination(client):
    for i in range(4):
        task_resp = await client.post("/tasks", json={"project": "spare", "description": f"T{i}"})
        task_id = (await task_resp.json())["id"]
        await client.app["store"].store_result(
            Result(task_id=task_id, status="completed", summary=f"Done {i}")
        )
    resp = await client.get("/results?limit=2&offset=0")
    data = await resp.json()
    assert data["total"] == 4
    assert len(data["items"]) == 2


async def test_list_results_search(client):
    task_resp = await client.post("/tasks", json={"project": "spare", "description": "T1"})
    tid = (await task_resp.json())["id"]
    await client.app["store"].store_result(
        Result(task_id=tid, status="completed", summary="Fixed the authentication bug")
    )
    await client.app["store"].store_result(
        Result(task_id=tid, status="completed", summary="Added new feature")
    )
    resp = await client.get("/results?search=authentication")
    data = await resp.json()
    assert data["total"] == 1
    assert "authentication" in data["items"][0]["summary"]


# ---------------------------------------------------------------------------
# Artifacts endpoint
# ---------------------------------------------------------------------------

async def test_get_task_artifacts(client):
    task_resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await task_resp.json())["id"]
    await client.app["store"].store_result(
        Result(
            task_id=task_id,
            status="completed",
            summary="Done",
            artifacts=[
                Artifact(type="pr", url="https://github.com/user/repo/pull/1"),
                Artifact(type="log", path="/tmp/agent.log"),
            ],
        )
    )
    resp = await client.get(f"/tasks/{task_id}/artifacts")
    assert resp.status == 200
    data = await resp.json()
    assert len(data) == 2
    assert data[0]["type"] == "pr"
    assert data[1]["type"] == "log"


async def test_get_task_artifacts_not_found(client):
    resp = await client.get("/tasks/t-nonexistent/artifacts")
    assert resp.status == 404


# ---------------------------------------------------------------------------
# Failure tracking in API
# ---------------------------------------------------------------------------

async def test_task_includes_failure_fields(client):
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    data = await resp.json()
    assert data["max_failures"] == 3
    assert data["consecutive_failures"] == 0


async def test_retry_resets_consecutive_failures(client):
    """Retry should reset consecutive_failures to 0."""
    resp = await client.post("/tasks", json={
        "project": "spare",
        "description": "Recurring test",
        "schedule": {"type": "interval", "every": "30m"},
    })
    task_id = (await resp.json())["id"]
    # Simulate: active -> running -> active (with failures)
    await client.patch(f"/tasks/{task_id}", json={"status": "running"})
    await client.patch(f"/tasks/{task_id}", json={
        "status": "active",
        "consecutive_failures": 2,
    })
    # Now retry
    resp = await client.post(f"/tasks/{task_id}/retry")
    assert resp.status == 201
    data = await resp.json()
    assert data["consecutive_failures"] == 0


# ---------------------------------------------------------------------------
# Detail-status endpoint
# ---------------------------------------------------------------------------

async def test_detail_status_no_log(client):
    """Detail-status for a task with no log file returns empty but valid response."""
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]
    resp = await client.get(f"/tasks/{task_id}/detail-status")
    assert resp.status == 200
    data = await resp.json()
    assert data["task_id"] == task_id
    assert data["status"] == "pending"
    assert data["files_touched"] == []
    assert data["tools_used"] == {}


async def test_detail_status_with_log(client):
    """Detail-status parses agent log and returns structured data."""
    resp = await client.post("/tasks", json={"project": "spare", "description": "Test"})
    task_id = (await resp.json())["id"]

    # Create a fake agent log
    import os
    results_dir = client.app.get("results_dir", "results")
    log_dir = os.path.join(results_dir, task_id)
    os.makedirs(log_dir, exist_ok=True)
    with open(os.path.join(log_dir, "agent.log"), "w") as f:
        f.write("Using Read tool on src/main.py\n")
        f.write("Using Edit tool on src/main.py\n")
        f.write("Using Bash tool to run tests\n")

    resp = await client.get(f"/tasks/{task_id}/detail-status")
    assert resp.status == 200
    data = await resp.json()
    assert data["task_id"] == task_id
    assert "Read" in data["tools_used"]
    assert "Edit" in data["tools_used"]
    assert "Bash" in data["tools_used"]
    assert any("main.py" in f for f in data["files_touched"])


async def test_detail_status_not_found(client):
    resp = await client.get("/tasks/t-nonexistent/detail-status")
    assert resp.status == 404


# ---------------------------------------------------------------------------
# API Auth tests
# ---------------------------------------------------------------------------

async def test_auth_required_without_token(auth_client):
    """Requests without Authorization header should be rejected."""
    resp = await auth_client.get("/tasks")
    assert resp.status == 401


async def test_auth_wrong_token(auth_client):
    """Requests with wrong token should be rejected."""
    resp = await auth_client.get("/tasks", headers={"Authorization": "Bearer wrong-key"})
    assert resp.status == 401


async def test_auth_correct_token(auth_client):
    """Requests with correct token should succeed."""
    resp = await auth_client.get("/tasks", headers={"Authorization": "Bearer test-secret-key"})
    assert resp.status == 200


async def test_auth_health_exempt(auth_client):
    """Health endpoint should be exempt from auth."""
    resp = await auth_client.get("/health")
    assert resp.status == 200
    data = await resp.json()
    assert data["status"] == "ok"


async def test_auth_post_with_token(auth_client):
    """POST requests with correct token should work."""
    resp = await auth_client.post(
        "/tasks",
        json={"project": "spare", "description": "Auth test"},
        headers={"Authorization": "Bearer test-secret-key"},
    )
    assert resp.status == 201


async def test_no_auth_when_not_configured(client):
    """When no api_key configured, all requests should pass without auth."""
    resp = await client.get("/tasks")
    assert resp.status == 200


class _MockAiohttpResponse:
    def __init__(self, status, payload):
        self.status = status
        self._payload = payload

    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc, tb):
        return False

    async def json(self):
        return self._payload

    async def text(self):
        return json.dumps(self._payload)


class _MockAiohttpSession:
    def __init__(self, responses):
        self._responses = list(responses)

    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc, tb):
        return False

    def get(self, *args, **kwargs):
        assert self._responses, "Unexpected extra HTTP request"
        return self._responses.pop(0)


def _write_codex_rate_limit_event(
    root, *, timestamp="2026-03-22T18:59:53.234Z", secondary=None, credits=None
):
    payload = {
        "timestamp": timestamp,
        "type": "event_msg",
        "payload": {
            "type": "token_count",
            "info": {
                "total_token_usage": {"input_tokens": 100, "output_tokens": 20, "total_tokens": 120},
                "last_token_usage": {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12},
                "model_context_window": 258400,
            },
            "rate_limits": {
                "limit_id": "codex",
                "limit_name": None,
                "primary": {
                    "used_percent": 100.0,
                    "window_minutes": 10080,
                    "resets_at": 1774652040,
                },
                "secondary": secondary,
                "credits": (
                    {"has_credits": True, "unlimited": False, "balance": None}
                    if credits is None else credits
                ),
                "plan_type": "free",
            },
        },
    }
    path = root / "2026" / "03" / "22" / "rollout-test.jsonl"
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload) + "\n", encoding="utf-8")
    return path


async def test_usage_endpoint_combines_provider_status(client, monkeypatch, tmp_path):
    monkeypatch.setattr(api_module, "_read_keychain_token", lambda: None)
    monkeypatch.setattr(api_module, "_load_dotenv_values", lambda: {})
    monkeypatch.setattr(api_module, "_codex_sessions_root", lambda: tmp_path / "empty-codex-sessions")
    monkeypatch.delenv("OPENAI_ADMIN_KEY", raising=False)
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    api_module._oauth_cache = None
    api_module._openai_usage_cache = None
    api_module._dotenv_cache = None

    resp = await client.get("/usage")
    assert resp.status == 200
    data = await resp.json()
    assert data["claude"]["available"] is False
    assert data["codex"]["configuration"]["admin_key_configured"] is False
    assert data["codex"]["local_plan"]["available"] is False
    assert "OPENAI_ADMIN_KEY" in data["codex"]["requirements"]["required"]


async def test_usage_endpoint_reports_local_codex_plan(client, monkeypatch, tmp_path):
    sessions_root = tmp_path / "codex-sessions"
    _write_codex_rate_limit_event(sessions_root)
    monkeypatch.setattr(api_module, "_read_keychain_token", lambda: None)
    monkeypatch.setattr(api_module, "_load_dotenv_values", lambda: {})
    monkeypatch.setattr(api_module, "_codex_sessions_root", lambda: sessions_root)
    monkeypatch.delenv("OPENAI_ADMIN_KEY", raising=False)
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    api_module._oauth_cache = None
    api_module._openai_usage_cache = None
    api_module._dotenv_cache = None

    resp = await client.get("/usage")
    assert resp.status == 200
    data = await resp.json()
    codex = data["codex"]
    assert codex["local_plan"]["available"] is True
    assert codex["local_plan"]["weekly_limit"]["remaining_percent"] == pytest.approx(0.0)
    assert codex["local_plan"]["weekly_limit"]["resets_at"] == "2026-03-27T22:54:00Z"
    assert codex["local_plan"]["credits"]["has_credits"] is True
    assert codex["local_plan"]["credits"]["available"] is False
    assert codex["error"] is None


async def test_usage_endpoint_reports_codex_api_usage(client, monkeypatch, tmp_path):
    monkeypatch.setattr(api_module, "_read_keychain_token", lambda: None)
    monkeypatch.setattr(api_module, "_load_dotenv_values", lambda: {})
    monkeypatch.setattr(api_module, "_codex_sessions_root", lambda: tmp_path / "empty-codex-sessions")
    monkeypatch.setenv("OPENAI_ADMIN_KEY", "sk-admin-test")
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    api_module._oauth_cache = None
    api_module._openai_usage_cache = None
    api_module._dotenv_cache = None

    usage_payload = {
        "data": [
            {
                "results": [
                    {
                        "model": "gpt-5.1-codex-max",
                        "num_model_requests": 12,
                        "input_tokens": 1000,
                        "input_cached_tokens": 200,
                        "output_tokens": 400,
                    },
                    {
                        "model": "gpt-4o-mini",
                        "num_model_requests": 99,
                        "input_tokens": 9999,
                        "input_cached_tokens": 0,
                        "output_tokens": 999,
                    },
                ]
            }
        ]
    }
    cost_payload = {
        "data": [
            {
                "results": [
                    {
                        "line_item": "gpt-5.1-codex-max, input",
                        "amount": {"value": 1.25, "currency": "usd"},
                    },
                    {
                        "line_item": "gpt-5.1-codex-max, output",
                        "amount": {"value": 0.75, "currency": "usd"},
                    },
                    {
                        "line_item": "gpt-4o-mini, input",
                        "amount": {"value": 9.99, "currency": "usd"},
                    },
                ]
            }
        ]
    }

    mock_session = _MockAiohttpSession([
        _MockAiohttpResponse(200, usage_payload),
        _MockAiohttpResponse(200, cost_payload),
    ])
    with patch("aiohttp.ClientSession", return_value=mock_session):
        resp = await client.get("/usage")

    assert resp.status == 200
    data = await resp.json()
    codex = data["codex"]
    assert codex["api_usage"]["available"] is True
    assert codex["api_usage"]["requests"] == 12
    assert codex["api_usage"]["input_tokens"] == 1200
    assert codex["api_usage"]["output_tokens"] == 400
    assert codex["api_usage"]["cost_usd"] == pytest.approx(2.0)
    assert codex["api_usage"]["models"][0]["model"] == "gpt-5.1-codex-max"


async def test_auth_usage_exempt(auth_client, monkeypatch, tmp_path):
    """Combined usage endpoint should be exempt from API auth."""
    monkeypatch.setattr(api_module, "_load_dotenv_values", lambda: {})
    monkeypatch.setattr(api_module, "_read_keychain_token", lambda: None)
    monkeypatch.setattr(api_module, "_codex_sessions_root", lambda: tmp_path / "empty-codex-sessions")
    api_module._oauth_cache = None
    api_module._openai_usage_cache = None
    resp = await auth_client.get("/usage")
    assert resp.status == 200


# ---------------------------------------------------------------------------
# SSE endpoint tests
# ---------------------------------------------------------------------------

async def test_sse_endpoint_streams_events(sse_client):
    """SSE endpoint should stream events published to the event bus."""
    client, event_bus = sse_client

    # Start SSE connection
    resp = await client.get("/events")
    assert resp.status == 200
    assert resp.headers["Content-Type"] == "text/event-stream"

    # Publish an event
    await event_bus.publish("task_status", {"task_id": "t-123", "status": "completed"})

    # Read the event from the stream
    data = await resp.content.readline()  # "event: task_status\n"
    assert b"event: task_status" in data
    data = await resp.content.readline()  # "data: {...}\n"
    assert b"t-123" in data


async def test_sse_not_available_without_bus(tmp_path, aiohttp_client):
    """SSE endpoint should return 503 when event bus is not configured."""
    store = SqliteStore(str(tmp_path / "no_bus.db"))
    await store.initialize()
    app = create_app(store, results_dir=str(tmp_path / "results"))
    client = await aiohttp_client(app)
    resp = await client.get("/events")
    assert resp.status == 503
    await store.close()


# ---------------------------------------------------------------------------
# EventBus unit tests
# ---------------------------------------------------------------------------

async def test_event_bus_pubsub():
    """EventBus should deliver events to subscribers."""
    bus = EventBus()
    q = bus.subscribe()

    await bus.publish("test_event", {"key": "value"})
    msg = q.get_nowait()
    assert msg["event"] == "test_event"
    assert msg["data"]["key"] == "value"

    bus.unsubscribe(q)
    await bus.publish("test_event", {"key": "value2"})
    assert q.empty()


async def test_event_bus_multiple_subscribers():
    """Multiple subscribers should all receive the same event."""
    bus = EventBus()
    q1 = bus.subscribe()
    q2 = bus.subscribe()

    await bus.publish("update", {"task_id": "t-1"})

    msg1 = q1.get_nowait()
    msg2 = q2.get_nowait()
    assert msg1 == msg2
    assert msg1["data"]["task_id"] == "t-1"

    bus.unsubscribe(q1)
    bus.unsubscribe(q2)


# ---------------------------------------------------------------------------
# Project policies
# ---------------------------------------------------------------------------

async def test_project_policies_merge_on_task_create(client):
    """Project default_backend and default_priority should be merged into tasks."""
    await client.post("/projects", json={
        "name": "policed",
        "repo": "/path/policed",
        "policies": {
            "default_backend": "codex",
            "default_priority": "high",
            "default_timeout": 7200,
        },
    })
    resp = await client.post("/tasks", json={
        "project": "policed",
        "description": "Test policy merge",
    })
    assert resp.status == 201
    data = await resp.json()
    assert data["backend"] == "codex"
    assert data["priority"] == "high"
    assert data["timeout"] == 7200


async def test_project_policies_task_overrides(client):
    """Explicit task values should override project defaults."""
    await client.post("/projects", json={
        "name": "policed2",
        "repo": "/path/policed2",
        "policies": {
            "default_backend": "codex",
            "default_priority": "high",
        },
    })
    resp = await client.post("/tasks", json={
        "project": "policed2",
        "description": "Override test",
        "backend": "claude",
        "priority": "low",
    })
    assert resp.status == 201
    data = await resp.json()
    assert data["backend"] == "claude"
    assert data["priority"] == "low"


async def test_register_project_with_policies(client):
    resp = await client.post("/projects", json={
        "name": "withpol",
        "repo": "/path",
        "policies": {
            "auto_chain": True,
            "max_concurrent": 3,
        },
    })
    assert resp.status == 201
    data = await resp.json()
    assert data["policies"]["auto_chain"] is True
    assert data["policies"]["max_concurrent"] == 3


# ---------------------------------------------------------------------------
# Auto-chaining
# ---------------------------------------------------------------------------

async def test_auto_chain_creates_dependency(client):
    """When auto_chain is enabled, new tasks depend on the latest pending/running task."""
    await client.post("/projects", json={
        "name": "autochain",
        "repo": "/path/autochain",
        "policies": {"auto_chain": True},
    })
    resp1 = await client.post("/tasks", json={
        "project": "autochain",
        "description": "First task",
    })
    first_id = (await resp1.json())["id"]

    resp2 = await client.post("/tasks", json={
        "project": "autochain",
        "description": "Second task (should auto-chain)",
    })
    second = await resp2.json()
    assert second["schedule"]["type"] == "after"
    assert second["schedule"]["depends_on"] == first_id
    assert second["status"] == "active"  # AFTER tasks start as ACTIVE


async def test_auto_chain_skipped_with_explicit_schedule(client):
    """If a task has an explicit schedule, auto_chain should not override it."""
    await client.post("/projects", json={
        "name": "autochain2",
        "repo": "/path/autochain2",
        "policies": {"auto_chain": True},
    })
    await client.post("/tasks", json={
        "project": "autochain2",
        "description": "First",
    })
    resp = await client.post("/tasks", json={
        "project": "autochain2",
        "description": "Cron task",
        "schedule": {"type": "cron", "expr": "0 3 * * *"},
    })
    data = await resp.json()
    assert data["schedule"]["type"] == "cron"


# ---------------------------------------------------------------------------
# Cascade retry
# ---------------------------------------------------------------------------

async def test_cascade_retry(client):
    """Retry with cascade should also reset downstream dependents."""
    store = client.app["store"]
    # Create chain: t1 -> t2 -> t3
    resp1 = await client.post("/tasks", json={"project": "spare", "description": "Step 1"})
    t1_id = (await resp1.json())["id"]
    resp2 = await client.post("/tasks", json={
        "project": "spare",
        "description": "Step 2",
        "schedule": {"type": "after", "depends_on": t1_id},
    })
    t2_id = (await resp2.json())["id"]
    resp3 = await client.post("/tasks", json={
        "project": "spare",
        "description": "Step 3",
        "schedule": {"type": "after", "depends_on": t2_id},
    })
    t3_id = (await resp3.json())["id"]

    # Fail t1
    await client.patch(f"/tasks/{t1_id}", json={"status": "running"})
    await client.patch(f"/tasks/{t1_id}", json={"status": "failed"})

    # Cascade retry t1
    resp = await client.post(f"/tasks/{t1_id}/retry?cascade=true")
    assert resp.status == 201
    data = await resp.json()
    assert "retried" in data
    retried_ids = [t["id"] for t in data["retried"]]
    assert t1_id in retried_ids
    assert t2_id in retried_ids
    assert t3_id in retried_ids

    # Verify t1 is pending, t2 and t3 are active (waiting)
    t1 = await store.get_task(t1_id)
    assert t1.status == TaskStatus.PENDING
    t2 = await store.get_task(t2_id)
    assert t2.status == TaskStatus.ACTIVE
    t3 = await store.get_task(t3_id)
    assert t3.status == TaskStatus.ACTIVE


async def test_retry_without_cascade(client):
    """Regular retry should not affect dependents."""
    resp1 = await client.post("/tasks", json={"project": "spare", "description": "Parent"})
    t1_id = (await resp1.json())["id"]
    resp2 = await client.post("/tasks", json={
        "project": "spare",
        "description": "Child",
        "schedule": {"type": "after", "depends_on": t1_id},
    })
    await resp2.json()  # consume response

    await client.patch(f"/tasks/{t1_id}", json={"status": "running"})
    await client.patch(f"/tasks/{t1_id}", json={"status": "failed"})

    resp = await client.post(f"/tasks/{t1_id}/retry")
    assert resp.status == 201
    data = await resp.json()
    assert "retried" not in data  # no cascade
    assert data["status"] == "pending"
