"""Postgres CRUD for tasks — the ONLY module that writes task data to Postgres."""

from __future__ import annotations

import json
from datetime import datetime, timezone

import asyncpg

from loom.exceptions import ClaimConflictError, TaskNotFoundError, TaskStateError
from loom.graph.task import TASK_TYPE_MERGE, Task, TaskStatus
from loom.ids import task_id as gen_task_id


async def create_task(pool: asyncpg.Pool, task: Task) -> Task:
    """Insert a task and its dependency edges in a single transaction."""
    async with pool.acquire() as conn:
        async with conn.transaction():
            record = await conn.fetchrow(
                """
                INSERT INTO tasks (id, project_id, title, status, priority,
                    assignee, parent_id, context, output, done_when, verify_command)
                VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10, $11)
                RETURNING *
                """,
                task.id, task.project_id, task.title, task.status.value,
                task.priority.value, task.assignee, task.parent_id,
                json.dumps(task.context), json.dumps(task.output), task.done_when,
                task.verify_command,
            )
            if task.depends_on:
                await conn.executemany(
                    "INSERT INTO task_deps (task_id, depends_on) VALUES ($1, $2)",
                    [(task.id, dep) for dep in task.depends_on],
                )
    return Task.from_record(record, depends_on=task.depends_on)


async def get_task(pool: asyncpg.Pool, task_id: str) -> Task:
    """Fetch a task with its dependency list."""
    async with pool.acquire() as conn:
        record = await conn.fetchrow("SELECT * FROM tasks WHERE id = $1", task_id)
        if record is None:
            raise TaskNotFoundError(task_id)
        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )
    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps])


async def claim_task(
    pool: asyncpg.Pool, task_id: str, agent_id: str, ttl_seconds: int | None = None
) -> Task:
    """Atomically claim a pending task using SELECT FOR UPDATE SKIP LOCKED."""
    async with pool.acquire() as conn:
        async with conn.transaction():
            row = await conn.fetchrow(
                """
                SELECT * FROM tasks
                WHERE id = $1 AND status = 'pending'
                FOR UPDATE SKIP LOCKED
                """,
                task_id,
            )
            if row is None:
                # Check if the task exists at all
                exists = await conn.fetchval(
                    "SELECT status FROM tasks WHERE id = $1", task_id
                )
                if exists is None:
                    raise TaskNotFoundError(task_id)
                if exists == "epic":
                    raise TaskStateError(task_id, exists, "claimed — convert it first with loom_update(task_id, status='pending')")
                if exists == "idea":
                    raise TaskStateError(task_id, exists, "claimed — promote it first with promote_idea()")
                if exists != "pending":
                    raise TaskStateError(task_id, exists, "claimed")
                raise ClaimConflictError(task_id)

            record = await conn.fetchrow(
                """
                UPDATE tasks
                SET status = 'claimed', assignee = $1,
                    claimed_at = NOW(), updated_at = NOW(),
                    claim_expires_at = CASE WHEN $3::int IS NOT NULL
                        THEN NOW() + ($3::int || ' seconds')::interval
                        ELSE NULL END
                WHERE id = $2
                RETURNING *
                """,
                agent_id, task_id, ttl_seconds,
            )
        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )
    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps])


async def batch_claim(
    pool: asyncpg.Pool, task_ids: list[str], agent_id: str,
    ttl_seconds: int | None = None,
) -> tuple[list[Task], list[dict]]:
    """Claim multiple tasks atomically in a single transaction.

    Returns (claimed_tasks, failures) where failures is a list of
    {"task_id": str, "error": str} dicts for tasks that could not be claimed.
    """
    claimed: list[Task] = []
    failures: list[dict] = []
    async with pool.acquire() as conn:
        async with conn.transaction():
            for task_id in task_ids:
                row = await conn.fetchrow(
                    """
                    SELECT * FROM tasks
                    WHERE id = $1 AND status = 'pending'
                    FOR UPDATE SKIP LOCKED
                    """,
                    task_id,
                )
                if row is None:
                    status = await conn.fetchval(
                        "SELECT status FROM tasks WHERE id = $1", task_id
                    )
                    if status is None:
                        failures.append({"task_id": task_id, "error": f"Task not found: {task_id}"})
                    elif status != "pending":
                        failures.append({"task_id": task_id, "error": f"Cannot claim: status is {status}"})
                    else:
                        failures.append({"task_id": task_id, "error": "Claim conflict (locked by another)"})
                    continue
                record = await conn.fetchrow(
                    """
                    UPDATE tasks
                    SET status = 'claimed', assignee = $1,
                        claimed_at = NOW(), updated_at = NOW(),
                        claim_expires_at = CASE WHEN $3::int IS NOT NULL
                            THEN NOW() + ($3::int || ' seconds')::interval
                            ELSE NULL END
                    WHERE id = $2
                    RETURNING *
                    """,
                    agent_id, task_id, ttl_seconds,
                )
                deps = await conn.fetch(
                    "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
                )
                claimed.append(Task.from_record(record, depends_on=[r["depends_on"] for r in deps]))
    return claimed, failures


async def complete_task(
    pool: asyncpg.Pool,
    task_id: str,
    output: dict,
    branch_name: str | None = None,
) -> Task | tuple[Task, Task]:
    """Mark a task as done with output.

    When branch_name is provided, atomically creates a merge task in the same
    transaction. Returns (completed_task, merge_task) in that case.
    """
    async with pool.acquire() as conn:
        async with conn.transaction():
            current = await conn.fetchval(
                "SELECT status FROM tasks WHERE id = $1", task_id
            )
            if current is None:
                raise TaskNotFoundError(task_id)
            if current not in ("claimed", "pending"):
                raise TaskStateError(task_id, current, "done")

            record = await conn.fetchrow(
                """
                UPDATE tasks
                SET status = 'done', output = $1::jsonb,
                    done_at = NOW(), updated_at = NOW()
                WHERE id = $2
                RETURNING *
                """,
                json.dumps(output), task_id,
            )

            merge_record = None
            if branch_name:
                merge_record = await _create_merge_task_in_txn(
                    conn, record, branch_name,
                )

        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )

    completed = Task.from_record(record, depends_on=[r["depends_on"] for r in deps])
    if merge_record is not None:
        merge_task = Task.from_record(merge_record, depends_on=[])
        return completed, merge_task
    return completed


async def _create_merge_task_in_txn(
    conn: asyncpg.Connection,
    parent_record,
    branch_name: str,
) -> asyncpg.Record:
    """Create a merge task within an existing transaction.

    Uses the parent task's project_id and id to populate the merge task context.
    """
    merge_id = gen_task_id()
    project_id = str(parent_record["project_id"])
    parent_task_id = parent_record["id"]
    worktree_path = f".claude/worktrees/{branch_name.removeprefix('worktree-')}"
    context = {
        "branch_name": branch_name,
        "parent_task_id": parent_task_id,
        "worktree_path": worktree_path,
    }
    return await conn.fetchrow(
        """
        INSERT INTO tasks (id, project_id, title, status, priority,
            context, output, done_when)
        VALUES ($1, $2::uuid, $3, $4, $5, $6::jsonb, '{}'::jsonb, $7)
        RETURNING *
        """,
        merge_id,
        project_id,
        f"Merge branch {branch_name}",
        TaskStatus.PENDING.value,
        "p0",
        json.dumps(context),
        f"Branch {branch_name} merged to main and worktree cleaned up",
    )


async def batch_done(
    pool: asyncpg.Pool, task_ids: list[str], output: dict
) -> tuple[list[Task], list[dict]]:
    """Mark multiple tasks as done in a single transaction.

    Returns (done_tasks, failures) where failures is a list of
    {"task_id": str, "error": str} dicts for tasks that could not transition.
    """
    done_tasks: list[Task] = []
    failures: list[dict] = []
    async with pool.acquire() as conn:
        async with conn.transaction():
            for task_id in task_ids:
                current = await conn.fetchval(
                    "SELECT status FROM tasks WHERE id = $1", task_id
                )
                if current is None:
                    failures.append({"task_id": task_id, "error": f"Task not found: {task_id}"})
                    continue
                if current not in ("claimed", "pending"):
                    failures.append({
                        "task_id": task_id,
                        "error": f"Cannot transition from {current} to done",
                    })
                    continue
                record = await conn.fetchrow(
                    """
                    UPDATE tasks
                    SET status = 'done', output = $1::jsonb,
                        done_at = NOW(), updated_at = NOW()
                    WHERE id = $2
                    RETURNING *
                    """,
                    json.dumps(output), task_id,
                )
                deps = await conn.fetch(
                    "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
                )
                done_tasks.append(
                    Task.from_record(record, depends_on=[r["depends_on"] for r in deps])
                )
    return done_tasks, failures


async def fail_task(pool: asyncpg.Pool, task_id: str, reason: str) -> Task:
    """Mark a task as failed and create an escalation."""
    async with pool.acquire() as conn:
        async with conn.transaction():
            current = await conn.fetchrow(
                "SELECT status, project_id FROM tasks WHERE id = $1", task_id
            )
            if current is None:
                raise TaskNotFoundError(task_id)
            if current["status"] not in ("claimed", "pending"):
                raise TaskStateError(task_id, current["status"], "failed")

            record = await conn.fetchrow(
                """
                UPDATE tasks SET status = 'failed', updated_at = NOW()
                WHERE id = $1 RETURNING *
                """,
                task_id,
            )
            await conn.execute(
                """
                INSERT INTO escalations (project_id, task_id, message)
                VALUES ($1, $2, $3)
                """,
                current["project_id"], task_id, reason,
            )
        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )
    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps])


async def update_task(pool: asyncpg.Pool, task_id: str, **fields) -> Task:
    """Update mutable task fields (title, context, priority, done_when, depends_on, status).

    Status changes are restricted: only epic→pending is allowed.
    """
    new_deps = fields.pop("depends_on", None)
    new_status = fields.pop("status", None)

    # Validate status transition if requested
    if new_status is not None:
        current = await get_task(pool, task_id)
        if current.status != TaskStatus.EPIC or new_status != TaskStatus.PENDING:
            raise TaskStateError(task_id, current.status, new_status)

    allowed = {"title", "context", "priority", "done_when", "verify_command"}
    updates = {k: v for k, v in fields.items() if k in allowed and v is not None}
    if new_status is not None:
        updates["status"] = new_status

    if not updates and new_deps is None:
        return await get_task(pool, task_id)

    async with pool.acquire() as conn:
        async with conn.transaction():
            # Update scalar fields if any
            if updates:
                set_parts = []
                values = []
                for i, (key, val) in enumerate(updates.items(), start=1):
                    if key == "context":
                        set_parts.append(f"{key} = ${i}::jsonb")
                        values.append(json.dumps(val))
                    else:
                        set_parts.append(f"{key} = ${i}")
                        values.append(val)
                set_parts.append(f"updated_at = ${len(values) + 1}")
                values.append(datetime.now(timezone.utc))
                values.append(task_id)

                query = f"UPDATE tasks SET {', '.join(set_parts)} WHERE id = ${len(values)} RETURNING *"
                record = await conn.fetchrow(query, *values)
                if record is None:
                    raise TaskNotFoundError(task_id)
            else:
                # Touch updated_at even if only deps changed
                record = await conn.fetchrow(
                    "UPDATE tasks SET updated_at = $1 WHERE id = $2 RETURNING *",
                    datetime.now(timezone.utc), task_id,
                )
                if record is None:
                    raise TaskNotFoundError(task_id)

            # Update dependency edges if provided
            if new_deps is not None:
                # Cycle detection before committing
                from loom.graph.deps import detect_cycle
                if new_deps:
                    await detect_cycle(pool, task_id, new_deps)

                # Replace deps
                await conn.execute(
                    "DELETE FROM task_deps WHERE task_id = $1", task_id
                )
                if new_deps:
                    await conn.executemany(
                        "INSERT INTO task_deps (task_id, depends_on) VALUES ($1, $2)",
                        [(task_id, dep) for dep in new_deps],
                    )

                # Recompute status: blocked if any dep not done, pending otherwise
                # (only for tasks currently in pending/blocked state)
                current_status = record["status"]
                if current_status in ("pending", "blocked"):
                    from loom.graph.deps import compute_initial_status
                    new_status = await compute_initial_status(pool, new_deps)
                    if new_status.value != current_status:
                        record = await conn.fetchrow(
                            "UPDATE tasks SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *",
                            new_status.value, task_id,
                        )

            deps_rows = await conn.fetch(
                "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
            )

    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps_rows])


async def _batch_load_deps(pool: asyncpg.Pool, task_ids: list[str]) -> dict[str, list[str]]:
    """Load dependencies for multiple tasks in a single query."""
    if not task_ids:
        return {}
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT task_id, depends_on FROM task_deps WHERE task_id = ANY($1)",
            task_ids,
        )
    deps: dict[str, list[str]] = {tid: [] for tid in task_ids}
    for row in rows:
        deps[row["task_id"]].append(row["depends_on"])
    return deps


async def get_ready_tasks(
    pool: asyncpg.Pool, project_id: str, limit: int = 10
) -> list[Task]:
    """Get pending tasks with no unresolved dependencies (Postgres fallback)."""
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT t.* FROM tasks t
            WHERE t.project_id = $1::uuid
              AND t.status = 'pending'
              AND t.dead_letter = FALSE
              AND NOT EXISTS (
                  SELECT 1 FROM task_deps td
                  JOIN tasks dep ON dep.id = td.depends_on
                  WHERE td.task_id = t.id AND dep.status NOT IN ('done')
              )
            ORDER BY CASE t.priority
                WHEN 'p0' THEN 0 WHEN 'p1' THEN 1 WHEN 'p2' THEN 2 END
            LIMIT $2
            """,
            project_id, limit,
        )
    task_ids = [row["id"] for row in rows]
    all_deps = await _batch_load_deps(pool, task_ids)
    return [Task.from_record(row, depends_on=all_deps.get(row["id"], [])) for row in rows]


async def get_ready_count(pool: asyncpg.Pool, project_id: str) -> int:
    """Count tasks with no unmet dependencies (quick parallelism metric)."""
    async with pool.acquire() as conn:
        count = await conn.fetchval(
            """
            SELECT COUNT(*) FROM tasks t
            WHERE t.project_id = $1::uuid
              AND t.status = 'pending'
              AND t.dead_letter = FALSE
              AND NOT EXISTS (
                  SELECT 1 FROM task_deps td
                  JOIN tasks dep ON dep.id = td.depends_on
                  WHERE td.task_id = t.id AND dep.status NOT IN ('done')
              )
            """,
            project_id,
        )
    return count or 0


async def get_tasks_by_status(
    pool: asyncpg.Pool, project_id: str, status: str, limit: int = 50
) -> list[Task]:
    """Get tasks filtered by status."""
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT t.* FROM tasks t
            WHERE t.project_id = $1::uuid AND t.status = $2
            ORDER BY CASE t.priority
                WHEN 'p0' THEN 0 WHEN 'p1' THEN 1 WHEN 'p2' THEN 2 END
            LIMIT $3
            """,
            project_id, status, limit,
        )
    task_ids = [row["id"] for row in rows]
    all_deps = await _batch_load_deps(pool, task_ids)
    return [Task.from_record(row, depends_on=all_deps.get(row["id"], [])) for row in rows]


async def get_actionable_tasks(
    pool: asyncpg.Pool, project_id: str,
) -> list[Task]:
    """Get all non-done, non-epic tasks (pending, blocked, claimed, failed)."""
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT t.* FROM tasks t
            WHERE t.project_id = $1::uuid
              AND t.status NOT IN ('done', 'epic', 'idea')
              AND t.dead_letter = FALSE
            ORDER BY CASE t.priority
                WHEN 'p0' THEN 0 WHEN 'p1' THEN 1 WHEN 'p2' THEN 2 END
            """,
            project_id,
        )
    task_ids = [row["id"] for row in rows]
    all_deps = await _batch_load_deps(pool, task_ids)
    return [Task.from_record(row, depends_on=all_deps.get(row["id"], [])) for row in rows]


async def get_dependents(pool: asyncpg.Pool, task_id: str) -> list[str]:
    """Get task IDs that depend on the given task."""
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT task_id FROM task_deps WHERE depends_on = $1", task_id
        )
    return [r["task_id"] for r in rows]


async def list_active_tasks(pool: asyncpg.Pool, project_id: str) -> list[Task]:
    """List all non-done, non-failed tasks for cache rebuild."""
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT * FROM tasks
            WHERE project_id = $1::uuid AND status NOT IN ('done', 'failed', 'idea')
            """,
            project_id,
        )
    task_ids = [row["id"] for row in rows]
    all_deps = await _batch_load_deps(pool, task_ids)
    return [Task.from_record(row, depends_on=all_deps.get(row["id"], [])) for row in rows]


async def create_escalation(
    pool: asyncpg.Pool, project_id: str, task_id: str, message: str
) -> int:
    """Create an escalation record. Returns the escalation ID."""
    async with pool.acquire() as conn:
        return await conn.fetchval(
            """
            INSERT INTO escalations (project_id, task_id, message)
            VALUES ($1::uuid, $2, $3) RETURNING id
            """,
            project_id, task_id, message,
        )


async def record_event(
    pool: asyncpg.Pool,
    project_id: str,
    event_type: str,
    task_id: str | None = None,
    agent_id: str | None = None,
    payload: dict | None = None,
) -> int:
    """Write an event to the audit log. Returns the event ID."""
    async with pool.acquire() as conn:
        return await conn.fetchval(
            """
            INSERT INTO events (project_id, event_type, task_id, agent_id, payload)
            VALUES ($1::uuid, $2, $3, $4, $5::jsonb) RETURNING id
            """,
            project_id, event_type, task_id, agent_id,
            json.dumps(payload or {}),
        )


async def record_skill_run(
    pool: asyncpg.Pool,
    project_id: str,
    skill_name: str,
    inputs: dict,
    output: dict | list,
    model: str | None = None,
    tokens_used: int | None = None,
    duration_ms: int | None = None,
) -> str:
    """Log a skill execution to the skill_runs table. Returns the run UUID."""
    async with pool.acquire() as conn:
        run_id = await conn.fetchval(
            """
            INSERT INTO skill_runs (project_id, skill_name, inputs, output,
                model, tokens_used, duration_ms)
            VALUES ($1::uuid, $2, $3::jsonb, $4::jsonb, $5, $6, $7)
            RETURNING id
            """,
            project_id, skill_name, json.dumps(inputs),
            json.dumps(output if isinstance(output, dict) else {"result": output}),
            model, tokens_used, duration_ms,
        )
    return str(run_id)


async def create_tasks_batch(pool: asyncpg.Pool, tasks: list[Task]) -> list[Task]:
    """Insert multiple tasks with deps in a single transaction. All-or-nothing."""
    created: list[Task] = []
    async with pool.acquire() as conn:
        async with conn.transaction():
            for task in tasks:
                record = await conn.fetchrow(
                    """
                    INSERT INTO tasks (id, project_id, title, status, priority,
                        assignee, parent_id, context, output, done_when, verify_command)
                    VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10, $11)
                    RETURNING *
                    """,
                    task.id, task.project_id, task.title, task.status.value,
                    task.priority.value, task.assignee, task.parent_id,
                    json.dumps(task.context), json.dumps(task.output), task.done_when,
                    task.verify_command,
                )
                if task.depends_on:
                    await conn.executemany(
                        "INSERT INTO task_deps (task_id, depends_on) VALUES ($1, $2)",
                        [(task.id, dep) for dep in task.depends_on],
                    )
                created.append(Task.from_record(record, depends_on=task.depends_on))
    return created


async def set_task_status(
    pool: asyncpg.Pool, task_id: str, status: TaskStatus
) -> Task:
    """Directly set a task's status (used by dependency resolution)."""
    async with pool.acquire() as conn:
        record = await conn.fetchrow(
            """
            UPDATE tasks SET status = $1, updated_at = NOW()
            WHERE id = $2 RETURNING *
            """,
            status.value, task_id,
        )
        if record is None:
            raise TaskNotFoundError(task_id)
        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )
    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps])


# ---------------------------------------------------------------------------
# Phase 3: Claim TTL management
# ---------------------------------------------------------------------------


async def set_claim_expiry(
    pool: asyncpg.Pool, task_id: str, expires_at: datetime
) -> Task:
    """Set or extend the claim expiry time (heartbeat)."""
    async with pool.acquire() as conn:
        record = await conn.fetchrow(
            """
            UPDATE tasks SET claim_expires_at = $1, updated_at = NOW()
            WHERE id = $2 AND status = 'claimed'
            RETURNING *
            """,
            expires_at, task_id,
        )
        if record is None:
            raise TaskNotFoundError(task_id)
        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )
    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps])


async def get_expired_claims(pool: asyncpg.Pool, project_id: str) -> list[Task]:
    """Get all claimed tasks whose claim TTL has expired."""
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT * FROM tasks
            WHERE project_id = $1::uuid
              AND status = 'claimed'
              AND claim_expires_at IS NOT NULL
              AND claim_expires_at < NOW()
            """,
            project_id,
        )
    task_ids = [row["id"] for row in rows]
    all_deps = await _batch_load_deps(pool, task_ids)
    return [Task.from_record(row, depends_on=all_deps.get(row["id"], [])) for row in rows]


async def release_expired_claim(pool: asyncpg.Pool, task_id: str) -> Task:
    """Release an expired claim: claimed → pending, clear assignee and TTL."""
    async with pool.acquire() as conn:
        record = await conn.fetchrow(
            """
            UPDATE tasks
            SET status = 'pending', assignee = NULL,
                claim_expires_at = NULL, claimed_at = NULL, updated_at = NOW()
            WHERE id = $1 AND status = 'claimed'
            RETURNING *
            """,
            task_id,
        )
        if record is None:
            raise TaskNotFoundError(task_id)
        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )
    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps])


# ---------------------------------------------------------------------------
# Phase 3: Retry + dead letter management
# ---------------------------------------------------------------------------


async def increment_retry(
    pool: asyncpg.Pool, task_id: str, retry_after: datetime
) -> Task:
    """Increment retry count and set the next retry time."""
    async with pool.acquire() as conn:
        record = await conn.fetchrow(
            """
            UPDATE tasks
            SET retry_count = retry_count + 1,
                retry_after = $1,
                last_failed_at = NOW(),
                updated_at = NOW()
            WHERE id = $2
            RETURNING *
            """,
            retry_after, task_id,
        )
        if record is None:
            raise TaskNotFoundError(task_id)
        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )
    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps])


async def get_retryable_tasks(pool: asyncpg.Pool, project_id: str) -> list[Task]:
    """Get failed tasks that are ready for retry (retry_after <= NOW)."""
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT * FROM tasks
            WHERE project_id = $1::uuid
              AND status = 'failed'
              AND dead_letter = FALSE
              AND retry_after IS NOT NULL
              AND retry_after <= NOW()
            """,
            project_id,
        )
    task_ids = [row["id"] for row in rows]
    all_deps = await _batch_load_deps(pool, task_ids)
    return [Task.from_record(row, depends_on=all_deps.get(row["id"], [])) for row in rows]


async def mark_dead_letter(
    pool: asyncpg.Pool, task_id: str, reason: str
) -> Task:
    """Move a task to dead letter — permanently failed."""
    async with pool.acquire() as conn:
        record = await conn.fetchrow(
            """
            UPDATE tasks
            SET status = 'failed', dead_letter = TRUE, dead_letter_reason = $1,
                retry_after = NULL, updated_at = NOW()
            WHERE id = $2
            RETURNING *
            """,
            reason, task_id,
        )
        if record is None:
            raise TaskNotFoundError(task_id)
        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )
    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps])


async def reset_task(
    pool: asyncpg.Pool, task_id: str, clear_output: bool = False,
) -> Task:
    """Reset a stuck/failed task back to pending status.

    Validates task is in 'claimed', 'failed', or dead-lettered status.
    Clears assignee, claim timestamps, retry state, and dead letter flags.
    Optionally clears output.
    """
    async with pool.acquire() as conn:
        async with conn.transaction():
            current = await conn.fetchrow(
                "SELECT status, dead_letter FROM tasks WHERE id = $1", task_id
            )
            if current is None:
                raise TaskNotFoundError(task_id)

            status = current["status"]
            is_dead = current["dead_letter"]

            if status not in ("claimed", "failed") and not is_dead:
                raise TaskStateError(task_id, status, "pending")

            output_clause = ", output = '{}'::jsonb" if clear_output else ""

            record = await conn.fetchrow(
                f"""
                UPDATE tasks
                SET status = 'pending',
                    assignee = NULL,
                    claimed_at = NULL,
                    claim_expires_at = NULL,
                    retry_count = 0,
                    retry_after = NULL,
                    last_failed_at = NULL,
                    dead_letter = FALSE,
                    dead_letter_reason = NULL,
                    updated_at = NOW()
                    {output_clause}
                WHERE id = $1
                RETURNING *
                """,
                task_id,
            )
        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )
    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps])


# ---------------------------------------------------------------------------
# Idea backlog
# ---------------------------------------------------------------------------


async def create_idea(
    pool: asyncpg.Pool, project_id: str, title: str, context: dict | None = None,
) -> Task:
    """Create a task with status='idea'. No deps, no parent."""
    idea = Task(
        id=gen_task_id(),
        project_id=project_id,
        title=title,
        status=TaskStatus.IDEA,
        context=context or {},
    )
    return await create_task(pool, idea)


async def list_ideas(
    pool: asyncpg.Pool, project_id: str, limit: int = 50,
) -> list[Task]:
    """List all ideas for a project."""
    return await get_tasks_by_status(pool, project_id, "idea", limit=limit)


async def drop_idea(pool: asyncpg.Pool, task_id: str) -> None:
    """Hard-delete an idea. Validates status is 'idea' first."""
    async with pool.acquire() as conn:
        status = await conn.fetchval(
            "SELECT status FROM tasks WHERE id = $1", task_id
        )
        if status is None:
            raise TaskNotFoundError(task_id)
        if status != "idea":
            raise TaskStateError(task_id, status, "dropped — only ideas can be dropped")
        await conn.execute("DELETE FROM tasks WHERE id = $1", task_id)


async def promote_idea(pool: asyncpg.Pool, task_id: str) -> Task:
    """Transition an idea to epic status."""
    async with pool.acquire() as conn:
        status = await conn.fetchval(
            "SELECT status FROM tasks WHERE id = $1", task_id
        )
        if status is None:
            raise TaskNotFoundError(task_id)
        if status != "idea":
            raise TaskStateError(task_id, status, "epic — only ideas can be promoted")
        record = await conn.fetchrow(
            """
            UPDATE tasks SET status = 'epic', updated_at = NOW()
            WHERE id = $1 RETURNING *
            """,
            task_id,
        )
        deps = await conn.fetch(
            "SELECT depends_on FROM task_deps WHERE task_id = $1", task_id
        )
    return Task.from_record(record, depends_on=[r["depends_on"] for r in deps])


# ---------------------------------------------------------------------------
# Phase 3: Workflow state CRUD
# ---------------------------------------------------------------------------


def _parse_workflow_row(row) -> dict:
    """Convert a workflow_runs asyncpg Record to a dict with parsed JSONB."""
    result = dict(row)
    result["id"] = str(result["id"])
    result["project_id"] = str(result["project_id"])
    for field in ("inputs", "state", "outputs"):
        val = result.get(field)
        if isinstance(val, str):
            result[field] = json.loads(val)
    return result


async def create_workflow_run(
    pool: asyncpg.Pool,
    project_id: str,
    workflow_name: str,
    inputs: dict,
) -> dict:
    """Create a new workflow run. Returns the run record as a dict."""
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            """
            INSERT INTO workflow_runs (project_id, workflow_name, inputs)
            VALUES ($1::uuid, $2, $3::jsonb)
            RETURNING *
            """,
            project_id, workflow_name, json.dumps(inputs),
        )
    return _parse_workflow_row(row)


async def get_workflow_run(pool: asyncpg.Pool, run_id: str) -> dict:
    """Fetch a workflow run by ID."""
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT * FROM workflow_runs WHERE id = $1::uuid", run_id
        )
    if row is None:
        raise LookupError(f"Workflow run not found: {run_id}")
    return _parse_workflow_row(row)


async def update_workflow_run(
    pool: asyncpg.Pool, run_id: str, **fields
) -> dict:
    """Update workflow run fields (status, current_step, state, outputs, error, completed_at)."""
    allowed = {"status", "current_step", "state", "outputs", "error", "completed_at"}
    updates = {k: v for k, v in fields.items() if k in allowed and v is not None}
    if not updates:
        return await get_workflow_run(pool, run_id)

    set_parts = []
    values = []
    for i, (key, val) in enumerate(updates.items(), start=1):
        if key in ("state", "outputs"):
            set_parts.append(f"{key} = ${i}::jsonb")
            values.append(json.dumps(val))
        else:
            set_parts.append(f"{key} = ${i}")
            values.append(val)
    set_parts.append(f"updated_at = ${len(values) + 1}")
    values.append(datetime.now(timezone.utc))
    values.append(run_id)

    query = (
        f"UPDATE workflow_runs SET {', '.join(set_parts)} "
        f"WHERE id = ${len(values)}::uuid RETURNING *"
    )
    async with pool.acquire() as conn:
        row = await conn.fetchrow(query, *values)
    if row is None:
        raise LookupError(f"Workflow run not found: {run_id}")
    return _parse_workflow_row(row)


async def list_workflow_runs(
    pool: asyncpg.Pool, project_id: str, status: str | None = None
) -> list[dict]:
    """List workflow runs for a project, optionally filtered by status."""
    async with pool.acquire() as conn:
        if status:
            rows = await conn.fetch(
                """
                SELECT * FROM workflow_runs
                WHERE project_id = $1::uuid AND status = $2
                ORDER BY started_at DESC
                """,
                project_id, status,
            )
        else:
            rows = await conn.fetch(
                """
                SELECT * FROM workflow_runs
                WHERE project_id = $1::uuid
                ORDER BY started_at DESC
                """,
                project_id,
            )
    return [_parse_workflow_row(row) for row in rows]


async def record_workflow_step(
    pool: asyncpg.Pool,
    run_id: str,
    step_name: str,
    status: str,
    inputs: dict | None = None,
    outputs: dict | None = None,
    error: str | None = None,
) -> int:
    """Log a workflow step execution. Returns the step ID."""
    async with pool.acquire() as conn:
        return await conn.fetchval(
            """
            INSERT INTO workflow_steps (run_id, step_name, status, inputs, outputs, error,
                completed_at)
            VALUES ($1::uuid, $2, $3, $4::jsonb, $5::jsonb, $6,
                CASE WHEN $3 IN ('completed', 'failed', 'skipped') THEN NOW() END)
            RETURNING id
            """,
            run_id, step_name, status,
            json.dumps(inputs or {}), json.dumps(outputs or {}), error,
        )
