"""Task-related API routes."""

import logging
from datetime import datetime

import asyncpg
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request

from peon_mcp.common.auth import AuthInfo, get_auth
from peon_mcp.common.constants import VALID_PRIORITIES, VALID_TASK_STATUSES, VALID_TASK_TRANSITIONS
from peon_mcp.common.dependencies import get_db, get_project_id
from peon_mcp.common.events import (
    EventType,
    TaskCompletedEvent,
    TaskCreatedEvent,
    TaskFailedEvent,
    TaskProgressUpdatedEvent,
    TaskStatusChangedEvent,
    TaskTimeoutEvent,
    event_bus,
)
from peon_mcp.common.schemas import BulkUpdateResponse, OkResponse, PaginatedResponse
from peon_mcp.common.scoping import (
    authorize_project_access,
    authorize_entity_project_access,
    get_accessible_project_ids,
)
from peon_mcp.db import row_to_dict
from peon_mcp.tasks.schemas import (
    AddDependencyRequest,
    BulkUpdateTasksRequest,
    ConflictResolutionResponse,
    CreateTaskRequest,
    GlobalNextTaskRequest,
    NextTaskRequest,
    NextTaskResponse,
    RecoverableTaskResponse,
    RecoveryResponse,
    TaskDependencyResponse,
    TaskDetailResponse,
    TaskResponse,
    UpdateTaskProgressRequest,
    UpdateTaskRequest,
)

router = APIRouter(tags=["Tasks"])


async def _enrich_deps(db, tasks: list[dict]) -> None:
    """Populate depends_on/blocked_by on each task dict in-place (one query)."""
    if not tasks:
        return
    task_ids = [t["id"] for t in tasks]
    placeholders = ",".join("?" * len(task_ids))
    rows = await db.execute_fetchall(
        f"SELECT td.task_id, td.depends_on_task_id, dep.status"
        f" FROM task_dependencies td"
        f" JOIN tasks dep ON td.depends_on_task_id = dep.id"
        f" WHERE td.task_id IN ({placeholders})",
        task_ids,
    )
    deps: dict[int, tuple[list[int], list[int]]] = {}
    for r in rows:
        tid = r["task_id"]
        dep_id = r["depends_on_task_id"]
        if tid not in deps:
            deps[tid] = ([], [])
        deps[tid][0].append(dep_id)
        if r["status"] not in ("merged", "cancelled"):
            deps[tid][1].append(dep_id)
    for task in tasks:
        tid = task["id"]
        if tid in deps:
            task["depends_on"] = deps[tid][0]
            task["blocked_by"] = deps[tid][1]
        else:
            task["depends_on"] = []
            task["blocked_by"] = []


async def _would_create_cycle(db, task_id: int, depends_on_task_id: int) -> bool:
    """BFS from depends_on_task_id; return True if task_id is reachable."""
    visited: set[int] = set()
    queue = [depends_on_task_id]
    while queue:
        current = queue.pop(0)
        if current == task_id:
            return True
        if current in visited:
            continue
        visited.add(current)
        rows = await db.execute_fetchall(
            "SELECT depends_on_task_id FROM task_dependencies WHERE task_id = ?",
            (current,),
        )
        queue.extend(r["depends_on_task_id"] for r in rows)
    return False



@router.get("/api/tasks/peek-next")
async def peek_next_task(
    request: Request,
    project_id: str | None = Query(default=None),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    """Read-only peek at the highest-priority todo task without claiming it.

    When project_id is omitted, searches across all projects.
    Returns {project_id, task_id} or 404 if no todo tasks exist.
    """
    if project_id:
        if auth.is_session:
            await authorize_project_access(db, auth, project_id, request)
        query = (
            "SELECT id, project_id FROM tasks"
            " WHERE project_id = ? AND status = 'todo'"
        )
        params: list = [project_id]
    else:
        # Scope to accessible projects for session auth
        accessible_ids = await get_accessible_project_ids(db, auth, request)
        if accessible_ids is not None:
            if not accessible_ids:
                raise HTTPException(404, detail="No todo tasks found")
            placeholders = ",".join("?" * len(accessible_ids))
            query = (
                f"SELECT id, project_id FROM tasks"
                f" WHERE project_id IN ({placeholders}) AND status = 'todo'"
            )
            params = list(accessible_ids)
        else:
            query = (
                "SELECT id, project_id FROM tasks"
                " WHERE status = 'todo'"
            )
            params = []

    query += (
        " ORDER BY CASE priority"
        "   WHEN 'critical' THEN 0"
        "   WHEN 'high' THEN 1"
        "   WHEN 'medium' THEN 2"
        "   WHEN 'low' THEN 3"
        " END, created_at ASC"
        " LIMIT 1"
    )

    rows = await db.execute_fetchall(query, params)
    if not rows:
        raise HTTPException(404, detail="No todo tasks found")
    row = row_to_dict(rows[0])
    return {"project_id": row["project_id"], "task_id": row["id"]}


@router.get("/api/projects/{owner}/{repo}/tasks", response_model=PaginatedResponse[TaskResponse])
async def list_tasks(
    request: Request,
    status: str | None = Query(default=None),
    priority: str | None = Query(default=None),
    sort: str = Query(default="priority"),
    limit: int = Query(default=50, ge=1, le=200),
    offset: int = Query(default=0, ge=0),
    search: str | None = Query(default=None),
    feature_id: int | None = Query(default=None),
    started_after: str | None = Query(default=None, description="Filter tasks started at or after this ISO timestamp"),
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)
    query = "SELECT * FROM tasks WHERE project_id = ?"
    params: list = [project_id]

    # Support search by title and description
    if search:
        search_term = f"%{search}%"
        query += " AND (title ILIKE ? OR description ILIKE ?)"
        params.extend([search_term, search_term])

    # Support multiple status values (comma-separated)
    if status:
        statuses = [s.strip() for s in status.split(",") if s.strip()]
        if statuses:
            placeholders = ",".join("?" * len(statuses))
            query += f" AND status IN ({placeholders})"
            params.extend(statuses)

    # Support multiple priority values (comma-separated)
    if priority:
        priorities = [p.strip() for p in priority.split(",") if p.strip()]
        if priorities:
            placeholders = ",".join("?" * len(priorities))
            query += f" AND priority IN ({placeholders})"
            params.extend(priorities)

    # Support filtering by feature_id
    if feature_id is not None:
        query += " AND feature_id = ?"
        params.append(feature_id)

    # Support filtering by started_at timestamp
    if started_after:
        try:
            started_dt = datetime.fromisoformat(started_after)
        except ValueError:
            raise HTTPException(400, detail=f"Invalid ISO timestamp for started_after: {started_after}")
        query += " AND started_at >= ?"
        params.append(started_dt)

    # Get total count for pagination metadata
    count_query = query.replace("SELECT *", "SELECT COUNT(*)")
    count_rows = await db.execute_fetchall(count_query, params)
    total = count_rows[0]["count"] if count_rows else 0

    # Add ORDER BY clause based on sort parameter
    if sort == "status":
        query += (
            " ORDER BY CASE status"
            " WHEN 'todo' THEN 0"
            " WHEN 'in_progress' THEN 1"
            " WHEN 'done' THEN 2"
            " WHEN 'cancelled' THEN 3"
            " END, created_at DESC"
        )
    elif sort == "created":
        query += " ORDER BY created_at DESC"
    elif sort == "updated":
        query += " ORDER BY updated_at DESC"
    elif sort == "started":
        query += " ORDER BY started_at DESC NULLS LAST"
    elif sort == "completed":
        query += " ORDER BY completed_at DESC NULLS LAST"
    else:  # default to priority
        query += (
            " ORDER BY CASE priority"
            " WHEN 'critical' THEN 0"
            " WHEN 'high' THEN 1"
            " WHEN 'medium' THEN 2"
            " WHEN 'low' THEN 3"
            " END, created_at DESC"
        )

    # Add pagination
    query += " LIMIT ? OFFSET ?"
    params.extend([limit, offset])

    rows = await db.execute_fetchall(query, params)
    items = [row_to_dict(r) for r in rows]
    await _enrich_deps(db, items)

    # Enrich items with feature names
    feature_ids = {item["feature_id"] for item in items if item.get("feature_id")}
    if feature_ids:
        feat_placeholders = ",".join("?" * len(feature_ids))
        feat_rows = await db.execute_fetchall(
            f"SELECT id, name FROM features WHERE id IN ({feat_placeholders})",
            list(feature_ids),
        )
        feat_map = {r["id"]: r["name"] for r in feat_rows}
        for item in items:
            item["feature_name"] = feat_map.get(item.get("feature_id"))
    else:
        for item in items:
            item["feature_name"] = None

    return {
        "items": items,
        "total": total,
        "limit": limit,
        "offset": offset,
        "has_next": (offset + len(items)) < total,
    }


@router.post("/api/projects/{owner}/{repo}/tasks", response_model=TaskResponse, status_code=201)
async def create_task(
    body: CreateTaskRequest,
    request: Request,
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)
    title = body.title.strip()
    if not title:
        raise HTTPException(400, detail="title is required")
    if body.status not in VALID_TASK_STATUSES:
        raise HTTPException(
            400,
            detail=f"Invalid status '{body.status}'. Must be one of: {', '.join(sorted(VALID_TASK_STATUSES))}"
        )
    if body.priority not in VALID_PRIORITIES:
        raise HTTPException(
            400,
            detail=f"Invalid priority '{body.priority}'. Must be one of: {', '.join(sorted(VALID_PRIORITIES))}"
        )
    # Validate feature exists and belongs to same project
    if body.feature_id is not None:
        feat_rows = await db.execute_fetchall(
            "SELECT project_id FROM features WHERE id = ?", (body.feature_id,)
        )
        if not feat_rows:
            raise HTTPException(400, detail=f"Feature {body.feature_id} does not exist.")
        if feat_rows[0]["project_id"] != project_id:
            raise HTTPException(
                400,
                detail=f"Feature {body.feature_id} belongs to project '{feat_rows[0]['project_id']}', not '{project_id}'."
            )
    row = await db.execute_fetchone(
        "INSERT INTO tasks (project_id, feature_id, title, description, priority, status, commit_base, commit_head, branch, worktree_path, pr_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
        (project_id, body.feature_id, title, body.description, body.priority, body.status, body.commit_base, body.commit_head, body.branch, body.worktree_path, body.pr_url),
    )
    new_task_id = row["id"]

    if body.depends_on:
        # Validate each dependency target exists
        dep_ids = list(body.depends_on)
        placeholders = ",".join("?" * len(dep_ids))
        dep_rows = await db.execute_fetchall(
            f"SELECT id FROM tasks WHERE id IN ({placeholders})",
            dep_ids,
        )
        found = {r["id"] for r in dep_rows}
        for dep_id in dep_ids:
            if dep_id not in found:
                raise HTTPException(400, detail=f"Dependency task {dep_id} does not exist.")

        # Cycle detection for each edge (new task has no existing deps, so cycles are
        # impossible in practice, but we run it for correctness)
        for dep_id in dep_ids:
            if await _would_create_cycle(db, new_task_id, dep_id):
                raise HTTPException(
                    400,
                    detail=(
                        f"Adding dependency on task {dep_id} would create a cycle."
                    ),
                )

        # Bulk-insert dependency edges
        for dep_id in dep_ids:
            await db.execute(
                "INSERT INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)",
                (new_task_id, dep_id),
            )

    await db.commit()
    rows = await db.execute_fetchall(
        "SELECT * FROM tasks WHERE id = ?", (new_task_id,)
    )
    task = row_to_dict(rows[0])
    await _enrich_deps(db, [task])
    event_bus.emit(TaskCreatedEvent(
        project_id=project_id,
        task_id=task["id"],
        title=task.get("title", ""),
        priority=task.get("priority", ""),
        status=task.get("status", ""),
        feature_id=task.get("feature_id"),
        task_data=task,
    ))
    return task


@router.post("/api/projects/{owner}/{repo}/next-task", response_model=NextTaskResponse)
async def next_task(
    request: Request,
    body: NextTaskRequest | None = Body(default=None),
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)
    """Atomically claim the next highest-priority todo task for a project.

    Uses SELECT … FOR UPDATE SKIP LOCKED within an explicit transaction to
    prevent multiple concurrent callers from claiming the same task. Locked
    rows (being claimed by another caller) are silently skipped. Returns 404
    if no claimable todo tasks remain.
    """
    commit_base = body.commit_base if body else ""
    feature_id = body.feature_id if body else None
    host = body.host if body else ""

    max_retries = 10
    excluded_ids: list[int] = []

    for _ in range(max_retries):
        query = (
            "SELECT * FROM tasks WHERE project_id = ? AND status = 'todo'"
        )
        params: list = [project_id]

        if feature_id is not None:
            query += " AND feature_id = ?"
            params.append(feature_id)

        if excluded_ids:
            placeholders = ",".join("?" * len(excluded_ids))
            query += f" AND id NOT IN ({placeholders})"
            params.extend(excluded_ids)

        query += (
            " AND NOT EXISTS ("
            "   SELECT 1 FROM task_dependencies td"
            "   JOIN tasks dep ON td.depends_on_task_id = dep.id"
            "   WHERE td.task_id = tasks.id"
            "     AND dep.status NOT IN ('merged', 'cancelled')"
            " )"
        )

        query += (
            " ORDER BY CASE priority"
            "   WHEN 'critical' THEN 0"
            "   WHEN 'high' THEN 1"
            "   WHEN 'medium' THEN 2"
            "   WHEN 'low' THEN 3"
            " END, created_at ASC"
            " LIMIT 1"
            " FOR UPDATE SKIP LOCKED"
        )

        async with db.transaction() as tx:
            rows = await tx.execute_fetchall(query, params)
            if not rows:
                raise HTTPException(404, detail="No remaining todo tasks for project")

            task = row_to_dict(rows[0])

            # Re-check dependencies within the transaction (guards against
            # status changes between the NOT EXISTS filter and now).
            dep_recheck = await tx.execute_fetchall(
                "SELECT COUNT(*) as cnt FROM task_dependencies td"
                " JOIN tasks dep ON td.depends_on_task_id = dep.id"
                " WHERE td.task_id = ? AND dep.status NOT IN ('merged', 'cancelled')",
                (task["id"],),
            )
            if dep_recheck[0]["cnt"] > 0:
                # Dependencies changed — skip this task, don't claim it.
                # Transaction commits with no writes; row lock is released.
                excluded_ids.append(task["id"])
                continue

            # Claim the task — safe because we hold the row lock.
            await tx.execute(
                "UPDATE tasks SET status = 'in_progress', commit_base = ?,"
                " host = ?, started_at = CURRENT_TIMESTAMP,"
                " updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                (commit_base, host, task["id"]),
            )
            # Transaction commits on exit, releasing the lock.

        # --- Outside transaction: enrich and return ---
        refreshed_rows = await db.execute_fetchall(
            "SELECT * FROM tasks WHERE id = ?", (task["id"],)
        )
        task = row_to_dict(refreshed_rows[0])

        if task.get("feature_id"):
            feat_rows = await db.execute_fetchall(
                "SELECT branch FROM features WHERE id = ?", (task["feature_id"],)
            )
            if feat_rows:
                task["feature_branch"] = feat_rows[0]["branch"]

        await _enrich_deps(db, [task])
        return task

    raise HTTPException(409, detail=f"Unable to claim a task after {max_retries} attempts. Please try again.")


@router.post("/api/next-task", response_model=NextTaskResponse)
async def global_next_task(
    request: Request,
    body: GlobalNextTaskRequest | None = Body(default=None),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    """Atomically claim the next highest-priority todo task.

    Unlike the project-scoped endpoint, this allows an empty project_id
    to search across all projects.  Uses SELECT … FOR UPDATE SKIP LOCKED
    within an explicit transaction to prevent concurrent double-claims.
    """
    project_id = (body.project_id if body else "").strip()
    commit_base = body.commit_base if body else ""
    feature_id = body.feature_id if body else None
    host = body.host if body else ""

    # For session auth with specific project_id, verify access
    if project_id and auth.is_session:
        await authorize_project_access(db, auth, project_id, request)

    # Get accessible project IDs for scoping
    accessible_ids = await get_accessible_project_ids(db, auth, request) if not project_id else None

    max_retries = 10
    excluded_ids: list[int] = []

    for _ in range(max_retries):
        if project_id:
            query = (
                "SELECT * FROM tasks WHERE project_id = ? AND status = 'todo'"
            )
            params: list = [project_id]
        elif accessible_ids is not None:
            if not accessible_ids:
                raise HTTPException(404, detail="No todo tasks found")
            placeholders = ",".join("?" * len(accessible_ids))
            query = (
                f"SELECT * FROM tasks WHERE project_id IN ({placeholders}) AND status = 'todo'"
            )
            params = list(accessible_ids)
        else:
            query = (
                "SELECT * FROM tasks WHERE status = 'todo'"
            )
            params = []

        if feature_id is not None:
            query += " AND feature_id = ?"
            params.append(feature_id)

        if excluded_ids:
            placeholders = ",".join("?" * len(excluded_ids))
            query += f" AND id NOT IN ({placeholders})"
            params.extend(excluded_ids)

        query += (
            " AND NOT EXISTS ("
            "   SELECT 1 FROM task_dependencies td"
            "   JOIN tasks dep ON td.depends_on_task_id = dep.id"
            "   WHERE td.task_id = tasks.id"
            "     AND dep.status NOT IN ('merged', 'cancelled')"
            " )"
        )

        query += (
            " ORDER BY CASE priority"
            "   WHEN 'critical' THEN 0"
            "   WHEN 'high' THEN 1"
            "   WHEN 'medium' THEN 2"
            "   WHEN 'low' THEN 3"
            " END, created_at ASC"
            " LIMIT 1"
            " FOR UPDATE SKIP LOCKED"
        )

        async with db.transaction() as tx:
            rows = await tx.execute_fetchall(query, params)
            if not rows:
                feature_msg = f" in feature {feature_id}" if feature_id else ""
                project_msg = f" for project '{project_id}'" if project_id else " in any project"
                raise HTTPException(404, detail=f"No remaining todo tasks{project_msg}{feature_msg}")

            task = row_to_dict(rows[0])

            # Re-check dependencies within the transaction.
            dep_recheck = await tx.execute_fetchall(
                "SELECT COUNT(*) as cnt FROM task_dependencies td"
                " JOIN tasks dep ON td.depends_on_task_id = dep.id"
                " WHERE td.task_id = ? AND dep.status NOT IN ('merged', 'cancelled')",
                (task["id"],),
            )
            if dep_recheck[0]["cnt"] > 0:
                excluded_ids.append(task["id"])
                continue

            # Claim the task — safe because we hold the row lock.
            await tx.execute(
                "UPDATE tasks SET status = 'in_progress', commit_base = ?,"
                " host = ?, started_at = CURRENT_TIMESTAMP,"
                " updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                (commit_base, host, task["id"]),
            )

        # --- Outside transaction: enrich and return ---
        refreshed_rows = await db.execute_fetchall(
            "SELECT * FROM tasks WHERE id = ?", (task["id"],)
        )
        task = row_to_dict(refreshed_rows[0])

        if task.get("feature_id"):
            feat_rows = await db.execute_fetchall(
                "SELECT branch FROM features WHERE id = ?", (task["feature_id"],)
            )
            if feat_rows:
                task["feature_branch"] = feat_rows[0]["branch"]

        await _enrich_deps(db, [task])
        return task

    raise HTTPException(409, detail=f"Unable to claim a task after {max_retries} attempts. Please try again.")


# CRITICAL: bulk-update routes MUST be registered BEFORE /{task_id} routes
# or FastAPI will match "bulk-update" as a task_id parameter.


@router.put("/api/tasks/bulk-update", response_model=BulkUpdateResponse)
async def global_bulk_update_tasks(
    body: BulkUpdateTasksRequest,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    """Update the status of multiple tasks in one operation (project-agnostic)."""
    task_ids = body.task_ids
    status = body.status.strip()

    if not task_ids:
        raise HTTPException(400, detail="task_ids cannot be empty")

    MAX_BULK_TASK_IDS = 500
    if len(task_ids) > MAX_BULK_TASK_IDS:
        raise HTTPException(
            400,
            detail=f"task_ids exceeds maximum allowed length of {MAX_BULK_TASK_IDS}. Split your request into smaller batches."
        )

    if not status:
        raise HTTPException(400, detail="status is required")

    if status not in VALID_TASK_STATUSES:
        raise HTTPException(
            400,
            detail=f"Invalid status '{status}'. Must be one of: {', '.join(sorted(VALID_TASK_STATUSES))}"
        )

    if status == "done":
        raise HTTPException(
            400,
            detail="Cannot set status to 'done' via bulk update. Use individual task update to ensure test gate validation."
        )

    # For session auth, verify all target tasks are in accessible projects
    if auth.is_session:
        accessible_ids = await get_accessible_project_ids(db, auth, request)
        if accessible_ids is not None:
            placeholders_check = ",".join("?" * len(task_ids))
            check_rows = await db.execute_fetchall(
                f"SELECT DISTINCT project_id FROM tasks WHERE id IN ({placeholders_check})",
                task_ids,
            )
            for row in check_rows:
                if row["project_id"] not in accessible_ids:
                    raise HTTPException(404, detail="Project not found")

    placeholders = ",".join("?" * len(task_ids))
    query = f"UPDATE tasks SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})"
    params = [status] + task_ids

    cursor = await db.execute(query, params)
    await db.commit()

    return {"updated": cursor.rowcount}


@router.put("/api/projects/{owner}/{repo}/tasks/bulk-update", response_model=BulkUpdateResponse)
async def bulk_update_tasks(
    body: BulkUpdateTasksRequest,
    request: Request,
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)
    """Update the status of multiple tasks in one operation."""
    task_ids = body.task_ids
    status = body.status.strip()

    # Validate task_ids
    if not task_ids:
        raise HTTPException(400, detail="task_ids cannot be empty")

    MAX_BULK_TASK_IDS = 500
    if len(task_ids) > MAX_BULK_TASK_IDS:
        raise HTTPException(
            400,
            detail=f"task_ids exceeds maximum allowed length of {MAX_BULK_TASK_IDS}. Split your request into smaller batches."
        )

    # Validate status
    if not status:
        raise HTTPException(400, detail="status is required")

    if status not in VALID_TASK_STATUSES:
        raise HTTPException(
            400,
            detail=f"Invalid status '{status}'. Must be one of: {', '.join(sorted(VALID_TASK_STATUSES))}"
        )

    if status == "done":
        raise HTTPException(
            400,
            detail="Cannot set status to 'done' via bulk update. Use individual task update to ensure test gate validation."
        )

    # Build parameterized query
    placeholders = ",".join("?" * len(task_ids))
    query = f"UPDATE tasks SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders}) AND project_id = ?"
    params = [status] + task_ids + [project_id]

    # Execute the update
    cursor = await db.execute(query, params)
    await db.commit()

    return {"updated": cursor.rowcount}


@router.get("/api/projects/{owner}/{repo}/tasks/recoverable", response_model=list[RecoverableTaskResponse])
async def find_recoverable_tasks(
    request: Request,
    max_age_hours: int = Query(default=24, ge=1, le=720),
    project_id: str = Depends(get_project_id),
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_project_access(db, auth, project_id, request)
    rows = await db.execute_fetchall(
        """
        SELECT * FROM tasks
        WHERE project_id = ?
          AND (
            (status = 'in_progress' AND started_at < NOW() - INTERVAL '1 hour' * ?)
            OR (status = 'in_progress' AND progress >= 1.0)
            OR (status = 'timeout')
          )
        ORDER BY started_at ASC
        """,
        (project_id, max_age_hours),
    )

    tasks = [row_to_dict(r) for r in rows]

    # Add recovery hints to each task
    for task in tasks:
        hints = []
        if task["status"] == "timeout":
            hints.append("Task was marked as timed out")
        elif task["progress"] >= 1.0:
            hints.append("Progress is 100% but task not marked done")
        else:
            hints.append(f"Task has been in_progress for >{max_age_hours} hours")

        if task["branch"] and not task["pr_url"]:
            hints.append("Branch exists but no PR created")
        if task["worktree_path"]:
            hints.append(f"Worktree may exist at: {task['worktree_path']}")

        task["recovery_hints"] = hints

    return tasks


@router.get("/api/tasks/{task_id}", response_model=TaskDetailResponse)
async def get_task(
    task_id: int,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_entity_project_access(db, auth, request, "tasks", task_id)
    rows = await db.execute_fetchall(
        "SELECT * FROM tasks WHERE id = ?", (task_id,)
    )
    if not rows:
        raise HTTPException(404, detail="Task not found")

    # Fetch related work logs
    log_rows = await db.execute_fetchall(
        "SELECT * FROM work_logs WHERE task_id = ? ORDER BY created_at DESC",
        (task_id,)
    )

    # Build response with task data and work logs
    task_data = row_to_dict(rows[0])
    task_data["work_logs"] = [row_to_dict(r) for r in log_rows]
    await _enrich_deps(db, [task_data])

    return task_data


@router.put("/api/tasks/{task_id}", response_model=TaskResponse)
async def update_task(
    task_id: int,
    body: UpdateTaskRequest,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_entity_project_access(db, auth, request, "tasks", task_id)
    provided = body.model_dump(exclude_unset=True)

    # Validate status and priority values
    if "status" in provided and provided["status"] not in VALID_TASK_STATUSES:
        raise HTTPException(
            400,
            detail=f"Invalid status '{provided['status']}'. Must be one of: {', '.join(sorted(VALID_TASK_STATUSES))}"
        )
    if "priority" in provided and provided["priority"] not in VALID_PRIORITIES:
        raise HTTPException(
            400,
            detail=f"Invalid priority '{provided['priority']}'. Must be one of: {', '.join(sorted(VALID_PRIORITIES))}"
        )

    # Validate status transition
    old_status: str | None = None
    if "status" in provided:
        # Fetch current status
        current_rows = await db.execute_fetchall(
            "SELECT status FROM tasks WHERE id = ?", (task_id,)
        )
        if not current_rows:
            raise HTTPException(404, detail="Task not found")
        current_status = current_rows[0]["status"]
        old_status = current_status
        new_status = provided["status"]

        if current_status != new_status:  # Only check if actually changing
            allowed = VALID_TASK_TRANSITIONS.get(current_status, set())
            if new_status not in allowed:
                raise HTTPException(
                    400,
                    detail=(
                        f"Invalid status transition: '{current_status}' → '{new_status}'. "
                        f"Allowed transitions from '{current_status}': {', '.join(sorted(allowed)) or 'none (terminal state)'}"
                    ),
                )

    # Validate dependencies when transitioning to 'in_progress'
    if provided.get("status") == "in_progress":
        task_exists = await db.execute_fetchall(
            "SELECT id FROM tasks WHERE id = ?", (task_id,)
        )
        if not task_exists:
            raise HTTPException(404, detail="Task not found")
        dep_rows = await db.execute_fetchall(
            "SELECT td.depends_on_task_id, dep.title, dep.status"
            " FROM task_dependencies td"
            " JOIN tasks dep ON td.depends_on_task_id = dep.id"
            " WHERE td.task_id = ? AND dep.status NOT IN ('merged', 'cancelled')",
            (task_id,),
        )
        if dep_rows:
            blocking = [
                f"#{r['depends_on_task_id']} ({r['title']!r}: {r['status']})"
                for r in dep_rows
            ]
            raise HTTPException(
                409,
                detail=(
                    f"Cannot set task #{task_id} to 'in_progress': "
                    f"unfinished dependencies: {', '.join(blocking)}"
                ),
            )

    # Validate 'done' status requires pr_url and performance metrics
    if provided.get("status") == "done":
        # Check if the task already has required fields
        rows = await db.execute_fetchall(
            "SELECT project_id, feature_id, pr_url, branch, lines_added, lines_deleted, tokens_used FROM tasks WHERE id = ?", (task_id,)
        )
        if not rows:
            raise HTTPException(404, detail="Task not found")
        task_project_id = rows[0]["project_id"]
        task_feature_id = rows[0]["feature_id"]
        existing_pr_url = rows[0]["pr_url"]
        existing_branch = rows[0]["branch"]
        existing_lines_added = rows[0]["lines_added"]
        existing_lines_deleted = rows[0]["lines_deleted"]
        existing_tokens_used = rows[0]["tokens_used"]

        # Check pr_url requirement
        if "pr_url" not in provided and not existing_pr_url:
            raise HTTPException(400, detail=(
                f"Cannot mark task #{task_id} as 'done' without a pr_url. "
                f"Please push your branch ({existing_branch or 'unknown'}) to GitHub "
                f"and create a pull request first, then include pr_url in the request body."
            ))

        # Check performance metrics requirements
        final_lines_added = provided.get("lines_added", existing_lines_added)
        final_lines_deleted = provided.get("lines_deleted", existing_lines_deleted)
        final_tokens_used = provided.get("tokens_used", existing_tokens_used)

        if final_lines_added == 0 and final_lines_deleted == 0 and not provided.get("pr_url") and not existing_pr_url:
            raise HTTPException(400, detail=(
                f"Cannot mark task #{task_id} as 'done' without either a pr_url or performance metrics (lines_added/lines_deleted). "
                f"For coding tasks, provide lines_added and lines_deleted. "
                f"For non-coding tasks (reviews, sweeps), provide a pr_url or set lines_added=1 as a placeholder."
            ))

    fields: list[str] = []
    params: list = []
    for key in ("title", "description", "priority", "status", "commit_base", "commit_head", "branch", "worktree_path", "pr_url", "feature_id", "lines_added", "lines_deleted", "tokens_used", "status_reason"):
        if key in provided:
            fields.append(f"{key} = ?")
            params.append(provided[key])
    # Set started_at when transitioning to 'in_progress' status
    if provided.get("status") == "in_progress":
        fields.append("started_at = CURRENT_TIMESTAMP")
    # Set completed_at and progress when transitioning to 'done' status
    if provided.get("status") == "done":
        fields.append("completed_at = CURRENT_TIMESTAMP")
        fields.append("progress = 1.0")
    if not fields:
        raise HTTPException(400, detail="No fields to update")
    fields.append("updated_at = CURRENT_TIMESTAMP")
    params.append(task_id)
    await db.execute(
        f"UPDATE tasks SET {', '.join(fields)} WHERE id = ?", params
    )
    await db.commit()

    rows = await db.execute_fetchall(
        "SELECT * FROM tasks WHERE id = ?", (task_id,)
    )
    if not rows:
        raise HTTPException(404, detail="Task not found")

    result = row_to_dict(rows[0])

    # Check if all tasks in the same feature are complete when marking this task as done
    if provided.get("status") == "done" and result.get("feature_id"):
        sibling_rows = await db.execute_fetchall(
            "SELECT status FROM tasks WHERE feature_id = ? AND id != ?",
            (result["feature_id"], task_id),
        )
        if sibling_rows:
            all_complete = all(
                row["status"] in ("done", "cancelled") for row in sibling_rows
            )
            if all_complete:
                result["all_feature_tasks_complete"] = True
                # Also include the feature branch and PR URL for subscribers
                feat_rows = await db.execute_fetchall(
                    "SELECT branch, pr_url FROM features WHERE id = ?", (result["feature_id"],)
                )
                if feat_rows and feat_rows[0]["branch"]:
                    result["feature_branch"] = feat_rows[0]["branch"]
                if feat_rows and feat_rows[0]["pr_url"]:
                    result["feature_pr_url"] = feat_rows[0]["pr_url"]

    # Emit typed events via the event bus
    new_status = provided.get("status")

    # Always emit a generic status-changed event on any transition
    if new_status and old_status and new_status != old_status:
        event_bus.emit(TaskStatusChangedEvent(
            project_id=result["project_id"],
            task_id=task_id,
            old_status=old_status,
            new_status=new_status,
            feature_id=result.get("feature_id"),
            task_data=result,
        ))

    # Emit specific terminal events
    if new_status == "done":
        # Fetch the most recent work log to include its summary in the notification
        log_rows = await db.execute_fetchall(
            "SELECT summary, files_changed, test_result FROM work_logs"
            " WHERE task_id = ? ORDER BY created_at DESC LIMIT 1",
            (task_id,),
        )
        event_data = dict(result)
        if log_rows:
            event_data["work_log_summary"] = log_rows[0]["summary"]
            event_data["work_log_files_changed"] = log_rows[0]["files_changed"] or ""
            event_data["work_log_test_result"] = log_rows[0]["test_result"] or ""
        event_bus.emit(TaskCompletedEvent(
            project_id=result["project_id"],
            task_id=task_id,
            feature_id=result.get("feature_id"),
            pr_url=result.get("pr_url", ""),
            lines_added=result.get("lines_added"),
            lines_deleted=result.get("lines_deleted"),
            tokens_used=result.get("tokens_used"),
            work_log_summary=event_data.get("work_log_summary", ""),
            work_log_files_changed=event_data.get("work_log_files_changed", ""),
            work_log_test_result=event_data.get("work_log_test_result", ""),
            task_data=event_data,
        ))
    elif new_status == "cancelled":
        event_bus.emit(TaskFailedEvent(
            project_id=result["project_id"],
            task_id=task_id,
            feature_id=result.get("feature_id"),
            task_data=result,
        ))
    elif new_status == "timeout":
        event_bus.emit(TaskTimeoutEvent(
            project_id=result["project_id"],
            task_id=task_id,
            feature_id=result.get("feature_id"),
            task_data=result,
        ))

    return result


@router.delete("/api/tasks/{task_id}", response_model=OkResponse)
async def delete_task(
    task_id: int,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_entity_project_access(db, auth, request, "tasks", task_id)
    cursor = await db.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
    await db.commit()
    if cursor.rowcount == 0:
        raise HTTPException(404, detail="Task not found")
    return {"ok": True}


@router.put("/api/tasks/{task_id}/progress", response_model=TaskResponse)
async def update_task_progress(
    task_id: int,
    body: UpdateTaskProgressRequest,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_entity_project_access(db, auth, request, "tasks", task_id)
    progress = body.progress

    if progress < 0.0 or progress > 1.0:
        raise HTTPException(
            400,
            detail=f"progress must be between 0.0 and 1.0, got {progress}"
        )

    # Get current task to check existing progress
    rows = await db.execute_fetchall(
        "SELECT progress FROM tasks WHERE id = ?", (task_id,)
    )
    if not rows:
        raise HTTPException(404, detail="Task not found")

    current_progress = rows[0]["progress"]

    # Ensure progress only increases
    if progress < current_progress:
        raise HTTPException(400, detail=(
            f"Progress can only increase. Current progress is {current_progress}, "
            f"cannot decrease to {progress}"
        ))

    # Update the progress
    await db.execute(
        "UPDATE tasks SET progress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
        (progress, task_id),
    )
    await db.commit()

    # Return updated task
    rows = await db.execute_fetchall("SELECT * FROM tasks WHERE id = ?", (task_id,))
    result = row_to_dict(rows[0])

    # Emit progress event if the value actually changed
    if progress != current_progress:
        event_bus.emit(TaskProgressUpdatedEvent(
            project_id=result["project_id"],
            task_id=task_id,
            old_progress=current_progress,
            new_progress=progress,
            task_data=result,
        ))

    return result


@router.post("/api/tasks/{task_id}/recover", response_model=RecoveryResponse)
async def recover_task_endpoint(
    task_id: int,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_entity_project_access(db, auth, request, "tasks", task_id)
    """Create a recovery task for a specific stuck task."""
    try:
        # Get task and project details
        rows = await db.execute_fetchall(
            """
            SELECT t.*, p.path as project_path
            FROM tasks t
            JOIN projects p ON t.project_id = p.id
            WHERE t.id = ?
            """,
            (task_id,),
        )
        if not rows:
            raise HTTPException(404, detail="Task not found")

        task = row_to_dict(rows[0])
        recovery_title = f"Recover Task #{task_id}: {task['title']}"

        # Check if a recovery task already exists for this task (duplicate prevention).
        # The check, reset, and insert are all done before any commit so that the
        # single shared connection serializes concurrent requests naturally.
        existing = await db.execute_fetchall(
            """
            SELECT id FROM tasks
            WHERE project_id = ? AND title = ? AND status != 'cancelled'
            LIMIT 1
            """,
            (task["project_id"], recovery_title),
        )
        if existing:
            existing_id = existing[0]["id"]
            return {
                "status": "existing",
                "recovery_task_id": existing_id,
                "original_task_id": task_id,
                "message": f"Recovery task #{existing_id} already exists for task #{task_id}",
            }

        # Build recovery description before modifying any state
        recovery_description = f"""Recovery task for stuck task #{task_id}.

**Original Task:** {task['title']}

**Original Description:**
{task['description']}

**Current State:**
- Status: {task['status']}
- Progress: {task.get('progress', 0.0) * 100:.0f}%
- Branch: {task.get('branch', 'NOT CREATED')}
- Worktree: {task.get('worktree_path', 'NOT CREATED')}
- Base commit: {task.get('commit_base', 'NOT RECORDED')}

**Recovery Instructions:**
Use the recover_task prompt from peon-mcp to recover this task. Pass the following parameters:
- project_id: {task['project_id']}
- task_id: {task_id}
- task_title: {task['title']}
- task_description: {task['description']}
- repo_path: {task['project_path']}
- branch: {task.get('branch', '')}
- worktree_path: {task.get('worktree_path', '')}
- commit_base: {task.get('commit_base', '')}
- progress: {task.get('progress', 0.0)}
"""

        # Reset original task progress and create the recovery task in one transaction
        await db.execute(
            "UPDATE tasks SET progress = 0.0, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
            (task_id,),
        )
        row = await db.execute_fetchone(
            """
            INSERT INTO tasks (
                project_id, title, description, priority, status,
                created_at, updated_at
            )
            VALUES (?, ?, ?, 'high', 'todo', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
            RETURNING id
            """,
            (task["project_id"], recovery_title, recovery_description),
        )
        await db.commit()
        recovery_task_id = row["id"]

        return {
            "status": "created",
            "recovery_task_id": recovery_task_id,
            "original_task_id": task_id,
            "message": f"Created recovery task #{recovery_task_id}"
        }

    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(500, detail=f"Failed to create recovery task: {str(e)}")


@router.post("/api/tasks/{task_id}/resolve-conflicts", response_model=ConflictResolutionResponse)
async def resolve_merge_conflicts_endpoint(
    task_id: int,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_entity_project_access(db, auth, request, "tasks", task_id)
    """Create a task to resolve merge conflicts for a completed task with a PR."""
    try:
        # Get task and project details
        rows = await db.execute_fetchall(
            """
            SELECT t.*, p.path as project_path
            FROM tasks t
            JOIN projects p ON t.project_id = p.id
            WHERE t.id = ?
            """,
            (task_id,),
        )
        if not rows:
            raise HTTPException(404, detail="Task not found")

        task = row_to_dict(rows[0])

        # Validate that task is done and has a PR URL
        if task["status"] != "done":
            raise HTTPException(400, detail="Can only resolve merge conflicts for completed tasks")

        if not task.get("pr_url"):
            raise HTTPException(400, detail="Task must have a PR URL to resolve merge conflicts")

        # Create a new task for resolving merge conflicts
        conflict_resolution_title = f"Resolve Merge Conflicts for Task #{task_id}"
        conflict_resolution_description = f"""Resolve merge conflicts for task #{task_id}: {task['title']}

**Original Task:** {task['title']}

**PR URL:** {task['pr_url']}

**Branch:** {task.get('branch', 'UNKNOWN')}

**Instructions:**
1. Review the PR at {task['pr_url']} and identify merge conflicts
2. Check out the branch {task.get('branch', '')} (or create a worktree if needed)
3. Pull the latest changes from main/base branch
4. Resolve all merge conflicts carefully, preserving the intent of both changes
5. Run tests to ensure nothing is broken
6. Commit the resolved changes with a clear message
7. Push the resolved changes to update the PR
8. Comment on the PR that conflicts have been resolved

**Important:** Be thorough in conflict resolution. Don't just accept one side blindly - understand both changes and merge them intelligently.
"""

        # Insert the new conflict resolution task with high priority
        row = await db.execute_fetchone(
            """
            INSERT INTO tasks (
                project_id, title, description, priority, status,
                created_at, updated_at
            )
            VALUES (?, ?, ?, 'high', 'todo', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
            RETURNING id
            """,
            (task["project_id"], conflict_resolution_title, conflict_resolution_description),
        )
        await db.commit()
        conflict_resolution_task_id = row["id"]

        return {
            "status": "created",
            "conflict_resolution_task_id": conflict_resolution_task_id,
            "original_task_id": task_id,
            "message": f"Created merge conflict resolution task #{conflict_resolution_task_id}"
        }

    except HTTPException:
        raise
    except Exception as e:
        raise HTTPException(500, detail=f"Failed to create merge conflict resolution task: {str(e)}")


# ---------------------------------------------------------------------------
# Task dependency endpoints
# ---------------------------------------------------------------------------


@router.post(
    "/api/tasks/{task_id}/dependencies",
    response_model=TaskDependencyResponse,
    status_code=201,
)
async def add_task_dependency(
    task_id: int,
    body: AddDependencyRequest,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_entity_project_access(db, auth, request, "tasks", task_id)
    """Add a dependency edge: task_id depends on depends_on_task_id."""
    dep_id = body.depends_on_task_id

    # Self-dependency
    if task_id == dep_id:
        raise HTTPException(400, detail="A task cannot depend on itself.")

    # Both tasks must exist
    rows = await db.execute_fetchall(
        "SELECT id, project_id FROM tasks WHERE id IN (?, ?)", (task_id, dep_id)
    )
    found = {r["id"]: r["project_id"] for r in rows}
    if task_id not in found:
        raise HTTPException(404, detail=f"Task {task_id} not found.")
    if dep_id not in found:
        raise HTTPException(404, detail=f"Task {dep_id} not found.")

    # Cycle detection
    if await _would_create_cycle(db, task_id, dep_id):
        raise HTTPException(
            400,
            detail=(
                f"Adding this dependency would create a cycle: "
                f"task {dep_id} already (transitively) depends on task {task_id}."
            ),
        )

    try:
        row = await db.execute_fetchone(
            "INSERT INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?) RETURNING id",
            (task_id, dep_id),
        )
        await db.commit()
    except asyncpg.UniqueViolationError:
        raise HTTPException(
            409, detail=f"Dependency already exists: task {task_id} already depends on task {dep_id}."
        )
    except Exception as exc:
        raise HTTPException(500, detail=f"Failed to add dependency: {exc}")

    dep_rows = await db.execute_fetchall(
        "SELECT * FROM task_dependencies WHERE id = ?", (row["id"],)
    )
    return row_to_dict(dep_rows[0])


@router.delete("/api/tasks/{task_id}/dependencies/{depends_on_task_id}", response_model=OkResponse)
async def remove_task_dependency(
    task_id: int,
    depends_on_task_id: int,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    """Remove a dependency edge."""
    cursor = await db.execute(
        "DELETE FROM task_dependencies WHERE task_id = ? AND depends_on_task_id = ?",
        (task_id, depends_on_task_id),
    )
    await db.commit()
    if cursor.rowcount == 0:
        raise HTTPException(
            404,
            detail=f"Dependency not found: task {task_id} does not depend on task {depends_on_task_id}.",
        )
    return {"ok": True}


@router.get("/api/tasks/{task_id}/dependencies", response_model=list[TaskDependencyResponse])
async def list_task_dependencies(
    task_id: int,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_entity_project_access(db, auth, request, "tasks", task_id)
    """Return tasks that this task depends on (what blocks it)."""
    # Verify task exists
    rows = await db.execute_fetchall("SELECT id FROM tasks WHERE id = ?", (task_id,))
    if not rows:
        raise HTTPException(404, detail="Task not found")

    dep_rows = await db.execute_fetchall(
        "SELECT * FROM task_dependencies WHERE task_id = ? ORDER BY created_at ASC",
        (task_id,),
    )
    return [row_to_dict(r) for r in dep_rows]


@router.get("/api/tasks/{task_id}/dependents", response_model=list[TaskDependencyResponse])
async def list_task_dependents(
    task_id: int,
    request: Request,
    auth: AuthInfo = Depends(get_auth),
    db=Depends(get_db),
):
    await authorize_entity_project_access(db, auth, request, "tasks", task_id)
    """Return tasks that depend on this task (what this task blocks — reverse lookup)."""
    # Verify task exists
    rows = await db.execute_fetchall("SELECT id FROM tasks WHERE id = ?", (task_id,))
    if not rows:
        raise HTTPException(404, detail="Task not found")

    dep_rows = await db.execute_fetchall(
        "SELECT * FROM task_dependencies WHERE depends_on_task_id = ? ORDER BY created_at ASC",
        (task_id,),
    )
    return [row_to_dict(r) for r in dep_rows]
