"""
File review commands for pull request code reviews.

These commands handle file-level review actions: approve, request changes, etc.
Each command:
1. Posts a review comment (approve/request changes)
2. Marks the file as reviewed in Azure DevOps
3. Updates the review queue and triggers workflow continuation
"""

import copy
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING

from ...state import get_pull_request_id, get_state_dir, get_value, is_dry_run
from .auth import get_auth_headers, get_pat
from .config import AzureDevOpsConfig
from .helpers import (
    get_repository_id,
    patch_comment,
    patch_thread_status,
    require_requests,
    resolve_review_artifact_dir_name,
)
from .mark_reviewed import mark_file_reviewed

if TYPE_CHECKING:
    from .review_state import ReviewState


def _normalize_repo_path(path: str | None) -> str | None:
    """Normalize a file path to repository format (/path/to/file)."""
    if not path or not path.strip():
        return None
    clean = path.strip().replace("\\", "/").strip("/")
    if not clean:  # pragma: no cover
        return None
    return f"/{clean}"


def _get_attribution_params(
    review_state: "ReviewState",
    config: AzureDevOpsConfig,
    file_path: str | None = None,
) -> dict[str, str | None]:
    """Extract attribution parameters from ReviewState for comment rendering.

    Returns a dict with keys ``model_name``, ``commit_hash``, and ``commit_url``
    that can be unpacked as keyword arguments into ``render_file_summary()`` and
    ``render_overall_summary()``.  Any value may be ``None`` when the required
    data is absent (e.g. no sessions recorded, no commit hash stored yet).

    Args:
        review_state: Current ReviewState instance.
        config: AzureDevOpsConfig with org/project/repository.
        file_path: Optional file path for per-file URL (overall PR URL used when absent).

    Returns:
        Dict with ``model_name``, ``commit_hash``, and ``commit_url``.
    """
    from .review_attribution import build_commit_file_url, build_commit_pr_url
    from .review_state import normalize_file_path

    # Prefer the latest session model ID when available, but fall back to the
    # top-level ReviewState.modelId for backward/partial states where sessions
    # may be empty while the model is still known.
    model_name: str | None
    if getattr(review_state, "sessions", None):
        model_name = review_state.sessions[-1].modelId
    else:
        model_name = getattr(review_state, "modelId", None)

    commit_hash = review_state.commitHash
    commit_url: str | None = None
    if commit_hash and review_state.latestIterationId:
        try:
            if file_path:
                normalized = normalize_file_path(file_path)
                commit_url = build_commit_file_url(
                    config.organization,
                    config.project,
                    config.repository,
                    review_state.prId,
                    normalized,
                    review_state.latestIterationId,
                )
            else:
                commit_url = build_commit_pr_url(
                    config.organization,
                    config.project,
                    config.repository,
                    review_state.prId,
                    review_state.latestIterationId,
                )
        except Exception:
            commit_url = None
    return {"model_name": model_name, "commit_hash": commit_hash, "commit_url": commit_url}


def _get_thread_file_path(thread: dict) -> str | None:
    """Extract the file path from a thread's context."""
    context = thread.get("threadContext")
    if not context:
        return None

    raw_path = (
        context.get("filePath")
        or (context.get("leftFileStart") or {}).get("filePath")
        or (context.get("rightFileStart") or {}).get("filePath")
    )

    if not raw_path:  # pragma: no cover
        return None
    return raw_path.replace("\\", "/").lstrip("/")


# =============================================================================
# Queue Management
# =============================================================================


def _get_queue_path(pull_request_id: int) -> Path:
    """Get the path to the queue.json file for a pull request.

    Returns the new commit-hash-scoped path
    (``pull-request-review/<dir_name>/queue.json``).  If that file does not yet
    exist but the legacy path (``pull-request-review/prompts/<pr_id>/queue.json``)
    does, the legacy path is returned instead so that in-progress review sessions
    created before the commit-hash-short refactor continue to work transparently
    after an upgrade.
    """
    commit_hash_short = get_value("review.commit_hash_short")
    # Suppress warnings here: this function is called many times per review command.
    # Warnings about a missing/unsafe commit hash are more useful at setup time
    # (generate_review_prompts / checkout_and_sync_branch) than repeated per queue op.
    dir_name = resolve_review_artifact_dir_name(pull_request_id, commit_hash_short, warn=False)
    state_dir = get_state_dir()
    new_path = state_dir / "pull-request-review" / dir_name / "queue.json"
    # Backward-compatible fallback: silently use the legacy path when the new one
    # is absent but an old-format queue still exists.  This prevents silent queue
    # loss (empty/default status) when upgrading mid-review.
    if not new_path.exists():
        legacy_path = state_dir / "pull-request-review" / "prompts" / str(pull_request_id) / "queue.json"
        if legacy_path.exists():
            return legacy_path
    return new_path


def trigger_in_progress_for_file(
    pull_request_id: int,
    file_path: str,
    dry_run: bool = False,
) -> None:
    """
    Trigger "In Progress" status for a file when its review prompt is served.

    If the file's status in review-state.json is "unreviewed", this function:
    1. Updates the file status to "in-progress"
    2. PATCHes the file summary comment with the "In Progress" template
    3. Recalculates folder status and PATCHes if changed
    4. Recalculates overall PR status and PATCHes if changed
    5. Saves updated review-state.json

    If the file status is already "in-progress", "approved", or "needs-work" → no-op.

    Args:
        pull_request_id: PR ID
        file_path: File path to trigger status for
        dry_run: If True, skip API calls and print dry-run messages
    """
    from .review_scaffold import _build_pr_base_url
    from .review_state import (
        ReviewStatus,
        load_review_state,
        normalize_file_path,
        save_review_state,
        update_file_status,
    )
    from .review_templates import render_file_summary
    from .status_cascade import cascade_status_update, execute_cascade

    try:
        review_state = load_review_state(pull_request_id)
    except FileNotFoundError:
        return  # No review state yet; skip

    normalized = normalize_file_path(file_path)
    file_entry = review_state.files.get(normalized)
    if file_entry is None:
        return  # File not in review state; skip

    if file_entry.status != ReviewStatus.UNREVIEWED.value:
        return  # No-op for non-unreviewed files

    # Update file status to "in-progress"
    update_file_status(review_state, file_path, ReviewStatus.IN_PROGRESS.value)
    # Refresh local reference after in-place mutation
    file_entry = review_state.files[normalized]

    config = AzureDevOpsConfig.from_state()
    base_url = _build_pr_base_url(config, pull_request_id)

    # Use repoId already stored in review state (set during scaffolding)
    # to avoid shelling out to `az repos show` on every prompt.
    repo_id = review_state.repoId

    if dry_run:
        requests_module = None
        auth_headers: dict = {}
    else:
        requests_module = require_requests()
        auth_headers = get_auth_headers(get_pat())

    # PATCH file summary comment content; file thread status stays "active" per spec
    attrs = _get_attribution_params(review_state, config, file_path=file_path)

    # Ensure the current model's verdict reflects that review is in progress.
    # attrs["model_name"] holds the model ID derived from the review session.
    model_id = attrs.get("model_name")
    if model_id is not None:
        current_verdict = file_entry.get_model_verdict(model_id)
        if current_verdict is not None:
            current_verdict.status = ReviewStatus.IN_PROGRESS.value
            current_verdict.verdictType = None

    file_content = render_file_summary(file_entry, file_entry.suggestions, base_url, **attrs)
    patch_comment(
        requests_module=requests_module,
        headers=auth_headers,
        config=config,
        repo_id=repo_id,
        pull_request_id=pull_request_id,
        thread_id=file_entry.threadId,
        comment_id=file_entry.commentId,
        new_content=file_content,
        dry_run=dry_run,
    )

    # Cascade folder and overall summary updates. Persist the updated
    # review_state even if downstream cascade execution fails, so the
    # local state reflects the already-PATCHed file comment.
    try:
        cascade_attrs = _get_attribution_params(review_state, config)
        ops = cascade_status_update(review_state, file_path, base_url, **cascade_attrs)
        execute_cascade(ops, requests_module, auth_headers, config, repo_id, pull_request_id, dry_run=dry_run)
    finally:
        if not dry_run:
            save_review_state(review_state)


class _NoChange(Exception):
    """Sentinel raised inside read_modify_write_review_state to skip save."""


def _complete_active_session(pull_request_id: int) -> None:
    """Mark the latest in-progress review session as completed.

    Uses ``read_modify_write_review_state`` for atomic load-mutate-save
    under an exclusive file lock.  If no in-progress session exists the
    call is a no-op — a ``_NoChange`` sentinel is raised inside the
    context manager so the file is not rewritten needlessly.

    Silently returns when ``review-state.json`` does not exist (matching
    the pattern in ``trigger_in_progress_for_file``).

    Args:
        pull_request_id: PR ID whose review state to update.
    """
    from .review_state import read_modify_write_review_state

    try:
        with read_modify_write_review_state(pull_request_id) as review_state:
            # Iterate in reverse to find the latest in-progress session
            for session in reversed(review_state.sessions):
                if session.status == "in_progress":
                    session.status = "completed"
                    session.completedUtc = datetime.now(timezone.utc).isoformat()
                    break
            else:
                # No in-progress session found — skip the save.
                raise _NoChange
    except (FileNotFoundError, _NoChange):
        return


def print_next_file_prompt(pull_request_id: int) -> None:
    """
    Print the prompt for the next file to review.

    This is the main function called after a file review to continue the workflow.
    It shows the next pending file or a completion message.

    Args:
        pull_request_id: PR ID
    """
    # Get queue status
    status = get_queue_status(pull_request_id)

    # Trigger "In Progress" when serving the next file's prompt
    if not status["all_complete"] and status["current_file"]:
        try:
            trigger_in_progress_for_file(
                pull_request_id=pull_request_id,
                file_path=status["current_file"],
                dry_run=is_dry_run(),
            )
        except Exception as e:
            print(f"Warning: Could not trigger in-progress status: {e}", file=sys.stderr)

    print("")
    print("=" * 60)

    if status["all_complete"]:
        # Mark the active review session as completed (skipped in dry-run mode)
        if not is_dry_run():
            try:
                _complete_active_session(pull_request_id)
            except Exception as e:
                print(f"Warning: Could not complete review session: {e}", file=sys.stderr)

        # All files reviewed
        print("ALL FILES REVIEWED - READY FOR DECISION")
        print("=" * 60)
        print("")
        print(f"Total files reviewed: {status['completed_count']}")
        print("")
        print("The file review queue is complete.")
        print("")
        print("YOUR NEXT ACTION: Run agdt-advance-workflow decision")
        print("")
        print("This will advance the workflow to the decision step.")
    else:
        print(f"QUEUE STATUS: {status['completed_count']} completed, {status['pending_count']} pending")
        print("=" * 60)
        print("")

        if status["current_file"] and status["prompt_file_path"]:  # pragma: no cover
            print(f"Next file: {status['current_file']}")
            print(f"Prompt: {status['prompt_file_path']}")
        else:
            print("Continue with the next file in the queue.")
            print(f"Queue location: {_get_queue_path(pull_request_id)}")

    print("")


def _update_queue_after_review(
    pull_request_id: int,
    file_path: str,
    outcome: str,
    dry_run: bool = False,
) -> tuple[int, int]:
    """
    Update the review queue after completing a file review.

    Moves the file from pending (or submission-pending) to completed,
    updating status and timestamp. Also cleans up any task tracking fields.

    Args:
        pull_request_id: PR ID
        file_path: Path of reviewed file
        outcome: Review outcome ('Approve', 'Changes', 'Suggest')
        dry_run: If True, only print what would be done

    Returns:
        Tuple of (pending_count, completed_count) after update
    """
    queue_path = _get_queue_path(pull_request_id)

    if not queue_path.exists():
        print(f"Queue file not found at {queue_path}; skipping queue update.")
        return 0, 0

    try:
        with open(queue_path, encoding="utf-8") as f:
            queue_data = json.load(f)
    except (json.JSONDecodeError, OSError) as e:  # pragma: no cover
        print(f"Warning: Failed to read queue file: {e}")
        return 0, 0

    normalized_target = _normalize_repo_path(file_path)
    if not normalized_target:  # pragma: no cover
        return len(queue_data.get("pending", [])), len(queue_data.get("completed", []))

    pending = queue_data.get("pending", [])
    completed = queue_data.get("completed", [])

    # Find matching entry in pending (handles both "pending" and "submission-pending" status)
    matched_entry = None
    remaining_pending = []

    for entry in pending:
        entry_normalized = _normalize_repo_path(entry.get("path"))
        if entry_normalized and entry_normalized.lower() == normalized_target.lower():
            matched_entry = entry
        else:
            remaining_pending.append(entry)

    if not matched_entry:
        # Idempotent: if the file is already in the completed list (moved by
        # the async wrapper), silently return current counts.  If it's not in
        # completed either, emit a warning so bad inputs are detectable.
        already_completed = any(
            (_normalize_repo_path(e.get("path")) or "").lower() == normalized_target.lower() for e in completed
        )
        if not already_completed:
            print(
                f"Warning: File '{file_path}' (normalized: '{normalized_target}') "
                "not found in pending or completed queue.",
                file=sys.stderr,
            )
        return len(pending), len(completed)

    if dry_run:
        print(f"DRY-RUN: Would move '{file_path}' from pending to completed.")
        return len(pending) - 1, len(completed) + 1

    # Update the entry - clean up any submission tracking fields
    matched_entry["status"] = "completed"
    matched_entry["outcome"] = outcome
    matched_entry["completedUtc"] = datetime.now(timezone.utc).isoformat()

    # Remove submission tracking fields if present
    matched_entry.pop("taskId", None)
    matched_entry.pop("submittedUtc", None)
    matched_entry.pop("failedUtc", None)
    matched_entry.pop("errorMessage", None)

    completed.append(matched_entry)

    # Update queue data
    queue_data["pending"] = remaining_pending
    queue_data["completed"] = completed
    queue_data["lastUpdatedUtc"] = datetime.now(timezone.utc).isoformat()

    try:
        with open(queue_path, "w", encoding="utf-8") as f:
            json.dump(queue_data, f, indent=2)
    except OSError as e:  # pragma: no cover
        print(f"Warning: Failed to write queue file: {e}")

    return len(remaining_pending), len(completed)


def get_queue_status(pull_request_id: int) -> dict:
    """
    Get the current status of the file review queue.

    This function reads the queue.json file and returns status information
    suitable for populating workflow prompt templates.

    Args:
        pull_request_id: PR ID to get queue status for

    Returns:
        Dictionary with queue status:
        - pull_request_id: The PR ID
        - completed_count: Number of files reviewed
        - pending_count: Number of files still pending
        - total_count: Total number of files
        - current_file: Path of next file to review (or None if done)
        - prompt_file_path: Full path to the current file's prompt (or None)
        - all_complete: True if all files have been reviewed
    """
    queue_path = _get_queue_path(pull_request_id)

    result = {
        "pull_request_id": pull_request_id,
        "completed_count": 0,
        "pending_count": 0,
        "total_count": 0,
        "current_file": None,
        "prompt_file_path": None,
        "all_complete": False,
    }

    if not queue_path.exists():
        return result

    try:
        with open(queue_path, encoding="utf-8") as f:
            queue_data = json.load(f)
    except (json.JSONDecodeError, OSError):  # pragma: no cover
        return result

    pending = queue_data.get("pending", [])
    completed = queue_data.get("completed", [])

    result["pending_count"] = len(pending)
    result["completed_count"] = len(completed)
    result["total_count"] = len(pending) + len(completed)

    # All complete when no pending files remain
    result["all_complete"] = len(pending) == 0

    # Get the next file to review
    if pending:
        next_file = pending[0]
        file_path = next_file.get("path", "")
        result["current_file"] = file_path

        # Use the prompt path from the queue item (already computed during prompt generation)
        prompt_path = next_file.get("promptPath", "")
        if prompt_path and Path(prompt_path).exists():  # pragma: no cover
            result["prompt_file_path"] = prompt_path

    return result


def _resolve_file_threads(
    requests,
    headers: dict,
    config: AzureDevOpsConfig,
    repo_id: str,
    pull_request_id: int,
    target_path: str,
    dry_run: bool = False,
) -> int:
    """
    Resolve (close) all active threads for a specific file.

    Returns:
        Number of threads resolved.
    """
    normalized_target = _normalize_repo_path(target_path)
    if not normalized_target:
        return 0

    project_encoded = config.project.replace(" ", "%20")
    threads_url = (
        f"{config.organization}/{project_encoded}/_apis/git/repositories/{repo_id}"
        f"/pullRequests/{pull_request_id}/threads?api-version=7.1-preview.1"
    )

    try:
        response = requests.get(threads_url, headers=headers, timeout=30)
        response.raise_for_status()
        threads_data = response.json()
    except Exception as e:
        print(f"Warning: Failed to retrieve threads: {e}", file=sys.stderr)
        return 0

    thread_items = threads_data.get("value", [])
    matching = []

    for thread in thread_items:
        status = thread.get("status")
        if status not in ("active", "pending"):
            continue

        thread_path = _get_thread_file_path(thread)
        if not thread_path:  # pragma: no cover
            continue

        normalized_thread = _normalize_repo_path(thread_path)
        if normalized_thread and normalized_thread.lower() == normalized_target.lower():
            matching.append(thread)

    if not matching:
        print(f"No unresolved comment threads to resolve for '{target_path}'.")
        return 0

    print(f"Resolving {len(matching)} thread(s) for '{target_path}'...")

    resolved_count = 0
    for thread in matching:
        thread_id = thread.get("id")
        if not thread_id:  # pragma: no cover
            continue

        if dry_run:
            print(f"DRY-RUN: Would resolve thread {thread_id} for {target_path}")
            resolved_count += 1
            continue

        resolve_url = (
            f"{config.organization}/{project_encoded}/_apis/git/repositories/{repo_id}"
            f"/pullRequests/{pull_request_id}/threads/{thread_id}?api-version=7.1-preview.1"
        )
        try:
            resp = requests.patch(resolve_url, headers=headers, json={"status": "closed"}, timeout=30)
            resp.raise_for_status()
            resolved_count += 1
        except Exception as e:
            print(f"Failed to resolve thread {thread_id}: {e}", file=sys.stderr)

    if not dry_run and resolved_count > 0:
        print(f"Comment threads for '{target_path}' resolved.")

    return resolved_count


def approve_file() -> None:  # pragma: no cover
    """
    Approve a file in a pull request review.

    When review-state.json exists (new PATCH flow):
    1. Loads review state and updates file status to Approved
    2. PATCHes the existing file summary comment with rendered markdown
    3. PATCHes the file thread status to "closed"
    4. Marks the file as reviewed in Azure DevOps
    5. Cascades folder and PR summary updates via PATCH
    6. Saves updated review state
    7. Updates the review queue and triggers workflow continuation

    When review-state.json does not exist (legacy fallback):
    1. Resolves any existing threads for the file
    2. Posts a new approval comment
    3. Marks the file as reviewed and updates the queue

    State keys read:
        - pull_request_id (required): Pull request ID
        - file_review.file_path (required): Path of file to approve
        - file_review.summary (required): Approval summary text
        - content (deprecated): Falls back to file_review.summary with a warning
        - dry_run: If true, only print what would be done

    Raises:
        SystemExit: On validation or execution errors.
    """
    requests = require_requests()
    config = AzureDevOpsConfig.from_state()
    dry_run = is_dry_run()
    pull_request_id = get_pull_request_id(required=True)

    file_path = get_value("file_review.file_path")
    if not file_path:
        print("Error: 'file_review.file_path' is required.", file=sys.stderr)
        print("Set it with: agdt-set file_review.file_path <path>", file=sys.stderr)
        sys.exit(1)

    # Support file_review.summary (new) with content as deprecated fallback
    summary = get_value("file_review.summary")
    if not summary:
        content = get_value("content")
        if content:
            print(
                "Warning: 'content' is deprecated for agdt-approve-file. Use 'file_review.summary' instead.",
                file=sys.stderr,
            )
            summary = content
    if not summary:
        print(
            "Error: 'file_review.summary' (or 'content', deprecated) is required for approval.",
            file=sys.stderr,
        )
        print("Set it with: agdt-set file_review.summary '<approval summary>'", file=sys.stderr)
        sys.exit(1)

    if dry_run:
        print(f"DRY-RUN: Would approve file '{file_path}' on PR {pull_request_id}")
        print(f"  Organization: {config.organization}")
        print(f"  Project: {config.project}")
        print(f"  Repository: {config.repository}")
        print(f"Summary:\n{summary}")
        return

    pat = get_pat()
    headers = get_auth_headers(pat)

    # Try new PATCH flow (requires review-state.json)
    try:
        from .review_scaffold import _build_pr_base_url
        from .review_state import (
            ReviewStatus,
            clear_suggestions_for_re_review,
            load_review_state,
            normalize_file_path,
            save_review_state,
            update_file_status,
        )
        from .review_templates import render_file_summary
        from .status_cascade import cascade_status_update, execute_cascade

        review_state = load_review_state(pull_request_id)
        base_url = _build_pr_base_url(config, pull_request_id)

        # Use repoId already stored in review state (set during scaffolding)
        # to avoid shelling out to `az repos show` on every approval.
        repo_id = review_state.repoId

        # Re-review detection: if the file already has a terminal status and
        # previousSuggestions hasn't been set yet, this is a fresh re-review.
        # Rotate old suggestions to the audit trail before approving.
        normalized = normalize_file_path(file_path)
        try:
            clear_suggestions_for_re_review(review_state, file_path)
        except KeyError:
            pass  # handled by the KeyError catch on update_file_status below

        # Update file status to Approved with summary text
        try:
            update_file_status(review_state, file_path, ReviewStatus.APPROVED.value, summary=summary)
            file_entry = review_state.files[normalized]
        except KeyError:
            print(
                f"Error: File {file_path!r} is not present in review-state.json "
                f"for pull request {pull_request_id}. "
                "Regenerate the review state (for example by re-running the review command) "
                "before using this file-level command.",
                file=sys.stderr,
            )
            sys.exit(1)

        # Record verdict for model progress table; skip when no model ID is known
        # to avoid polluting the review state with a spurious "unknown" row.
        from .verdict_protocol import VerdictType, record_verdict

        if review_state.sessions:
            model_id = review_state.sessions[-1].modelId
        else:
            model_id = getattr(review_state, "modelId", None)
        if model_id:
            record_verdict(file_entry, model_id, VerdictType.AGREE, ReviewStatus.APPROVED.value)

        # PATCH file summary comment with regenerated markdown
        attrs = _get_attribution_params(review_state, config, file_path=file_path)
        file_content = render_file_summary(file_entry, [], base_url, **attrs)
        patch_comment(
            requests_module=requests,
            headers=headers,
            config=config,
            repo_id=repo_id,
            pull_request_id=pull_request_id,
            thread_id=file_entry.threadId,
            comment_id=file_entry.commentId,
            new_content=file_content,
            dry_run=dry_run,
        )

        # PATCH file thread status to closed
        patch_thread_status(
            requests_module=requests,
            headers=headers,
            config=config,
            repo_id=repo_id,
            pull_request_id=pull_request_id,
            thread_id=file_entry.threadId,
            status="closed",
            dry_run=dry_run,
        )

        # Mark file as reviewed in Azure DevOps
        mark_file_reviewed(
            file_path=file_path,
            pull_request_id=pull_request_id,
            config=config,
            repo_id=repo_id,
            dry_run=dry_run,
        )

        # Cascade folder and overall summary updates. Persist the updated
        # review_state even if downstream cascade execution fails, so the
        # local state reflects the already-PATCHed file comment.
        try:
            cascade_attrs = _get_attribution_params(review_state, config)
            patch_operations = cascade_status_update(review_state, file_path, base_url, **cascade_attrs)
            execute_cascade(
                patch_operations=patch_operations,
                requests_module=requests,
                headers=headers,
                config=config,
                repo_id=repo_id,
                pull_request_id=pull_request_id,
                dry_run=dry_run,
            )
        finally:
            save_review_state(review_state)

    except FileNotFoundError:
        # Legacy fallback: create new thread (no review-state.json available)
        print("Note: No review-state.json found. Using legacy approval flow.")
        from ...state import set_value
        from .commands import add_pull_request_comment  # Avoid circular import

        print(f"Resolving repository ID for '{config.repository}'...")
        repo_id = get_repository_id(config.organization, config.project, config.repository)

        # Step 1: Resolve any existing threads for this file
        _resolve_file_threads(requests, headers, config, repo_id, pull_request_id, file_path, dry_run)

        # Step 2: Post approval comment
        set_value("path", file_path)
        set_value("content", summary)
        set_value("is_pull_request_approval", "true")
        set_value("leave_thread_active", "false")

        add_pull_request_comment()

        # Step 3: Mark file as reviewed in Azure DevOps
        mark_file_reviewed(
            file_path=file_path,
            pull_request_id=pull_request_id,
            config=config,
            repo_id=repo_id,
            dry_run=dry_run,
        )

    # Update the review queue
    _update_queue_after_review(
        pull_request_id=pull_request_id,
        file_path=file_path,
        outcome="Approve",
        dry_run=dry_run,
    )

    print(f"File '{file_path}' approved successfully.")

    # Show next file prompt
    print_next_file_prompt(pull_request_id)


def request_changes() -> None:
    """
    Request changes on a file in a pull request review with multiple suggestions.

    When review-state.json exists (new PATCH flow):
    1. Parses each suggestion from file_review.suggestions JSON array
    2. POSTs a separate line-anchored thread for each suggestion
    3. Stores suggestion thread IDs in review state
    4. PATCHes the file summary comment with categorized suggestion links
    5. Sets file thread status to "active" (needs work)
    6. Marks the file as reviewed in Azure DevOps
    7. Cascades folder and PR summary updates via PATCH
    8. Saves updated review state
    9. Updates the review queue and triggers workflow continuation

    When review-state.json does not exist (legacy fallback):
    1. Posts the file-level summary comment
    2. Posts each suggestion as a separate line-anchored review comment
    3. Marks the file as reviewed and updates the queue

    State keys read:
        - pull_request_id (required): Pull request ID
        - file_review.file_path (required): Path of file to review
        - file_review.summary (required): Summary of changes (overall assessment)
        - file_review.suggestions (required): JSON array of suggestion objects.
          Each object must have: content (str), line (int), severity (str: high/medium/low).
          Optional fields: end_line (int | None), out_of_scope (bool), link_text (str | None).
          When end_line is null or omitted, it is treated as equal to line.
        - dry_run: If true, only print what would be done

    Suggestion schema:
        {
            "content": str,                 # Review comment text (required)
            "line": int,                    # Start line (required)
            "end_line": int | None,         # Optional end line; when null/omitted, defaults to line
            "severity": str,                # "high" | "medium" | "low" (required)
            "out_of_scope": bool,           # Default false
            "link_text": str | None         # Optional; custom link text. Default: "line X" or "lines X - Y"
        }

    Raises:
        KeyError: If pull_request_id is not set in state.
        SystemExit: On validation or execution errors.
    """
    requests = require_requests()
    config = AzureDevOpsConfig.from_state()
    dry_run = is_dry_run()
    pull_request_id = get_pull_request_id(required=True)

    file_path = get_value("file_review.file_path")
    if not isinstance(file_path, str) or not file_path.strip():
        print(
            "Error: 'file_review.file_path' is required and must be a non-empty string.",
            file=sys.stderr,
        )
        print("Set it with: agdt-set file_review.file_path <path>", file=sys.stderr)
        sys.exit(1)

    summary = get_value("file_review.summary")
    if not isinstance(summary, str) or not summary.strip():
        print(
            "Error: 'file_review.summary' is required for request-changes and must be a non-empty string.",
            file=sys.stderr,
        )
        print("Set it with: agdt-set file_review.summary '<overall assessment>'", file=sys.stderr)
        sys.exit(1)

    suggestions_raw = get_value("file_review.suggestions")
    if not suggestions_raw:
        print("Error: 'file_review.suggestions' is required.", file=sys.stderr)
        print(
            'Set it with: agdt-set file_review.suggestions \'[{"line":42,"severity":"high","content":"..."}]\'',
            file=sys.stderr,
        )
        sys.exit(1)

    # Parse suggestions JSON
    if isinstance(suggestions_raw, str):
        try:
            suggestions_data = json.loads(suggestions_raw)
        except json.JSONDecodeError as e:
            print(f"Error: 'file_review.suggestions' is not valid JSON: {e}", file=sys.stderr)
            sys.exit(1)
    else:
        suggestions_data = suggestions_raw

    if not isinstance(suggestions_data, list):
        print("Error: 'file_review.suggestions' must be a JSON array (list) of suggestion objects.", file=sys.stderr)
        sys.exit(1)

    if not suggestions_data:
        print("Error: 'file_review.suggestions' must contain at least one suggestion.", file=sys.stderr)
        sys.exit(1)

    # Validate each suggestion object
    valid_severities = ("high", "medium", "low")
    for i, s in enumerate(suggestions_data):
        if not isinstance(s, dict):
            print(f"Error: Suggestion at index {i} is not an object.", file=sys.stderr)
            sys.exit(1)
        for required_field in ("content", "line", "severity"):
            if required_field not in s:
                print(f"Error: Suggestion at index {i} is missing required field '{required_field}'.", file=sys.stderr)
                sys.exit(1)
        # Validate content is a non-empty string
        if not isinstance(s["content"], str) or not s["content"].strip():
            print(f"Error: Suggestion at index {i} field 'content' must be a non-empty string.", file=sys.stderr)
            sys.exit(1)
        # Validate severity is a string with a known value
        if not isinstance(s["severity"], str) or s["severity"] not in valid_severities:
            print(
                f"Error: Suggestion at index {i} has invalid severity {s['severity']!r}. "
                f"Must be one of: {', '.join(valid_severities)}.",
                file=sys.stderr,
            )
            sys.exit(1)
        # Validate line fields are true integers >= 1 (reject float, bool, etc.)
        for int_field in ("line", "end_line"):
            if int_field in s:
                val = s[int_field]
                # Treat None as absent only for end_line; for line it is an error
                if val is None:
                    if int_field == "end_line":
                        del s[int_field]
                        continue
                    print(
                        f"Error: Suggestion at index {i} field 'line' must not be null.",
                        file=sys.stderr,
                    )
                    sys.exit(1)
                if isinstance(val, bool) or not isinstance(val, int):
                    print(
                        f"Error: Suggestion at index {i} field '{int_field}' must be an integer "
                        f"(got {val!r} of type {type(val).__name__}).",
                        file=sys.stderr,
                    )
                    sys.exit(1)
                if val < 1:
                    print(
                        f"Error: Suggestion at index {i} field '{int_field}' must be >= 1 (got {val}).",
                        file=sys.stderr,
                    )
                    sys.exit(1)
        # Validate end_line >= line when both present
        line_val = s["line"]
        end_line_val = s.get("end_line", line_val)
        if end_line_val < line_val:
            print(
                f"Error: Suggestion at index {i} has end_line ({end_line_val}) < line ({line_val}).",
                file=sys.stderr,
            )
            sys.exit(1)
        # Validate out_of_scope is a bool (not a truthy string)
        if "out_of_scope" in s and not isinstance(s["out_of_scope"], bool):
            print(
                f"Error: Suggestion at index {i} field 'out_of_scope' must be a boolean "
                f"(got {s['out_of_scope']!r} of type {type(s['out_of_scope']).__name__}).",
                file=sys.stderr,
            )
            sys.exit(1)
        # Validate link_text is a non-empty string when present; treat null/blank as absent
        if "link_text" in s:
            if s["link_text"] is None:
                del s["link_text"]
            elif not isinstance(s["link_text"], str):
                print(
                    f"Error: Suggestion at index {i} field 'link_text' must be a string.",
                    file=sys.stderr,
                )
                sys.exit(1)
            elif not s["link_text"].strip():
                del s["link_text"]

    if dry_run:
        print(f"DRY-RUN: Would request changes on '{file_path}' on PR {pull_request_id}")
        print(f"  Organization: {config.organization}")
        print(f"  Project: {config.project}")
        print(f"  Repository: {config.repository}")
        print(f"  Summary: {summary}")
        print(f"  Suggestions ({len(suggestions_data)}):")
        for i, s in enumerate(suggestions_data):
            line = s["line"]
            end_line = s.get("end_line", line)
            severity = s["severity"]
            scope_tag = " [out of scope]" if s.get("out_of_scope", False) else ""
            range_str = f"lines {line} - {end_line}" if end_line != line else f"line {line}"
            print(f"    {i + 1}. [{severity.upper()}]{scope_tag} {range_str}: {s['content'][:60]}")
        return

    pat = get_pat()
    headers = get_auth_headers(pat)

    # Try new PATCH flow (requires review-state.json)
    try:
        from .helpers import build_thread_context
        from .review_scaffold import _build_pr_base_url
        from .review_state import (
            ReviewStatus,
            SuggestionEntry,
            add_suggestion_to_file,
            clear_suggestions_for_re_review,
            load_review_state,
            normalize_file_path,
            save_review_state,
            update_file_status,
        )
        from .review_templates import render_file_summary
        from .status_cascade import cascade_status_update, execute_cascade

        review_state = load_review_state(pull_request_id)
        base_url = _build_pr_base_url(config, pull_request_id)

        # Use repoId already stored in review state (set during scaffolding)
        repo_id = review_state.repoId

        # Verify file is tracked in review state
        normalized = normalize_file_path(file_path)
        if normalized not in review_state.files:
            print(
                f"Error: File {file_path!r} is not present in review-state.json "
                f"for pull request {pull_request_id}. "
                "Regenerate the review state (for example by re-running the review command) "
                "before using this file-level command.",
                file=sys.stderr,
            )
            sys.exit(1)

        # Re-review detection: if the file already has a terminal status and
        # previousSuggestions hasn't been set yet, this is the start of a fresh
        # re-review (not a retry).  Rotate old suggestions to the audit trail
        # so that new threads aren't wrongly skipped by _already_posted.
        clear_suggestions_for_re_review(review_state, file_path)

        # Set file status to NEEDS_WORK upfront (preserving existing suggestions
        # so that threads created by a prior partial run are not lost).
        # The try/finally below ensures persistence even if a subsequent
        # POST or PATCH call fails partway through.
        update_file_status(
            review_state,
            file_path,
            ReviewStatus.NEEDS_WORK.value,
            summary=summary,
        )

        # Snapshot existing suggestions for duplicate detection on retry.
        existing = review_state.files[normalized].suggestions

        def _already_posted(line, end_line, severity, content, out_of_scope, link_text):
            """Check if a matching suggestion already exists from a prior run."""
            for e in existing:
                if (
                    e.line == line
                    and e.endLine == end_line
                    and e.severity == severity
                    and e.content == content
                    and e.outOfScope == out_of_scope
                    and e.linkText == link_text
                ):
                    return True
            return False

        try:
            # POST a line-anchored thread for each suggestion, persisting
            # each thread ID incrementally into review_state so that partial
            # progress is saved even if a later POST fails.
            # Suggestions already persisted from a prior run are skipped to
            # make retries idempotent and avoid duplicate Azure DevOps threads.
            threads_url = config.build_api_url(repo_id, "pullRequests", pull_request_id, "threads")
            for s in suggestions_data:
                line = s["line"]
                end_line = s.get("end_line", line)
                severity = s["severity"]
                out_of_scope = s.get("out_of_scope", False)
                content = s["content"]

                # Build link text: custom > "lines X - Y" > "line X"
                if s.get("link_text"):
                    link_text = s["link_text"]
                elif end_line != line:
                    link_text = f"lines {line} - {end_line}"
                else:
                    link_text = f"line {line}"

                # Skip suggestions already persisted from a prior (partial) run
                if _already_posted(line, end_line, severity, content, out_of_scope, link_text):
                    continue

                # Build and POST line-anchored thread
                thread_context = build_thread_context(normalized, line, end_line)
                thread_body = {
                    "comments": [{"content": content, "commentType": "text"}],
                    "status": "active",
                    "threadContext": thread_context,
                }
                response = requests.post(threads_url, headers=headers, json=thread_body, timeout=30)
                response.raise_for_status()
                result = response.json()
                thread_id = result["id"]
                comment_id = result["comments"][0]["id"]

                add_suggestion_to_file(
                    review_state,
                    file_path,
                    SuggestionEntry(
                        threadId=thread_id,
                        commentId=comment_id,
                        line=line,
                        endLine=end_line,
                        severity=severity,
                        outOfScope=out_of_scope,
                        linkText=link_text,
                        content=content,
                    ),
                )

            file_entry = review_state.files[normalized]

            # Record verdict for model progress table; skip when no model ID is known
            # to avoid polluting the review state with a spurious "unknown" row.
            from .verdict_protocol import VerdictType, record_verdict

            if review_state.sessions:
                model_id = review_state.sessions[-1].modelId
            else:
                model_id = getattr(review_state, "modelId", None)
            if model_id:
                record_verdict(file_entry, model_id, VerdictType.AGREE, ReviewStatus.NEEDS_WORK.value)

            # PATCH file summary comment with regenerated markdown
            attrs = _get_attribution_params(review_state, config, file_path=file_path)
            file_content = render_file_summary(file_entry, file_entry.suggestions, base_url, **attrs)
            patch_comment(
                requests_module=requests,
                headers=headers,
                config=config,
                repo_id=repo_id,
                pull_request_id=pull_request_id,
                thread_id=file_entry.threadId,
                comment_id=file_entry.commentId,
                new_content=file_content,
                dry_run=dry_run,
            )

            # PATCH file thread status to "active" (needs work)
            patch_thread_status(
                requests_module=requests,
                headers=headers,
                config=config,
                repo_id=repo_id,
                pull_request_id=pull_request_id,
                thread_id=file_entry.threadId,
                status="active",
                dry_run=dry_run,
            )

            # Mark file as reviewed in Azure DevOps
            mark_file_reviewed(
                file_path=file_path,
                pull_request_id=pull_request_id,
                config=config,
                repo_id=repo_id,
                dry_run=dry_run,
            )

            # Cascade folder and overall summary updates
            cascade_attrs = _get_attribution_params(review_state, config)
            patch_operations = cascade_status_update(review_state, file_path, base_url, **cascade_attrs)
            execute_cascade(
                patch_operations=patch_operations,
                requests_module=requests,
                headers=headers,
                config=config,
                repo_id=repo_id,
                pull_request_id=pull_request_id,
                dry_run=dry_run,
            )
        finally:
            save_review_state(review_state)

    except FileNotFoundError:
        # Legacy fallback: create new threads (no review-state.json available)
        print("Note: No review-state.json found. Using legacy request-changes flow.")
        from .helpers import build_thread_context

        print(f"Resolving repository ID for '{config.repository}'...")
        repo_id = get_repository_id(config.organization, config.project, config.repository)

        threads_url = config.build_api_url(repo_id, "pullRequests", pull_request_id, "threads")

        # Post file-level summary comment (no line anchor, but scoped to the file)
        normalized_path = _normalize_repo_path(file_path)
        summary_body: dict = {
            "comments": [{"content": summary, "commentType": "text"}],
            "status": "active",
        }
        if normalized_path:
            summary_body["threadContext"] = {"filePath": normalized_path}
        print(f"Posting file-level summary comment for '{file_path}'...")
        resp = requests.post(threads_url, headers=headers, json=summary_body, timeout=30)
        resp.raise_for_status()

        # Post each suggestion as a separate line-anchored comment
        for s in suggestions_data:
            thread_context = build_thread_context(normalized_path, s["line"], s.get("end_line", s["line"]))
            thread_body = {
                "comments": [{"content": s["content"], "commentType": "text"}],
                "status": "active",
            }
            if thread_context:
                thread_body["threadContext"] = thread_context
            resp = requests.post(threads_url, headers=headers, json=thread_body, timeout=30)
            resp.raise_for_status()

        # Mark file as reviewed in Azure DevOps
        mark_file_reviewed(
            file_path=file_path,
            pull_request_id=pull_request_id,
            config=config,
            repo_id=repo_id,
            dry_run=dry_run,
        )

    # Update the review queue
    _update_queue_after_review(
        pull_request_id=pull_request_id,
        file_path=file_path,
        outcome="Changes",
        dry_run=dry_run,
    )

    print(f"Changes requested for '{file_path}'. Review suggestions have been posted where needed.")

    # Show next file prompt
    print_next_file_prompt(pull_request_id)


def request_changes_with_suggestion() -> None:
    """
    Request changes with code suggestion(s) on a file in a pull request review.

    Same flow as request_changes, with the addition of auto-wrapping each
    suggestion's replacement_code in suggestion fences so the AI agent never
    needs to generate fence syntax.

    State keys read: same as request_changes, but each suggestion in
    file_review.suggestions requires a replacement_code field.

    Suggestion schema:
        {
            "content": str,                 # Review comment text (required)
            "line": int,                    # Start line (required)
            "end_line": int | None,         # Optional end line; when null/omitted, defaults to line
            "severity": str,                # "high" | "medium" | "low" (required)
            "out_of_scope": bool,           # Default false
            "link_text": str | None,        # Optional; custom link text
            "replacement_code": str         # The replacement code (required)
        }

    The command automatically formats each suggestion thread's content as:

        {content}

        ```suggestion
        {replacement_code}
        ```

    Raises:
        KeyError: If pull_request_id is not set in state.
        SystemExit: On validation or execution errors.
    """
    from ...state import set_value

    suggestions_raw = get_value("file_review.suggestions")

    # Parse, validate replacement_code AND core schema fields up-front,
    # then transform a deep copy before delegating to request_changes().
    # Validation happens on the original data so that state is NOT mutated
    # if any field is invalid — making retries safe.
    if suggestions_raw:
        if isinstance(suggestions_raw, str):
            try:
                suggestions_data = json.loads(suggestions_raw)
            except json.JSONDecodeError:
                suggestions_data = None  # let request_changes handle the JSON error
        else:
            suggestions_data = suggestions_raw

        if isinstance(suggestions_data, list):
            # --- Validate on original data (no mutation) ---
            for i, s in enumerate(suggestions_data):
                if not isinstance(s, dict):
                    continue  # let request_changes report "not an object"
                # Validate replacement_code presence and type
                if "replacement_code" not in s:
                    print(
                        f"Error: Suggestion at index {i} is missing required field 'replacement_code'.",
                        file=sys.stderr,
                    )
                    sys.exit(1)
                rc = s["replacement_code"]
                if not isinstance(rc, str) or not rc.strip():
                    print(
                        f"Error: Suggestion at index {i} field 'replacement_code' must be a non-empty string.",
                        file=sys.stderr,
                    )
                    sys.exit(1)
                # Validate core required fields up-front so that a downstream
                # validation failure in request_changes() never occurs after
                # replacement_code has been stripped from state.
                for required_field in ("content", "line", "severity"):
                    if required_field not in s:
                        print(
                            f"Error: Suggestion at index {i} is missing required field '{required_field}'.",
                            file=sys.stderr,
                        )
                        sys.exit(1)
                if not isinstance(s["content"], str) or not s["content"].strip():
                    print(
                        f"Error: Suggestion at index {i} field 'content' must be a non-empty string.",
                        file=sys.stderr,
                    )
                    sys.exit(1)

            # --- Transform a deep copy (original data untouched) ---
            transformed = copy.deepcopy(suggestions_data)
            for s in transformed:
                if not isinstance(s, dict):
                    continue
                rc = s["replacement_code"]
                s["content"] = f"{s['content']}\n\n```suggestion\n{rc}\n```"
                del s["replacement_code"]

            set_value("file_review.suggestions", transformed)

    # Delegate to request_changes(); always restore original suggestions afterwards
    # so that replacement_code is preserved in state for retries and dry_run does
    # not leave mutated state behind.
    try:
        request_changes()
    finally:
        if suggestions_raw:
            set_value("file_review.suggestions", suggestions_raw)


# Valid outcomes for batch reviews (compared case-insensitively)
_BATCH_OUTCOME_APPROVE = "approve"
_BATCH_OUTCOME_CHANGES = "request-changes"
_BATCH_OUTCOME_SUGGEST = "request-changes-with-suggestion"
_BATCH_VALID_OUTCOMES = frozenset({_BATCH_OUTCOME_APPROVE, _BATCH_OUTCOME_CHANGES, _BATCH_OUTCOME_SUGGEST})


def submit_reviews() -> None:
    """
    Submit multiple file reviews in a single batch.

    Reads a JSON array of review objects from state, applies defaults for
    outcome and summary, then processes each file sequentially by delegating
    to the existing sync functions (approve_file, request_changes,
    request_changes_with_suggestion).

    State keys read:
        - pull_request_id (required): Pull request ID
        - batch_reviews.items (required): JSON array of review objects.
          Each object has ``file_path`` (required) and optionally ``outcome``,
          ``summary``, ``suggestions``.
        - batch_reviews.default_outcome (optional): Default outcome applied to
          entries that omit ``outcome``.  Defaults to ``"approve"``.
        - batch_reviews.default_summary (optional): Default summary applied to
          entries that omit ``summary``.
        - dry_run: If true, only print what would be done

    Review entry schema::

        {
            "file_path": str,             # Required
            "outcome": str | None,        # "approve" | "request-changes" |
                                          # "request-changes-with-suggestion"
            "summary": str | None,        # Override default summary
            "suggestions": list | None    # Required for non-approve outcomes
        }

    Raises:
        SystemExit: On fatal validation errors (missing batch_reviews.items,
            missing pull_request_id, invalid JSON, empty array), or when
            one or more file reviews fail (exit code 1 after summary).
    """
    from ...state import set_value

    # Fail fast: validate pull_request_id once before the loop so a missing
    # value produces a single clear error instead of N identical per-file errors.
    pr_id = get_pull_request_id(required=False)
    if pr_id is None:
        print("Error: 'pull_request_id' is required.", file=sys.stderr)
        sys.exit(1)

    batch_json = get_value("batch_reviews.items")
    if batch_json is None or batch_json == "":
        print("Error: 'batch_reviews.items' is required.", file=sys.stderr)
        sys.exit(1)

    if isinstance(batch_json, str):
        try:
            reviews = json.loads(batch_json)
        except json.JSONDecodeError as e:
            print(f"Error: 'batch_reviews.items' is not valid JSON: {e}", file=sys.stderr)
            sys.exit(1)
    else:
        reviews = batch_json

    if not isinstance(reviews, list):
        print("Error: 'batch_reviews.items' must be a JSON array.", file=sys.stderr)
        sys.exit(1)

    if not reviews:
        print("Error: 'batch_reviews.items' must contain at least one review.", file=sys.stderr)
        sys.exit(1)

    default_outcome = get_value("batch_reviews.default_outcome") or _BATCH_OUTCOME_APPROVE
    default_summary = get_value("batch_reviews.default_summary")

    total = len(reviews)
    succeeded = 0
    failed = 0
    errors: list[str] = []

    for i, review in enumerate(reviews):
        if not isinstance(review, dict):
            errors.append(f"Review at index {i}: not a JSON object.")
            failed += 1
            continue

        file_path = review.get("file_path")
        if not file_path or not isinstance(file_path, str) or not file_path.strip():
            errors.append(f"Review at index {i}: missing or empty 'file_path'.")
            failed += 1
            continue

        # Resolve outcome with defensive type checking for user-supplied JSON
        raw_outcome = review.get("outcome")
        if raw_outcome is None or (isinstance(raw_outcome, str) and not raw_outcome.strip()):
            raw_outcome = default_outcome

        if not isinstance(raw_outcome, str):
            errors.append(
                f"Review at index {i} ({file_path}): 'outcome' must be a string (got {type(raw_outcome).__name__})."
            )
            failed += 1
            continue

        outcome = raw_outcome.strip().lower()
        # Resolve summary: per-entry overrides default; validate type + non-empty after strip
        # to match the constraints of delegated commands (approve_file, request_changes).
        raw_summary = review.get("summary")
        if raw_summary is None or (isinstance(raw_summary, str) and not raw_summary.strip()):
            raw_summary = default_summary

        if raw_summary is not None and not isinstance(raw_summary, str):
            errors.append(
                f"Review at index {i} ({file_path}): 'summary' must be a string (got {type(raw_summary).__name__})."
            )
            failed += 1
            continue

        summary = raw_summary.strip() if raw_summary else None
        suggestions = review.get("suggestions")

        if outcome not in _BATCH_VALID_OUTCOMES:
            errors.append(f"Review at index {i} ({file_path}): unknown outcome '{outcome}'.")
            failed += 1
            continue

        if not summary:
            errors.append(f"Review at index {i} ({file_path}): summary is required for '{outcome}'.")
            failed += 1
            continue

        if outcome != _BATCH_OUTCOME_APPROVE:
            # For non-approve outcomes, suggestions must be a non-empty list of dicts
            if not isinstance(suggestions, list) or not suggestions:
                errors.append(
                    f"Review at index {i} ({file_path}): suggestions must be a non-empty list for '{outcome}'."
                )
                failed += 1
                continue

            invalid_suggestion = False
            for j, suggestion in enumerate(suggestions):
                if not isinstance(suggestion, dict):
                    errors.append(
                        f"Review at index {i} ({file_path}): suggestion at index {j} must be an object/dict"
                        f" (got {type(suggestion).__name__})."
                    )
                    failed += 1
                    invalid_suggestion = True
                    break

            if invalid_suggestion:
                continue

        # Set per-file state
        set_value("file_review.file_path", file_path)
        if summary:
            set_value("file_review.summary", summary)
        if suggestions is not None:
            set_value(
                "file_review.suggestions",
                json.dumps(suggestions) if isinstance(suggestions, list) else suggestions,
            )

        print(f"[{i + 1}/{total}] Processing {outcome} for {file_path}...")

        try:
            if outcome == _BATCH_OUTCOME_APPROVE:
                approve_file()
            elif outcome == _BATCH_OUTCOME_CHANGES:
                request_changes()
            else:
                request_changes_with_suggestion()
            succeeded += 1
            print("  ✅ Done")
        except SystemExit as exc:
            if exc.code and exc.code != 0:
                errors.append(f"Review at index {i} ({file_path}): exited with code {exc.code}")
                failed += 1
            else:
                succeeded += 1

    print(f"\nBatch complete: {succeeded}/{total} succeeded, {failed}/{total} failed.")
    if errors:
        print("Errors:", file=sys.stderr)
        for err in errors:
            print(f"  - {err}", file=sys.stderr)

    if failed:
        sys.exit(1)
