"""Code review orchestration with multiple perspectives and convergence detection."""
import asyncio
import json
import logging
import sqlite3
from typing import Any

import aiosqlite

from peon_mcp.db import get_db_path, row_to_dict

try:
    from peon_mcp.subagents.manager import (
        SpawnRequest,
        mark_completed,
        mark_failed,
        mark_running,
        mark_timeout,
        register_spawn,
    )
    _SUBAGENT_MANAGER_AVAILABLE = True
except ImportError:
    _SUBAGENT_MANAGER_AVAILABLE = False

log = logging.getLogger(__name__)

# Perspective definitions with focus areas, categories, and severity rubrics
PERSPECTIVES = {
    "security": {
        "key": "security",
        "name": "Security Reviewer",
        "focus_areas": (
            "Security vulnerabilities, authentication/authorization flaws, injection risks, "
            "sensitive data exposure, cryptographic weaknesses, insecure dependencies"
        ),
        "categories": [
            "injection",
            "authentication",
            "authorization",
            "sensitive_data",
            "cryptography",
            "dependency_vulnerability",
            "input_validation",
            "session_management",
            "error_handling",
            "insecure_configuration"
        ],
        "severity_rubric": {
            "critical": "Exploitable vulnerability that could lead to data breach, RCE, or system compromise",
            "high": "Significant security flaw that could be exploited with moderate effort",
            "medium": "Security weakness that requires specific conditions to exploit",
            "low": "Minor security concern or hardening opportunity"
        }
    },
    "maintainability": {
        "key": "maintainability",
        "name": "Maintainability Reviewer",
        "focus_areas": (
            "Code clarity, duplication, complexity, documentation, naming conventions, "
            "modularity, testability, technical debt"
        ),
        "categories": [
            "code_duplication",
            "complexity",
            "naming",
            "documentation",
            "modularity",
            "error_handling",
            "code_organization",
            "magic_values",
            "dead_code",
            "technical_debt"
        ],
        "severity_rubric": {
            "critical": "Severe maintainability issue that will block future development",
            "high": "Significant technical debt that will impede velocity",
            "medium": "Moderate concern that should be addressed soon",
            "low": "Minor improvement opportunity"
        }
    },
    "performance": {
        "key": "performance",
        "name": "Performance Reviewer",
        "focus_areas": (
            "Algorithm efficiency, database query optimization, memory usage, "
            "network latency, caching opportunities, resource leaks, scalability"
        ),
        "categories": [
            "algorithm_efficiency",
            "database_query",
            "memory_leak",
            "network_latency",
            "caching",
            "resource_usage",
            "concurrency",
            "scalability",
            "io_optimization",
            "unnecessary_computation"
        ],
        "severity_rubric": {
            "critical": "Performance bottleneck that will cause production failures at scale",
            "high": "Significant inefficiency that impacts user experience",
            "medium": "Noticeable performance degradation in common scenarios",
            "low": "Minor optimization opportunity"
        }
    },
    "architecture": {
        "key": "architecture",
        "name": "Architecture Reviewer",
        "focus_areas": (
            "Design patterns, separation of concerns, SOLID principles, coupling/cohesion, "
            "API design, data flow, architectural consistency, boundaries"
        ),
        "categories": [
            "separation_of_concerns",
            "coupling",
            "cohesion",
            "abstraction",
            "api_design",
            "data_flow",
            "layer_violation",
            "dependency_direction",
            "solid_violation",
            "architectural_consistency"
        ],
        "severity_rubric": {
            "critical": "Fundamental architectural flaw that violates core design principles",
            "high": "Significant design issue that will cause problems down the line",
            "medium": "Design concern that could be improved",
            "low": "Minor architectural suggestion"
        }
    },
    "testing": {
        "key": "testing",
        "name": "Testing Reviewer",
        "focus_areas": (
            "Test coverage, test quality, edge cases, test maintainability, "
            "test isolation, mocking strategy, integration testing, flaky tests"
        ),
        "categories": [
            "missing_tests",
            "edge_case_coverage",
            "test_quality",
            "test_isolation",
            "mock_abuse",
            "flaky_test",
            "test_maintainability",
            "assertion_quality",
            "test_organization",
            "integration_coverage"
        ],
        "severity_rubric": {
            "critical": "Critical path completely untested or tests give false confidence",
            "high": "Important functionality lacks adequate test coverage",
            "medium": "Tests could be improved to catch more issues",
            "low": "Minor test enhancement opportunity"
        }
    },
    "reliability": {
        "key": "reliability",
        "name": "Reliability Reviewer",
        "focus_areas": (
            "Error handling, race conditions, edge cases, failure modes, "
            "retry logic, idempotency, state management, observability"
        ),
        "categories": [
            "error_handling",
            "race_condition",
            "edge_case",
            "failure_mode",
            "retry_logic",
            "idempotency",
            "state_management",
            "observability",
            "timeout_handling",
            "circuit_breaker"
        ],
        "severity_rubric": {
            "critical": "Critical reliability issue that will cause production incidents",
            "high": "Significant failure mode not properly handled",
            "medium": "Reliability concern in uncommon scenarios",
            "low": "Minor robustness improvement"
        }
    }
}

DEFAULT_PERSPECTIVES = ["security", "maintainability", "architecture"]


def _try_register_subagent(
    project_id: str,
    session_id: int,
    perspective_key: str,
    parent_task_id: int | None = None,
    timeout_seconds: int = 600,
) -> int | None:
    """Register a perspective agent in the sub-agent registry.

    Returns the sub_agent row ID, or None if the registry is unavailable or
    spawn limits would be violated.

    Uses a synchronous sqlite3 connection so it can be called from both sync
    and async contexts without event-loop concerns.
    """
    if not _SUBAGENT_MANAGER_AVAILABLE:
        return None
    try:
        db_path = get_db_path()
        conn = sqlite3.connect(db_path)
        conn.row_factory = sqlite3.Row
        try:
            request = SpawnRequest(
                project_id=project_id,
                label=f"review-{perspective_key}",
                idempotency_key=f"review-session-{session_id}-{perspective_key}",
                parent_task_id=parent_task_id,
                depth=1,
                timeout_seconds=timeout_seconds,
            )
            agent = register_spawn(conn, request)
            return agent.id
        finally:
            conn.close()
    except Exception as e:
        log.warning(
            "Could not register sub-agent for review session %d / %s: %s",
            session_id,
            perspective_key,
            e,
        )
        return None


def _try_update_subagent_status(
    sub_agent_id: int | None,
    status: str,
    pid: int | None = None,
    result_summary: str = "",
    tokens_used: int = 0,
) -> None:
    """Update a sub-agent's status in the registry.

    No-ops silently when sub_agent_id is None or the registry is unavailable.
    """
    if sub_agent_id is None or not _SUBAGENT_MANAGER_AVAILABLE:
        return
    try:
        db_path = get_db_path()
        conn = sqlite3.connect(db_path)
        try:
            if status == "running":
                mark_running(conn, sub_agent_id, pid)
            elif status == "completed":
                mark_completed(conn, sub_agent_id, result_summary, tokens_used)
            elif status == "failed":
                mark_failed(conn, sub_agent_id, result_summary)
            elif status == "timeout":
                mark_timeout(conn, sub_agent_id)
        finally:
            conn.close()
    except Exception as e:
        log.warning("Could not update sub-agent #%d status to %s: %s", sub_agent_id, status, e)


async def fetch_pr_context(pr_url: str) -> dict[str, Any]:
    """Fetch PR diff and metadata using gh CLI.

    Args:
        pr_url: GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)

    Returns:
        Dictionary with:
            - diff: Full unified diff text
            - title: PR title
            - base_ref: Base branch name
            - head_ref: Head branch name
            - files: List of file paths changed
    """
    # Extract PR number from URL or use directly if it's just a number
    if pr_url.startswith("http"):
        pr_number = pr_url.rstrip("/").split("/")[-1]
    else:
        pr_number = pr_url

    # Fetch diff
    try:
        proc_diff = await asyncio.wait_for(
            asyncio.create_subprocess_exec(
                "gh", "pr", "diff", pr_number,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            ),
            timeout=30.0
        )
        diff_stdout, diff_stderr = await asyncio.wait_for(
            proc_diff.communicate(),
            timeout=30.0
        )
    except asyncio.TimeoutError:
        raise RuntimeError("GitHub CLI 'gh pr diff' command timed out after 30 seconds. Please check your network connection.")

    if proc_diff.returncode != 0:
        raise RuntimeError(f"Failed to fetch PR diff: {diff_stderr.decode()}")

    # Fetch metadata
    try:
        proc_meta = await asyncio.wait_for(
            asyncio.create_subprocess_exec(
                "gh", "pr", "view", pr_number,
                "--json", "title,baseRefName,headRefName,files",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE
            ),
            timeout=30.0
        )
        meta_stdout, meta_stderr = await asyncio.wait_for(
            proc_meta.communicate(),
            timeout=30.0
        )
    except asyncio.TimeoutError:
        raise RuntimeError("GitHub CLI 'gh pr view' command timed out after 30 seconds. Please check your network connection.")

    if proc_meta.returncode != 0:
        raise RuntimeError(f"Failed to fetch PR metadata: {meta_stderr.decode()}")

    metadata = json.loads(meta_stdout.decode())

    return {
        "diff": diff_stdout.decode(),
        "title": metadata.get("title", ""),
        "base_ref": metadata.get("baseRefName", ""),
        "head_ref": metadata.get("headRefName", ""),
        "files": [f["path"] for f in metadata.get("files", [])]
    }


async def create_review_tasks(db: aiosqlite.Connection, session_id: int) -> dict[str, Any]:
    """Create one peon task per perspective instead of spawning processes.

    Each task is a regular peon task with task_type='review' that gets picked up
    by the peon-loop. The loop detects review tasks and provides a review-specific
    prompt instead of the development prompt.

    Args:
        db: Database connection
        session_id: Review session ID

    Returns:
        Dictionary with session status and created task IDs.
    """
    # Fetch session details
    cursor = await db.execute(
        "SELECT pr_url, perspectives, project_id, task_id FROM review_sessions WHERE id = ?",
        (session_id,)
    )
    session_row = await cursor.fetchone()
    if not session_row:
        raise ValueError(f"Review session {session_id} not found")

    pr_url = session_row["pr_url"]
    perspectives_json = session_row["perspectives"]
    project_id = session_row["project_id"]
    perspectives = json.loads(perspectives_json) if perspectives_json else DEFAULT_PERSPECTIVES

    # Fetch PR title via gh CLI (just metadata, no diff — agent fetches diff itself)
    pr_title = ""
    try:
        if pr_url.startswith("http"):
            pr_number = pr_url.rstrip("/").split("/")[-1]
        else:
            pr_number = pr_url

        proc = await asyncio.create_subprocess_exec(
            "gh", "pr", "view", pr_number,
            "--json", "title",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE
        )
        stdout, _ = await proc.communicate()
        if proc.returncode == 0:
            metadata = json.loads(stdout.decode())
            pr_title = metadata.get("title", "")
    except Exception as e:
        log.warning("Failed to fetch PR title for session %d: %s", session_id, e)

    # Update session with PR title if we got one
    if pr_title:
        await db.execute(
            "UPDATE review_sessions SET pr_title = ? WHERE id = ?",
            (pr_title, session_id)
        )

    created_task_ids = []

    for perspective_key in perspectives:
        if perspective_key not in PERSPECTIVES:
            log.warning("Skipping unknown perspective: %s", perspective_key)
            continue

        persp = PERSPECTIVES[perspective_key]

        # Build task title and description
        title = f"[Review: {persp['name']}] {pr_title or pr_url}"

        # Build severity rubric text
        rubric_lines = [
            f"  - {severity}: {desc}"
            for severity, desc in persp["severity_rubric"].items()
        ]
        rubric_text = "\n".join(rubric_lines)
        categories_text = ", ".join(persp["categories"])

        description = (
            f"**Review Session:** #{session_id}\n"
            f"**Perspective:** {perspective_key} ({persp['name']})\n"
            f"**PR URL:** {pr_url}\n\n"
            f"**Focus Areas:** {persp['focus_areas']}\n\n"
            f"**Categories:** {categories_text}\n\n"
            f"**Severity Rubric:**\n{rubric_text}\n\n"
            f"**Instructions:**\n"
            f"1. Fetch the PR diff: run `gh pr diff {pr_url}`\n"
            f"2. Review the diff through the {perspective_key} lens\n"
            f"3. For each issue, call submit_review_finding() with:\n"
            f"   - session_id: {session_id}\n"
            f"   - perspective: \"{perspective_key}\"\n"
            f"   - severity, category, file_path, title, description, suggestion\n"
            f"4. When done, call complete_review_perspective(session_id={session_id}, perspective=\"{perspective_key}\")\n"
            f"5. Call update_task(task_id=<your_task_id>, status='done') to mark complete\n"
            f"6. Call log_work() with a summary of your findings"
        )

        # Create the peon task
        cursor = await db.execute(
            """INSERT INTO tasks
               (project_id, title, description, priority, status,
                task_type, review_session_id, review_perspective)
               VALUES (?, ?, ?, 'high', 'todo', 'review', ?, ?)""",
            (project_id, title, description, session_id, perspective_key)
        )
        task_id = cursor.lastrowid
        created_task_ids.append(task_id)

        # Register in sub-agent registry (best-effort)
        sub_agent_id = _try_register_subagent(
            project_id=project_id,
            session_id=session_id,
            perspective_key=perspective_key,
            parent_task_id=task_id,
        )

        # Create review_agents record
        await db.execute(
            """INSERT INTO review_agents (session_id, perspective, agent_status, sub_agent_id)
               VALUES (?, ?, 'pending', ?)""",
            (session_id, perspective_key, sub_agent_id)
        )

    # Update session status to in_progress
    await db.execute(
        "UPDATE review_sessions SET status = 'in_progress', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
        (session_id,)
    )
    await db.commit()

    log.info(
        "Created %d review tasks for session %d: %s",
        len(created_task_ids), session_id, created_task_ids
    )

    return {
        "session_id": session_id,
        "status": "in_progress",
        "tasks_created": len(created_task_ids),
        "task_ids": created_task_ids,
        "perspectives": perspectives,
    }


def build_review_prompt(perspective_key: str, pr_context: dict[str, Any]) -> str:
    """Generate perspective-specific review prompt.

    Args:
        perspective_key: Key from PERSPECTIVES dict (e.g., "security")
        pr_context: Dictionary from fetch_pr_context()

    Returns:
        Complete prompt string for the review agent
    """
    if perspective_key not in PERSPECTIVES:
        raise ValueError(f"Unknown perspective: {perspective_key}")

    persp = PERSPECTIVES[perspective_key]

    # Build severity rubric text
    rubric_lines = [
        f"  - {severity}: {description}"
        for severity, description in persp["severity_rubric"].items()
    ]
    rubric_text = "\n".join(rubric_lines)

    # Build category list text
    categories_text = ", ".join(persp["categories"])

    prompt = f"""You are a {persp['name']} conducting a code review.

**PR Title:** {pr_context['title']}
**Base Branch:** {pr_context['base_ref']}
**Head Branch:** {pr_context['head_ref']}
**Files Changed:** {len(pr_context['files'])} files

**Your Focus:**
{persp['focus_areas']}

**Use ONLY these categories when filing findings:**
{categories_text}

**Severity Rubric:**
{rubric_text}

**Instructions:**
1. Review the diff below line-by-line from your perspective
2. For each issue you find, call submit_review_finding() with:
   - category: One of the approved categories above
   - severity: critical/high/medium/low based on the rubric
   - file_path: The file containing the issue
   - line_number: Approximate line number (if identifiable)
   - message: Clear description of the issue
3. When you've completed your review, call complete_review_perspective()

**CRITICAL:** Use ONLY the standardized categories listed above. Do NOT invent new categories.

---

**PR DIFF:**

{pr_context['diff']}
"""

    return prompt


async def run_review_agent(
    db: aiosqlite.Connection,
    session_id: int,
    perspective_key: str,
    pr_context: dict[str, Any],
    project_id: str = "",
    timeout: int = 600
) -> dict[str, Any]:
    """Run a single review agent for one perspective.

    Args:
        db: Database connection
        session_id: Review session ID
        perspective_key: Perspective to use (e.g., "security")
        pr_context: PR context from fetch_pr_context()
        project_id: Project identifier for sub-agent registry (optional)
        timeout: Maximum execution time in seconds

    Returns:
        Dictionary with:
            - perspective: The perspective key
            - status: "completed" | "timeout" | "error"
            - findings_count: Number of findings submitted
            - error: Error message if status == "error"
    """
    if perspective_key not in PERSPECTIVES:
        return {
            "perspective": perspective_key,
            "status": "error",
            "findings_count": 0,
            "error": f"Unknown perspective: {perspective_key}"
        }

    # Register in sub-agent registry before spawning (best-effort)
    sub_agent_id: int | None = None
    if project_id:
        sub_agent_id = _try_register_subagent(
            project_id=project_id,
            session_id=session_id,
            perspective_key=perspective_key,
            timeout_seconds=timeout,
        )

    # Build the review prompt
    prompt = build_review_prompt(perspective_key, pr_context)

    # Build MCP config giving agent access to review tools
    db_path = get_db_path()

    # Find peon-mcp binary (simplified - in production should match loop.py logic)
    import shutil
    peon_bin = shutil.which("peon-mcp")
    if not peon_bin:
        _try_update_subagent_status(sub_agent_id, "failed", result_summary="peon-mcp binary not found in PATH")
        return {
            "perspective": perspective_key,
            "status": "error",
            "findings_count": 0,
            "error": "peon-mcp binary not found in PATH"
        }

    mcp_config = {
        "mcpServers": {
            "peon-mcp": {
                "command": peon_bin,
                "env": {
                    "PEON_DB_PATH": db_path,
                    "PEON_REVIEW_SESSION_ID": str(session_id),
                    "PEON_REVIEW_PERSPECTIVE": perspective_key
                }
            }
        }
    }
    mcp_config_json = json.dumps(mcp_config)

    # Find claude binary
    claude_bin = shutil.which("claude")
    if not claude_bin:
        _try_update_subagent_status(sub_agent_id, "failed", result_summary="claude binary not found in PATH")
        return {
            "perspective": perspective_key,
            "status": "error",
            "findings_count": 0,
            "error": "claude binary not found in PATH"
        }

    # Spawn the claude agent
    try:
        proc = await asyncio.create_subprocess_exec(
            claude_bin,
            "-p", prompt,
            "--mcp-config", mcp_config_json,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE
        )

        # Mark sub-agent as running with its OS PID
        _try_update_subagent_status(sub_agent_id, "running", pid=proc.pid)

        try:
            stdout, stderr = await asyncio.wait_for(
                proc.communicate(),
                timeout=timeout
            )
            timed_out = False
        except asyncio.TimeoutError:
            # Kill the process on timeout
            proc.kill()
            await proc.wait()
            timed_out = True
            log.warning("Review agent for %s timed out after %ds", perspective_key, timeout)

            # Mark sub-agent as timed out
            _try_update_subagent_status(sub_agent_id, "timeout")

            # Mark agent status as timeout in database
            await db.execute(
                "UPDATE review_agents SET agent_status = ?, completed_at = CURRENT_TIMESTAMP WHERE session_id = ? AND perspective = ?",
                ("timeout", session_id, perspective_key)
            )
            await db.commit()

            # Count findings submitted before timeout
            cursor = await db.execute(
                "SELECT COUNT(*) as count FROM review_findings WHERE session_id = ? AND perspective = ?",
                (session_id, perspective_key)
            )
            row = await cursor.fetchone()
            findings_count = row["count"] if row else 0

            return {
                "perspective": perspective_key,
                "status": "timeout",
                "findings_count": findings_count,
                "error": f"Agent timed out after {timeout}s"
            }

    except Exception as e:
        log.error("Error running review agent for %s: %s", perspective_key, e)
        _try_update_subagent_status(sub_agent_id, "failed", result_summary=str(e))
        return {
            "perspective": perspective_key,
            "status": "error",
            "findings_count": 0,
            "error": str(e)
        }

    # Count findings submitted
    cursor = await db.execute(
        "SELECT COUNT(*) as count FROM review_findings WHERE session_id = ? AND perspective = ?",
        (session_id, perspective_key)
    )
    row = await cursor.fetchone()
    findings_count = row["count"] if row else 0

    result_summary = f"{perspective_key}: {findings_count} findings submitted"
    _try_update_subagent_status(sub_agent_id, "completed", result_summary=result_summary)

    return {
        "perspective": perspective_key,
        "status": "completed",
        "findings_count": findings_count
    }


async def compute_convergences(db: aiosqlite.Connection, session_id: int) -> int:
    """Compute convergences from review findings.

    Groups findings by (category, file_path) and creates convergence records
    where 2+ perspectives agree on the same issue.

    Args:
        db: Database connection
        session_id: Review session ID

    Returns:
        Number of convergence records created.
    """
    # Query all findings for this session, including id for tracking
    cursor = await db.execute(
        """SELECT id, category, file_path, severity, description, perspective
           FROM review_findings
           WHERE session_id = ?
           ORDER BY category, file_path""",
        (session_id,)
    )
    findings = await cursor.fetchall()

    # Group by (category, file_path)
    groups: dict[tuple[str, str], list[dict]] = {}
    for row in findings:
        key = (row["category"], row["file_path"])
        if key not in groups:
            groups[key] = []
        groups[key].append(row_to_dict(row))

    # Create convergences where 2+ distinct perspectives agree
    created = 0
    for (category, file_path), group_findings in groups.items():
        unique_perspectives = list(dict.fromkeys(f["perspective"] for f in group_findings))
        convergence_count = len(unique_perspectives)
        if convergence_count < 2:
            continue

        # Determine max severity (critical > high > medium > low)
        severity_order = {"critical": 4, "high": 3, "medium": 2, "low": 1}
        max_severity = max(
            group_findings,
            key=lambda f: severity_order.get(f["severity"], 0)
        )["severity"]

        # Build summary text — deduplicate descriptions per perspective
        seen: set[str] = set()
        messages = []
        for f in group_findings:
            if f["perspective"] not in seen:
                seen.add(f["perspective"])
                messages.append(f["description"])
        summary = (
            f"{convergence_count} reviewers flagged {category} issue in {file_path}. "
            f"Perspectives: {', '.join(unique_perspectives)}. "
            f"Details: {' | '.join(messages[:3])}"  # Limit to first 3 messages
        )

        # Collect finding IDs and perspectives contributing to this convergence
        finding_ids = [f["id"] for f in group_findings]
        perspectives_json = json.dumps(unique_perspectives)
        finding_ids_json = json.dumps(finding_ids)

        # Insert convergence with finding_ids and perspectives populated
        await db.execute(
            """INSERT INTO review_convergences
               (session_id, category, file_path, convergence_count, max_severity, summary,
                finding_ids, perspectives)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
            (session_id, category, file_path, convergence_count, max_severity, summary,
             finding_ids_json, perspectives_json)
        )
        created += 1

    await db.commit()
    log.info("Computed convergences for session %d: %d groups", session_id, len(groups))
    return created


async def run_review_session(db: aiosqlite.Connection, session_id: int) -> dict[str, Any]:
    """Main orchestrator for running a review session.

    Fetches PR context, spawns review agents for configured perspectives,
    handles partial failures, and computes convergences.

    Args:
        db: Database connection
        session_id: Review session ID

    Returns:
        Dictionary with:
            - status: "completed" | "partial" | "failed"
            - agents_completed: Number of agents that completed successfully
            - agents_failed: Number of agents that failed/timed out
            - total_findings: Total findings across all agents
            - convergences_found: Number of convergence records created
    """
    # Fetch session details
    cursor = await db.execute(
        "SELECT pr_url, perspectives, project_id FROM review_sessions WHERE id = ?",
        (session_id,)
    )
    session_row = await cursor.fetchone()
    if not session_row:
        raise ValueError(f"Review session {session_id} not found")

    pr_url = session_row["pr_url"]
    perspectives_json = session_row["perspectives"]
    perspectives = json.loads(perspectives_json) if perspectives_json else DEFAULT_PERSPECTIVES
    project_id = session_row["project_id"]

    # Update session status to in_progress
    await db.execute(
        "UPDATE review_sessions SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
        ("in_progress", session_id)
    )
    await db.commit()

    # Fetch PR context
    try:
        pr_context = await fetch_pr_context(pr_url)
    except Exception as e:
        log.error("Failed to fetch PR context: %s", e)
        await db.execute(
            "UPDATE review_sessions SET status = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?",
            ("failed", session_id)
        )
        await db.commit()
        return {
            "status": "failed",
            "agents_completed": 0,
            "agents_failed": 0,
            "total_findings": 0,
            "convergences_found": 0,
            "error": str(e)
        }

    # Create agent records (register in sub-agent registry per perspective)
    for perspective in perspectives:
        sub_agent_id = _try_register_subagent(
            project_id=project_id,
            session_id=session_id,
            perspective_key=perspective,
        )
        await db.execute(
            """INSERT INTO review_agents (session_id, perspective, agent_status, sub_agent_id)
               VALUES (?, ?, 'pending', ?)""",
            (session_id, perspective, sub_agent_id)
        )
    await db.commit()

    # Spawn agents in parallel
    tasks = [
        run_review_agent(db, session_id, perspective, pr_context, project_id=project_id)
        for perspective in perspectives
    ]
    results = await asyncio.gather(*tasks, return_exceptions=True)

    # Count successes and failures
    agents_completed = sum(
        1 for r in results
        if isinstance(r, dict) and r.get("status") == "completed"
    )
    agents_failed = len(results) - agents_completed

    # Count total findings
    cursor = await db.execute(
        "SELECT COUNT(*) as count FROM review_findings WHERE session_id = ?",
        (session_id,)
    )
    row = await cursor.fetchone()
    total_findings = row["count"] if row else 0

    # Compute convergences if we have findings
    convergences_found = 0
    if total_findings > 0:
        await compute_convergences(db, session_id)
        cursor = await db.execute(
            "SELECT COUNT(*) as count FROM review_convergences WHERE session_id = ?",
            (session_id,)
        )
        row = await cursor.fetchone()
        convergences_found = row["count"] if row else 0

    # Determine overall session status
    if agents_completed == 0:
        status = "failed"
    elif agents_failed == 0:
        status = "completed"
    else:
        status = "partial"

    # Update session
    await db.execute(
        """UPDATE review_sessions
           SET status = ?, completed_at = CURRENT_TIMESTAMP
           WHERE id = ?""",
        (status, session_id)
    )
    await db.commit()

    log.info(
        "Review session %d %s: %d/%d agents completed, %d findings, %d convergences",
        session_id, status, agents_completed, len(perspectives), total_findings, convergences_found
    )

    return {
        "status": status,
        "agents_completed": agents_completed,
        "agents_failed": agents_failed,
        "total_findings": total_findings,
        "convergences_found": convergences_found
    }


async def _update_pipeline_stage(db: aiosqlite.Connection, session_id: int, stage: str) -> None:
    """Update the pipeline_stage column on a review session."""
    await db.execute(
        "UPDATE review_sessions SET pipeline_stage = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
        (stage, session_id),
    )
    await db.commit()


async def _get_session(db: aiosqlite.Connection, session_id: int) -> dict[str, Any]:
    """Fetch a review session row as a dict. Raises ValueError if not found."""
    rows = await db.execute_fetchall(
        "SELECT * FROM review_sessions WHERE id = ?", (session_id,)
    )
    if not rows:
        raise ValueError(f"Review session {session_id} not found")
    return row_to_dict(rows[0])


async def _create_fix_task_from_finding(
    db: aiosqlite.Connection,
    session: dict[str, Any],
    finding: dict[str, Any],
) -> int:
    """Create a fix task from a standalone review finding.

    Args:
        db: Database connection
        session: Review session dict
        finding: Review finding dict

    Returns:
        Newly created task ID.
    """
    line_ref = ""
    if finding.get("line_start"):
        line_ref = f" (line {finding['line_start']}"
        if finding.get("line_end") and finding["line_end"] != finding["line_start"]:
            line_ref += f"-{finding['line_end']}"
        line_ref += ")"

    priority_map = {"critical": "critical", "high": "high", "medium": "medium"}
    priority = priority_map.get(finding["severity"], "medium")

    title = f"[Fix] {finding['title']}"
    description = (
        f"## Fix: {finding['category']} in {finding['file_path']}\n\n"
        f"**Review Session**: #{session['id']}\n"
        f"**Perspective**: {finding['perspective']}\n"
        f"**Severity**: {finding['severity']}\n"
        f"**PR**: {session['pr_url']}\n\n"
        f"### Issue\n"
        f"**File**: `{finding['file_path']}`{line_ref}\n"
        f"**Description**: {finding['description']}\n\n"
        f"### Suggestion\n"
        f"{finding['suggestion']}\n\n"
        f"### Instructions\n"
        f"1. Read the PR diff for context on what changed\n"
        f"2. Apply the suggested fix, keeping scope tight\n"
        f"3. Only fix what the finding describes — don't expand scope\n"
    )

    cursor = await db.execute(
        """INSERT INTO tasks
           (project_id, title, description, priority, status,
            task_type, review_session_id)
           VALUES (?, ?, ?, ?, 'todo', 'fix', ?)""",
        (session["project_id"], title, description, priority, session["id"]),
    )
    task_id = cursor.lastrowid

    await db.execute(
        "UPDATE review_findings SET created_task_id = ? WHERE id = ?",
        (task_id, finding["id"]),
    )
    await db.commit()

    return task_id


async def _create_fix_task_from_convergence(
    db: aiosqlite.Connection,
    session: dict[str, Any],
    convergence: dict[str, Any],
) -> int:
    """Create a fix task from a convergence, carrying full review context.

    Args:
        db: Database connection
        session: Review session dict
        convergence: Review convergence dict (must have finding_ids populated)

    Returns:
        Newly created task ID.
    """
    # Parse finding IDs from the convergence record
    raw_finding_ids = convergence.get("finding_ids", "[]")
    finding_ids: list[int] = json.loads(raw_finding_ids) if isinstance(raw_finding_ids, str) else raw_finding_ids

    # Fetch all findings that contributed to this convergence
    findings: list[dict] = []
    if finding_ids:
        placeholders = ",".join("?" * len(finding_ids))
        rows = await db.execute_fetchall(
            f"SELECT * FROM review_findings WHERE id IN ({placeholders})",
            finding_ids,
        )
        findings = [row_to_dict(r) for r in rows]

    # Build description with per-perspective findings
    description = (
        f"## Fix: {convergence['category']} in {convergence['file_path']}\n\n"
        f"**Review Session**: #{session['id']}\n"
        f"**Convergence**: {convergence['convergence_count']} perspectives flagged this issue\n"
        f"**Max Severity**: {convergence['max_severity']}\n"
        f"**PR**: {session['pr_url']}\n\n"
        f"### Findings from each perspective:\n"
    )
    for f in findings:
        line_ref = ""
        if f.get("line_start"):
            line_ref = f" L{f['line_start']}"
            if f.get("line_end") and f["line_end"] != f["line_start"]:
                line_ref += f"-L{f['line_end']}"
        description += (
            f"\n#### [{f['perspective']}] {f['title']} ({f['severity']})\n"
            f"**File**: `{f['file_path']}`{line_ref}\n"
            f"**Issue**: {f['description']}\n"
            f"**Suggestion**: {f['suggestion']}\n"
        )

    description += (
        "\n### Instructions\n"
        "1. Read the PR diff for context on what changed\n"
        "2. Apply the suggested fixes, keeping scope tight\n"
        "3. Only fix what the findings describe — don't expand scope\n"
    )

    priority_map = {"critical": "critical", "high": "high", "medium": "medium"}
    priority = priority_map.get(convergence["max_severity"], "medium")
    title = f"[Fix] {convergence['category']} in {convergence['file_path']}"

    cursor = await db.execute(
        """INSERT INTO tasks
           (project_id, title, description, priority, status,
            task_type, review_session_id)
           VALUES (?, ?, ?, ?, 'todo', 'fix', ?)""",
        (session["project_id"], title, description, priority, session["id"]),
    )
    task_id = cursor.lastrowid

    # Link convergence back to the created task
    await db.execute(
        "UPDATE review_convergences SET created_task_id = ? WHERE id = ?",
        (task_id, convergence["id"]),
    )
    await db.commit()

    return task_id


async def _generate_fix_tasks(
    db: aiosqlite.Connection,
    session: dict[str, Any],
) -> list[int]:
    """Generate fix tasks from review findings.

    Priority order:
    1. Convergences (critical/high/medium) — multiple perspectives agree, highest signal
    2. Standalone critical/high findings not already covered by a convergence

    Args:
        db: Database connection
        session: Review session dict

    Returns:
        List of newly created task IDs.
    """
    task_ids: list[int] = []
    covered_finding_ids: set[int] = set()

    # 1. Create tasks from convergences (highest signal)
    convergences = await db.execute_fetchall(
        "SELECT * FROM review_convergences WHERE session_id = ? AND created_task_id IS NULL",
        (session["id"],),
    )
    for conv in convergences:
        conv_dict = row_to_dict(conv)
        if conv_dict["max_severity"] not in ("critical", "high", "medium"):
            continue

        task_id = await _create_fix_task_from_convergence(db, session, conv_dict)
        task_ids.append(task_id)

        # Track which findings are already covered so we don't duplicate
        raw_ids = conv_dict.get("finding_ids", "[]")
        ids: list[int] = json.loads(raw_ids) if isinstance(raw_ids, str) else raw_ids
        covered_finding_ids.update(ids)

    # 2. Standalone critical/high findings not covered by any convergence
    if covered_finding_ids:
        placeholders = ",".join("?" * len(covered_finding_ids))
        exclude_clause = f"AND id NOT IN ({placeholders})"
        params: list[Any] = [session["id"], *covered_finding_ids]
    else:
        exclude_clause = ""
        params = [session["id"]]

    standalone_rows = await db.execute_fetchall(
        f"""SELECT * FROM review_findings
            WHERE session_id = ? AND created_task_id IS NULL
              AND severity IN ('critical', 'high')
              {exclude_clause}""",
        params,
    )
    for row in standalone_rows:
        finding = row_to_dict(row)
        task_id = await _create_fix_task_from_finding(db, session, finding)
        task_ids.append(task_id)

    return task_ids


async def _try_advance_from_reviewing(
    db: aiosqlite.Connection,
    session: dict[str, Any],
) -> dict[str, Any]:
    """Try to advance pipeline from 'reviewing' to the next stage.

    Checks if all perspective agents are done. If so, computes convergences
    and (depending on fix_policy) generates fix tasks or advances to verifying.

    Args:
        db: Database connection
        session: Review session dict (must include 'id', 'fix_policy')

    Returns:
        Dict with keys: advanced, from_stage, to_stage (when advanced),
        reason (when not advanced), tasks_created (when advanced to fixing).
    """
    session_id = session["id"]

    # Check all perspective agents are terminal
    agents = await db.execute_fetchall(
        "SELECT agent_status FROM review_agents WHERE session_id = ?",
        (session_id,),
    )
    if not agents:
        return {"advanced": False, "reason": "no perspective agents registered"}

    pending = [
        a for a in agents
        if a["agent_status"] not in ("completed", "failed", "timeout")
    ]
    if pending:
        return {"advanced": False, "reason": "perspectives still running"}

    # Compute convergences
    await compute_convergences(db, session_id)

    fix_policy = session.get("fix_policy") or "auto"

    if fix_policy == "skip":
        await _update_pipeline_stage(db, session_id, "verifying")
        return {"advanced": True, "from_stage": "reviewing", "to_stage": "verifying"}

    if fix_policy == "manual":
        # Convergences computed but no auto-creation; advance to fixing stage
        await _update_pipeline_stage(db, session_id, "fixing")
        return {"advanced": True, "from_stage": "reviewing", "to_stage": "fixing", "tasks_created": 0}

    # fix_policy == 'auto'
    tasks_created = await _generate_fix_tasks(db, session)
    if not tasks_created:
        # No actionable findings — nothing to fix, advance to verifying
        await _update_pipeline_stage(db, session_id, "verifying")
        return {"advanced": True, "from_stage": "reviewing", "to_stage": "verifying", "tasks_created": 0}

    await _update_pipeline_stage(db, session_id, "fixing")
    return {
        "advanced": True,
        "from_stage": "reviewing",
        "to_stage": "fixing",
        "tasks_created": len(tasks_created),
    }


async def advance_pipeline(db: aiosqlite.Connection, session_id: int) -> dict[str, Any]:
    """Check if the current pipeline stage is complete and advance to the next.

    Called after any stage-relevant event (perspective complete, fix task done,
    verify done). Handles the full lifecycle:
      reviewing → fixing → verifying → done

    Args:
        db: Database connection
        session_id: Review session ID

    Returns:
        Dict with keys:
          - advanced (bool): whether the pipeline advanced
          - from_stage / to_stage: stage transition (when advanced)
          - reason: why it didn't advance (when not advanced)
          - tasks_created: count of fix tasks created (reviewing → fixing)
    """
    session = await _get_session(db, session_id)
    stage = session.get("pipeline_stage") or "reviewing"

    if stage == "reviewing":
        return await _try_advance_from_reviewing(db, session)

    # Future stages (fixing → verifying → done) are handled by later tasks.
    # Return a stable "not yet implemented" response so callers don't break.
    return {"advanced": False, "reason": f"stage '{stage}' advancement not yet implemented"}
