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

import asyncio

import aiosqlite
from fastapi import APIRouter, Body, Depends, HTTPException, Query

from peon_mcp.common.constants import VALID_PRIORITIES, VALID_TASK_STATUSES
from peon_mcp.common.dependencies import get_db
from peon_mcp.common.schemas import BulkUpdateResponse, OkResponse, PaginatedResponse
from peon_mcp.db import row_to_dict
from peon_mcp.webhooks.engine import EventType, emit_event
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 ("done", "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/projects/{project_id}/tasks", response_model=PaginatedResponse[TaskResponse])
async def list_tasks(
    project_id: str,
    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),
    scheduled: str | None = Query(default=None, description="Filter by schedule: 'past' (due/unscheduled), 'future' (not yet due), 'all' (default)"),
    db=Depends(get_db),
):
    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 LIKE ? OR description LIKE ?)"
        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 scheduled_at relative to now
    if scheduled == "past":
        # Tasks that are due now or have no schedule (immediately available)
        query += " AND (scheduled_at IS NULL OR scheduled_at <= datetime('now'))"
    elif scheduled == "future":
        # Tasks scheduled for a future time (not yet due)
        query += " AND scheduled_at > datetime('now')"

    # 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][0] 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[0]: r[1] 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/{project_id}/tasks", response_model=TaskResponse, status_code=201)
async def create_task(project_id: str, body: CreateTaskRequest, db=Depends(get_db)):
    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}'."
            )
    cursor = await db.execute(
        "INSERT INTO tasks (project_id, feature_id, title, description, priority, status, commit_base, commit_head, branch, worktree_path, pr_url, scheduled_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
        (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, body.scheduled_at),
    )
    new_task_id = cursor.lastrowid

    if body.depends_on:
        # Validate each dependency target exists and belongs to the same project
        dep_ids = list(body.depends_on)
        placeholders = ",".join("?" * len(dep_ids))
        dep_rows = await db.execute_fetchall(
            f"SELECT id, project_id FROM tasks WHERE id IN ({placeholders})",
            dep_ids,
        )
        found = {r["id"]: r["project_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.")
            if found[dep_id] != project_id:
                raise HTTPException(
                    400,
                    detail=(
                        f"Dependency task {dep_id} belongs to project '{found[dep_id]}', "
                        f"not '{project_id}'."
                    ),
                )

        # 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])
    asyncio.create_task(emit_event(db, project_id, EventType.TASK_CREATED, task))
    return task


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

    Uses optimistic concurrency control (WHERE status = 'todo' in UPDATE) with
    a retry loop to safely handle multiple concurrent callers. Returns 404 if
    no todo tasks remain.
    """
    commit_base = body.commit_base if body else ""
    feature_id = body.feature_id if body else None

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

    for _ in range(max_retries):
        query = (
            "SELECT * FROM tasks WHERE project_id = ? AND status = 'todo'"
            " AND (scheduled_at IS NULL OR scheduled_at <= datetime('now'))"
        )
        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 ('done', '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"
        )

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

        task = row_to_dict(rows[0])

        cursor = await db.execute(
            "UPDATE tasks SET status = 'in_progress', commit_base = ?, started_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'todo'",
            (commit_base, task["id"]),
        )
        await db.commit()

        if cursor.rowcount > 0:
            # Post-claim dependency re-check: guards against state changes that occurred
            # between the SELECT and UPDATE (e.g. in multi-process deployments or asyncio
            # interleaving). If a dependency is now unfinished, release the task and skip.
            dep_recheck = await db.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 ('done', 'cancelled')",
                (task["id"],),
            )
            if dep_recheck[0]["cnt"] > 0:
                await db.execute(
                    "UPDATE tasks SET status = 'todo', started_at = NULL,"
                    " updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                    (task["id"],),
                )
                await db.commit()
                excluded_ids.append(task["id"])
                continue

            # Re-fetch to get DB-generated fields (started_at, updated_at)
            refreshed_rows = await db.execute_fetchall(
                "SELECT * FROM tasks WHERE id = ?", (task["id"],)
            )
            task = row_to_dict(refreshed_rows[0])

            # Enrich with feature branch if applicable
            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
        else:
            excluded_ids.append(task["id"])

    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(
    body: GlobalNextTaskRequest | None = Body(default=None),
    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.
    """
    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

    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'"
                " AND (scheduled_at IS NULL OR scheduled_at <= datetime('now'))"
            )
            params: list = [project_id]
        else:
            query = (
                "SELECT * FROM tasks WHERE status = 'todo'"
                " AND (scheduled_at IS NULL OR scheduled_at <= datetime('now'))"
            )
            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 ('done', '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"
        )

        rows = await db.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])

        cursor = await db.execute(
            "UPDATE tasks SET status = 'in_progress', commit_base = ?, started_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'todo'",
            (commit_base, task["id"]),
        )
        await db.commit()

        if cursor.rowcount > 0:
            # Post-claim dependency re-check: guards against state changes that occurred
            # between the SELECT and UPDATE (e.g. in multi-process deployments or asyncio
            # interleaving). If a dependency is now unfinished, release the task and skip.
            dep_recheck = await db.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 ('done', 'cancelled')",
                (task["id"],),
            )
            if dep_recheck[0]["cnt"] > 0:
                await db.execute(
                    "UPDATE tasks SET status = 'todo', started_at = NULL,"
                    " updated_at = CURRENT_TIMESTAMP WHERE id = ?",
                    (task["id"],),
                )
                await db.commit()
                excluded_ids.append(task["id"])
                continue

            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
        else:
            excluded_ids.append(task["id"])

    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, 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))}"
        )

    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/{project_id}/tasks/bulk-update", response_model=BulkUpdateResponse)
async def bulk_update_tasks(project_id: str, body: BulkUpdateTasksRequest, db=Depends(get_db)):
    """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))}"
        )

    # 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/{project_id}/tasks/recoverable", response_model=list[RecoverableTaskResponse])
async def find_recoverable_tasks(
    project_id: str,
    max_age_hours: int = Query(default=24, ge=1, le=720),
    db=Depends(get_db),
):
    rows = await db.execute_fetchall(
        """
        SELECT * FROM tasks
        WHERE project_id = ?
          AND (
            (status = 'in_progress' AND started_at < datetime('now', ? || ' hours'))
            OR (status = 'in_progress' AND progress >= 1.0)
            OR (status = 'timeout')
          )
        ORDER BY started_at ASC
        """,
        (project_id, f"-{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, db=Depends(get_db)):
    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, db=Depends(get_db)):
    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 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 ('done', '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."
            ))

        # Validate PR base branch matches feature branch if task belongs to a feature
        final_pr_url = provided.get("pr_url", existing_pr_url)
        if task_feature_id and final_pr_url:
            # Look up the feature's branch name
            feature_rows = await db.execute_fetchall(
                "SELECT branch FROM features WHERE id = ?", (task_feature_id,)
            )
            if not feature_rows:
                raise HTTPException(400, detail=f"Task belongs to feature #{task_feature_id}, but feature not found in database")

            feature_branch = feature_rows[0]["branch"]
            if not feature_branch:
                raise HTTPException(400, detail=f"Task belongs to feature #{task_feature_id}, but feature has no branch configured")

            # Use GitHub CLI to get the PR's base branch
            try:
                proc = await asyncio.create_subprocess_exec(
                    "gh", "pr", "view", final_pr_url, "--json", "baseRefName", "--jq", ".baseRefName",
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
                try:
                    stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
                except asyncio.TimeoutError:
                    proc.kill()
                    await proc.communicate()
                    raise HTTPException(400, detail=(
                        f"GitHub CLI command timed out while checking PR base branch. "
                        f"Please verify your network connection and try again."
                    ))

                if proc.returncode != 0:
                    raise HTTPException(400, detail=(
                        f"Failed to validate PR base branch. GitHub CLI error: {stderr.decode().strip()}. "
                        f"Ensure 'gh' is installed and authenticated, and that the PR URL is valid."
                    ))

                pr_base_branch = stdout.decode().strip()
                if pr_base_branch != feature_branch:
                    raise HTTPException(400, detail=(
                        f"Task belongs to feature #{task_feature_id} (branch: {feature_branch}). "
                        f"PR must target '{feature_branch}', not '{pr_base_branch}'. "
                        f"Please recreate the PR with: gh pr create --base {feature_branch}"
                    ))
            except FileNotFoundError:
                raise HTTPException(400, detail=(
                    f"Cannot validate PR base branch: GitHub CLI ('gh') not found. "
                    f"Install it from https://cli.github.com/ to enable PR validation."
                ))

        # 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:
            raise HTTPException(400, detail=(
                f"Cannot mark task #{task_id} as 'done' without performance metrics. "
                f"Please provide lines_added and lines_deleted (you can use 'git diff --stat' to get these values)."
            ))

        if final_tokens_used == 0:
            raise HTTPException(400, detail=(
                f"Cannot mark task #{task_id} as 'done' without tokens_used. "
                f"Please provide the number of tokens used during task execution."
            ))

        # Test enforcement gate
        test_override = provided.get("test_override")
        test_cmd_rows = await db.execute_fetchall(
            "SELECT COUNT(*) as count FROM test_commands WHERE project_id = ? AND enabled = 1",
            (task_project_id,)
        )
        test_command_count = test_cmd_rows[0]["count"] if test_cmd_rows else 0

        if test_command_count > 0:
            latest_runs = await db.execute_fetchall("""
                SELECT
                    tc.id as test_command_id,
                    tc.name as command_name,
                    tc.max_retries,
                    tr.status,
                    (SELECT COUNT(*) FROM test_runs
                     WHERE task_id = ? AND test_command_id = tc.id AND status != 'pass') as attempt_count
                FROM test_commands tc
                LEFT JOIN (
                    SELECT test_command_id, status, created_at,
                           ROW_NUMBER() OVER (PARTITION BY test_command_id ORDER BY created_at DESC) as rn
                    FROM test_runs
                    WHERE task_id = ?
                ) tr ON tc.id = tr.test_command_id AND tr.rn = 1
                WHERE tc.project_id = ? AND tc.enabled = 1
            """, (task_id, task_id, task_project_id))

            if not latest_runs or all(row["status"] is None for row in latest_runs):
                raise HTTPException(400, detail=(
                    f"Cannot mark task #{task_id} done without running tests. "
                    f"Call run_tests first."
                ))

            failed_tests = [row for row in latest_runs if row["status"] and row["status"] != "pass"]
            if failed_tests:
                if test_override:
                    exhausted = all(
                        row["attempt_count"] >= row["max_retries"]
                        for row in failed_tests
                    )
                    if not exhausted:
                        raise HTTPException(400, detail=(
                            f"Cannot use test_override — retries not exhausted. "
                            f"Some test commands have not reached max_retries yet."
                        ))
                    # Retries exhausted; allow the update and log the override below
                else:
                    failed_names = [row["command_name"] for row in failed_tests]
                    raise HTTPException(400, detail=(
                        f"Cannot mark task #{task_id} done — test failures exist. "
                        f"Failed tests: {', '.join(failed_names)}. Fix and re-run."
                    ))

    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", "tool_policy_id"):
        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 when transitioning to 'done' status
    if provided.get("status") == "done":
        fields.append("completed_at = CURRENT_TIMESTAMP")
    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()

    # If test_override was used, insert a work_log entry recording the override
    if provided.get("status") == "done" and provided.get("test_override"):
        task_rows = await db.execute_fetchall("SELECT project_id FROM tasks WHERE id = ?", (task_id,))
        if task_rows:
            await db.execute(
                "INSERT INTO work_logs (task_id, project_id, summary, files_changed, test_result) VALUES (?, ?, ?, '', 'fail')",
                (task_id, task_rows[0]["project_id"], f"Test override applied: {provided['test_override']}")
            )
            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 for convenience
                feat_rows = await db.execute_fetchall(
                    "SELECT branch FROM features WHERE id = ?", (result["feature_id"],)
                )
                if feat_rows and feat_rows[0]["branch"]:
                    result["feature_branch"] = feat_rows[0]["branch"]

    # Fire-and-forget event emission based on new status
    new_status = provided.get("status")
    if new_status == "done":
        asyncio.create_task(emit_event(db, result["project_id"], EventType.TASK_COMPLETED, result))
    elif new_status == "cancelled":
        asyncio.create_task(emit_event(db, result["project_id"], EventType.TASK_FAILED, result))
    elif new_status == "timeout":
        asyncio.create_task(emit_event(db, result["project_id"], EventType.TASK_TIMEOUT, result))

    return result


@router.delete("/api/tasks/{task_id}", response_model=OkResponse)
async def delete_task(task_id: int, db=Depends(get_db)):
    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, db=Depends(get_db)):
    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,))
    return row_to_dict(rows[0])


@router.post("/api/tasks/{task_id}/recover", response_model=RecoveryResponse)
async def recover_task_endpoint(task_id: int, db=Depends(get_db)):
    """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 aiosqlite 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,),
        )
        cursor = await db.execute(
            """
            INSERT INTO tasks (
                project_id, title, description, priority, status,
                created_at, updated_at
            )
            VALUES (?, ?, ?, 'high', 'todo', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
            """,
            (task["project_id"], recovery_title, recovery_description),
        )
        await db.commit()
        recovery_task_id = cursor.lastrowid

        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, db=Depends(get_db)):
    """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
        cursor = await db.execute(
            """
            INSERT INTO tasks (
                project_id, title, description, priority, status,
                created_at, updated_at
            )
            VALUES (?, ?, ?, 'high', 'todo', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
            """,
            (task["project_id"], conflict_resolution_title, conflict_resolution_description),
        )
        await db.commit()
        conflict_resolution_task_id = cursor.lastrowid

        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,
    db=Depends(get_db),
):
    """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.")

    # Same project constraint
    if found[task_id] != found[dep_id]:
        raise HTTPException(
            400,
            detail=(
                f"Tasks must belong to the same project. "
                f"Task {task_id} is in '{found[task_id]}', "
                f"task {dep_id} is in '{found[dep_id]}'."
            ),
        )

    # 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:
        cursor = await db.execute(
            "INSERT INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)",
            (task_id, dep_id),
        )
        await db.commit()
    except Exception as exc:
        if "UNIQUE constraint failed" in str(exc):
            raise HTTPException(
                409, detail=f"Dependency already exists: task {task_id} already depends on task {dep_id}."
            )
        raise HTTPException(500, detail=f"Failed to add dependency: {exc}")

    dep_rows = await db.execute_fetchall(
        "SELECT * FROM task_dependencies WHERE id = ?", (cursor.lastrowid,)
    )
    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,
    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, db=Depends(get_db)):
    """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, db=Depends(get_db)):
    """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]
