"""Tests for _incremental_rescaffold internal function."""

from itertools import count
from unittest.mock import MagicMock, patch

from agentic_devtools.cli.azure_devops.config import AzureDevOpsConfig
from agentic_devtools.cli.azure_devops.review_scaffold import _incremental_rescaffold
from agentic_devtools.cli.azure_devops.review_state import (
    FileEntry,
    FolderGroup,
    ModelVerdict,
    OverallSummary,
    ReviewState,
    ReviewStatus,
    SuggestionEntry,
)

_ORG = "https://dev.azure.com/testorg"
_PROJECT = "TestProject"
_REPO = "test-repo"
_REPO_ID = "repo-guid"
_PR_ID = 12345


def _make_config():
    return AzureDevOpsConfig(organization=_ORG, project=_PROJECT, repository=_REPO)


def _make_post_response(thread_id, comment_id):
    resp = MagicMock()
    resp.raise_for_status = MagicMock()
    resp.json.return_value = {"id": thread_id, "comments": [{"id": comment_id}]}
    return resp


def _make_existing_state(files=None, commit_hash="old_hash"):
    """Build a complete existing state for re-scaffolding tests."""
    file_entries = {}
    folder_files = {}
    for fp in files or ["/src/a.ts"]:
        folder = fp.split("/")[1] if "/" in fp.lstrip("/") else "root"
        file_entries[fp] = FileEntry(
            threadId=100,
            commentId=1,
            folder=folder,
            fileName=fp.split("/")[-1],
            status=ReviewStatus.APPROVED.value,
            summary="Previously reviewed",
        )
        folder_files.setdefault(folder, []).append(fp)

    folders = {k: FolderGroup(files=v) for k, v in folder_files.items()}

    return ReviewState(
        prId=_PR_ID,
        repoId=_REPO_ID,
        repoName=_REPO,
        project=_PROJECT,
        organization=_ORG,
        latestIterationId=1,
        scaffoldedUtc="2026-01-01T00:00:00+00:00",
        overallSummary=OverallSummary(threadId=500, commentId=1),
        folders=folders,
        files=file_entries,
        commitHash=commit_hash,
        activityLogThreadId=999,
    )


class TestIncrementalRescaffold:
    """Tests for _incremental_rescaffold."""

    def _run_rescaffold(self, existing_state, current_files, changed_paths=None, model_id="gpt-5"):
        """Run _incremental_rescaffold with mocked detect_file_changes."""
        requests_mock = MagicMock()
        id_gen = count(1000)

        def make_resp(*args, **kwargs):
            i = next(id_gen)
            return _make_post_response(i, i + 1)

        requests_mock.post.side_effect = make_resp

        # Mock the GET for _get_thread_comments (returns main comment)
        get_resp = MagicMock()
        get_resp.raise_for_status = MagicMock()
        get_resp.json.return_value = {"comments": [{"id": 1, "content": "Old content"}]}
        requests_mock.get.return_value = get_resp

        # Mock PATCH
        patch_resp = MagicMock()
        patch_resp.raise_for_status = MagicMock()
        requests_mock.patch.return_value = patch_resp

        save_mock = MagicMock()

        # Mock detect_file_changes to return controlled results
        with patch("agentic_devtools.cli.azure_devops.review_scaffold.detect_file_changes") as mock_detect:
            from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

            existing_files = set(existing_state.files.keys())
            current_set = set(current_files)
            changed_set = set(changed_paths or [])

            result_obj = FileChangeResult(
                new_files=sorted(current_set - existing_files),
                modified_files=sorted(existing_files & current_set & changed_set),
                deleted_files=sorted(existing_files - current_set),
                unchanged_files=sorted((existing_files & current_set) - changed_set),
            )
            mock_detect.return_value = result_obj

            with patch("agentic_devtools.cli.azure_devops.review_scaffold.save_review_state", save_mock):
                result = _incremental_rescaffold(
                    existing_state=existing_state,
                    pull_request_id=_PR_ID,
                    files=current_files,
                    config=_make_config(),
                    repo_id=_REPO_ID,
                    repo_name=_REPO,
                    latest_iteration_id=5,
                    requests_module=requests_mock,
                    headers={},
                    dry_run=False,
                    commit_hash="new_hash",
                    model_id=model_id,
                )

        return result, requests_mock, save_mock

    def test_new_files_get_scaffolded(self):
        """New files get fresh file threads."""
        existing = _make_existing_state(files=["/src/a.ts"])
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts", "/src/new.ts"])

        assert "/src/new.ts" in result.files
        assert result.files["/src/new.ts"].threadId != 0
        assert result.files["/src/new.ts"].status == ReviewStatus.UNREVIEWED.value

    def test_modified_files_reset_to_unreviewed(self):
        """Modified files are reset to unreviewed status."""
        existing = _make_existing_state(files=["/src/a.ts"])
        result, _, _ = self._run_rescaffold(
            existing,
            ["/src/a.ts"],
            changed_paths=["/src/a.ts"],
        )

        assert result.files["/src/a.ts"].status == ReviewStatus.UNREVIEWED.value
        assert result.files["/src/a.ts"].summary is None

    def test_modified_files_rotate_suggestions(self):
        """Modified files move suggestions to previousSuggestions."""
        existing = _make_existing_state(files=["/src/a.ts"])
        existing.files["/src/a.ts"].suggestions = []
        result, _, _ = self._run_rescaffold(
            existing,
            ["/src/a.ts"],
            changed_paths=["/src/a.ts"],
        )

        assert result.files["/src/a.ts"].suggestions == []
        assert result.files["/src/a.ts"].previousSuggestions == []

    def test_modified_file_model_verdicts_reset_to_unreviewed(self):
        """Modified files have modelVerdicts reset to unreviewed status and verdictType=None."""
        existing = _make_existing_state(files=["/src/a.ts"])
        existing.files["/src/a.ts"].modelVerdicts = [
            ModelVerdict(modelId="gpt-5", status=ReviewStatus.APPROVED.value, verdictType="agree"),
        ]
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts"], changed_paths=["/src/a.ts"])

        mv = result.files["/src/a.ts"].modelVerdicts[0]
        assert mv.modelId == "gpt-5"
        assert mv.status == ReviewStatus.UNREVIEWED.value
        assert mv.verdictType is None

    def test_modified_file_consolidation_status_cleared(self):
        """Modified files have consolidationStatus cleared to None."""
        existing = _make_existing_state(files=["/src/a.ts"])
        existing.files["/src/a.ts"].consolidationStatus = "complete"
        existing.files["/src/a.ts"].modelVerdicts = [
            ModelVerdict(modelId="gpt-5", status=ReviewStatus.APPROVED.value),
        ]
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts"], changed_paths=["/src/a.ts"])

        assert result.files["/src/a.ts"].consolidationStatus is None

    def test_modified_file_rendered_content_shows_reset_verdict(self):
        """The PATCH content for a modified file shows the reset (unreviewed) verdict, not the old terminal one."""
        existing = _make_existing_state(files=["/src/a.ts"])
        existing.files["/src/a.ts"].modelVerdicts = [
            ModelVerdict(modelId="gpt-5", status=ReviewStatus.APPROVED.value, verdictType="agree"),
        ]
        result, requests_mock, _ = self._run_rescaffold(existing, ["/src/a.ts"], changed_paths=["/src/a.ts"])
        assert result is not None

        # The rendered content is PATCHed as the new main comment — collect all PATCH call bodies
        patch_bodies = [call.kwargs.get("json", {}) for call in requests_mock.patch.call_args_list]
        # Find any PATCH that updated a comment content (single {"content": ...} body)
        comment_patch_contents = [b.get("content", "") for b in patch_bodies if "content" in b]
        assert any("### Model Review Progress" in c for c in comment_patch_contents), (
            "Model Review Progress table should appear in the PATCH content"
        )
        # The table should show the unreviewed status, not the old "Approved" status
        assert not any("✅ Approved" in c for c in comment_patch_contents), (
            "Approved status should not appear in the re-rendered comment"
        )

    def test_deleted_files_marked_approved(self):
        """Deleted files are marked as approved with 'File removed' summary."""
        existing = _make_existing_state(files=["/src/a.ts", "/src/deleted.ts"])
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts"])

        assert result.files["/src/deleted.ts"].status == ReviewStatus.APPROVED.value
        assert result.files["/src/deleted.ts"].summary == "File removed"

    def test_unchanged_files_preserve_state(self):
        """Unchanged files keep their existing state."""
        existing = _make_existing_state(files=["/src/a.ts"])
        existing.files["/src/a.ts"].status = ReviewStatus.APPROVED.value
        existing.files["/src/a.ts"].summary = "LGTM"

        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts"])

        assert result.files["/src/a.ts"].status == ReviewStatus.APPROVED.value
        assert result.files["/src/a.ts"].summary == "LGTM"

    def test_commit_hash_updated(self):
        """Commit hash is updated to the new value."""
        existing = _make_existing_state(commit_hash="old_hash")
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts"])

        assert result.commitHash == "new_hash"

    def test_new_session_created(self):
        """A new session is created for the re-scaffolding."""
        existing = _make_existing_state()
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts"])

        assert len(result.sessions) == 1
        assert result.sessions[0].status == "in_progress"

    def test_state_saved(self):
        """State is saved after re-scaffolding."""
        existing = _make_existing_state()
        _, _, save_mock = self._run_rescaffold(existing, ["/src/a.ts"])

        save_mock.assert_called_once()

    def test_dry_run_returns_none(self):
        """Returns None in dry-run mode."""
        existing = _make_existing_state()

        with patch("agentic_devtools.cli.azure_devops.review_scaffold.detect_file_changes") as mock_detect:
            from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

            mock_detect.return_value = FileChangeResult(unchanged_files=["/src/a.ts"])

            with patch("agentic_devtools.cli.azure_devops.review_scaffold.save_review_state"):
                result = _incremental_rescaffold(
                    existing_state=existing,
                    pull_request_id=_PR_ID,
                    files=["/src/a.ts"],
                    config=_make_config(),
                    repo_id=_REPO_ID,
                    repo_name=_REPO,
                    latest_iteration_id=5,
                    requests_module=MagicMock(),
                    headers={},
                    dry_run=True,
                    commit_hash="new_hash",
                    model_id="gpt-5",
                )

        assert result is None

    def test_folder_groups_updated_for_new_files(self):
        """Folder groups are updated when new files are added."""
        existing = _make_existing_state(files=["/src/a.ts"])
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts", "/utils/b.ts"])

        assert "utils" in result.folders
        assert "/utils/b.ts" in result.folders["utils"].files

    def test_empty_folder_preserved_when_all_files_deleted(self):
        """Folder group is preserved (empty) when all its files are deleted."""
        existing = _make_existing_state(files=["/old/only.ts", "/src/a.ts"])
        # All files in "old" folder are deleted, only /src/a.ts remains
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts"])

        assert "old" in result.folders
        assert result.folders["old"].files == []

    def test_rebase_no_changes_updates_commit_hash(self):
        """Rebase with no file changes still updates the commit hash."""
        existing = _make_existing_state(files=["/src/a.ts"])
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts"])

        assert result.commitHash == "new_hash"

    def test_new_file_model_verdicts_initialized_when_model_id_truthy(self):
        """New file entries have modelVerdicts when model_id is a non-empty string."""
        existing = _make_existing_state(files=["/src/a.ts"])
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts", "/src/new.ts"], model_id="gpt-5")
        entry = result.files["/src/new.ts"]
        assert len(entry.modelVerdicts) == 1
        assert entry.modelVerdicts[0].modelId == "gpt-5"

    def test_new_file_model_verdicts_empty_when_model_id_falsy(self):
        """New file entries have no modelVerdicts when model_id is an empty string."""
        existing = _make_existing_state(files=["/src/a.ts"])
        result, _, _ = self._run_rescaffold(existing, ["/src/a.ts", "/src/new.ts"], model_id="")
        entry = result.files["/src/new.ts"]
        assert entry.modelVerdicts == []

    def test_initial_new_file_thread_content_includes_progress_table_when_model_id_truthy(self):
        """Initial content posted for a new file thread includes the Model Review Progress table."""
        existing = _make_existing_state(files=["/src/a.ts"])
        result, requests_mock, _ = self._run_rescaffold(existing, ["/src/a.ts", "/src/new.ts"], model_id="gpt-5")
        assert result is not None
        # Thread-shaped POSTs have a "comments" list in the JSON body
        thread_post_contents = [
            call.kwargs["json"]["comments"][0]["content"]
            for call in requests_mock.post.call_args_list
            if "comments" in call.kwargs.get("json", {})
        ]
        assert any("### Model Review Progress" in c for c in thread_post_contents)
        assert any("gpt-5" in c for c in thread_post_contents)

    def test_initial_new_file_thread_content_excludes_progress_table_when_model_id_empty(self):
        """Initial content posted for a new file thread has no Model Review Progress table when model_id empty."""
        existing = _make_existing_state(files=["/src/a.ts"])
        result, requests_mock, _ = self._run_rescaffold(existing, ["/src/a.ts", "/src/new.ts"], model_id="")
        assert result is not None
        thread_post_contents = [
            call.kwargs["json"]["comments"][0]["content"]
            for call in requests_mock.post.call_args_list
            if "comments" in call.kwargs.get("json", {})
        ]
        assert not any("### Model Review Progress" in c for c in thread_post_contents)


class TestIncrementalRescaffoldDryRun:
    """Tests for dry-run mode in _incremental_rescaffold."""

    def _run_dry(self, existing, files, change_result):
        """Run _incremental_rescaffold in dry-run mode with given FileChangeResult."""
        with patch("agentic_devtools.cli.azure_devops.review_scaffold.detect_file_changes") as mock_detect:
            mock_detect.return_value = change_result
            with patch("agentic_devtools.cli.azure_devops.review_scaffold.save_review_state"):
                return _incremental_rescaffold(
                    existing_state=existing,
                    pull_request_id=_PR_ID,
                    files=files,
                    config=_make_config(),
                    repo_id=_REPO_ID,
                    repo_name=_REPO,
                    latest_iteration_id=5,
                    requests_module=MagicMock(),
                    headers={},
                    dry_run=True,
                    commit_hash="new_hash",
                    model_id="gpt-5",
                )

    def test_prints_new_files(self, capsys):
        """Dry-run mode prints new file entries."""
        from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

        existing = _make_existing_state(files=["/src/a.ts"])
        self._run_dry(
            existing,
            ["/src/a.ts", "/src/new.ts"],
            FileChangeResult(new_files=["/src/new.ts"], unchanged_files=["/src/a.ts"]),
        )
        out = capsys.readouterr().out
        assert "[DRY RUN] New file: /src/new.ts" in out

    def test_prints_modified_files(self, capsys):
        """Dry-run mode prints modified file entries."""
        from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

        existing = _make_existing_state(files=["/src/a.ts"])
        self._run_dry(existing, ["/src/a.ts"], FileChangeResult(modified_files=["/src/a.ts"]))
        out = capsys.readouterr().out
        assert "[DRY RUN] Modified file: /src/a.ts" in out

    def test_prints_deleted_files(self, capsys):
        """Dry-run mode prints deleted file entries."""
        from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

        existing = _make_existing_state(files=["/src/a.ts"])
        self._run_dry(existing, [], FileChangeResult(deleted_files=["/src/a.ts"]))
        out = capsys.readouterr().out
        assert "[DRY RUN] Deleted file: /src/a.ts" in out

    def test_prints_unchanged_files(self, capsys):
        """Dry-run mode prints unchanged file entries."""
        from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

        existing = _make_existing_state(files=["/src/a.ts"])
        self._run_dry(existing, ["/src/a.ts"], FileChangeResult(unchanged_files=["/src/a.ts"]))
        out = capsys.readouterr().out
        assert "[DRY RUN] Unchanged file: /src/a.ts" in out


class TestIncrementalRescaffoldExceptionHandling:
    """Tests for exception handling in _incremental_rescaffold."""

    def _make_failing_requests_mock(self):
        """Create a requests mock where GET always fails."""
        requests_mock = MagicMock()
        requests_mock.get.side_effect = Exception("Network error")
        post_resp = MagicMock()
        post_resp.raise_for_status = MagicMock()
        post_resp.json.return_value = {"id": 999, "comments": [{"id": 1}]}
        requests_mock.post.return_value = post_resp
        return requests_mock

    def _run_with_failure(self, existing, files, change_result, requests_mock=None):
        """Run _incremental_rescaffold with failing mocks."""
        if requests_mock is None:
            requests_mock = self._make_failing_requests_mock()
        with patch("agentic_devtools.cli.azure_devops.review_scaffold.detect_file_changes") as mock_detect:
            mock_detect.return_value = change_result
            with patch("agentic_devtools.cli.azure_devops.review_scaffold.save_review_state"):
                return _incremental_rescaffold(
                    existing_state=existing,
                    pull_request_id=_PR_ID,
                    files=files,
                    config=_make_config(),
                    repo_id=_REPO_ID,
                    repo_name=_REPO,
                    latest_iteration_id=5,
                    requests_module=requests_mock,
                    headers={},
                    dry_run=False,
                    commit_hash="new_hash",
                    model_id="gpt-5",
                )

    def test_modified_file_demote_exception(self, capsys):
        """Modified file demote failure is caught and logged."""
        from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

        existing = _make_existing_state(files=["/src/a.ts"])
        result = self._run_with_failure(existing, ["/src/a.ts"], FileChangeResult(modified_files=["/src/a.ts"]))
        err = capsys.readouterr().err
        assert "Warning: Could not demote comment for /src/a.ts" in err
        assert result.files["/src/a.ts"].status == ReviewStatus.UNREVIEWED.value

    def test_deleted_file_demote_exception(self, capsys):
        """Deleted file demote failure is caught and logged."""
        from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

        existing = _make_existing_state(files=["/src/a.ts", "/src/del.ts"])
        result = self._run_with_failure(
            existing,
            ["/src/a.ts"],
            FileChangeResult(deleted_files=["/src/del.ts"], unchanged_files=["/src/a.ts"]),
        )
        err = capsys.readouterr().err
        assert "Warning: Could not demote comment for deleted /src/del.ts" in err
        assert result.files["/src/del.ts"].status == ReviewStatus.APPROVED.value

    def test_overall_summary_exception(self, capsys):
        """Overall summary update failure is caught and logged."""
        from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

        existing = _make_existing_state(files=["/src/a.ts"])
        requests_mock = MagicMock()
        call_count = [0]

        def get_side_effect(*args, **kwargs):
            call_count[0] += 1
            if call_count[0] > 1:
                raise Exception("Summary error")
            resp = MagicMock()
            resp.raise_for_status = MagicMock()
            resp.json.return_value = {"comments": [{"id": 1, "content": "Old"}]}
            return resp

        requests_mock.get.side_effect = get_side_effect
        post_resp = MagicMock()
        post_resp.raise_for_status = MagicMock()
        post_resp.json.return_value = {"id": 999, "comments": [{"id": 1}]}
        requests_mock.post.return_value = post_resp
        patch_resp = MagicMock()
        patch_resp.raise_for_status = MagicMock()
        requests_mock.patch.return_value = patch_resp

        result = self._run_with_failure(
            existing, ["/src/a.ts"], FileChangeResult(modified_files=["/src/a.ts"]), requests_mock
        )
        err = capsys.readouterr().err
        assert "Warning: Could not update overall summary" in err
        assert result is not None

    def test_rebase_summary_exception(self, capsys):
        """Rebase summary update failure is caught and logged."""
        from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

        existing = _make_existing_state(files=["/src/a.ts"])
        result = self._run_with_failure(existing, ["/src/a.ts"], FileChangeResult(unchanged_files=["/src/a.ts"]))
        err = capsys.readouterr().err
        assert "Warning: Could not update overall summary" in err
        assert result is not None

    def test_activity_log_exception(self, capsys):
        """Activity log posting failure is caught and logged."""
        from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

        existing = _make_existing_state(files=["/src/a.ts"])
        result = self._run_with_failure(existing, ["/src/a.ts"], FileChangeResult(unchanged_files=["/src/a.ts"]))
        err = capsys.readouterr().err
        assert "Warning: Could not" in err
        assert result is not None


def _make_suggestion(thread_id: int = 300) -> SuggestionEntry:
    return SuggestionEntry(
        threadId=thread_id,
        commentId=301,
        line=10,
        endLine=20,
        severity="high",
        outOfScope=False,
        linkText="lines 10 - 20",
        content="Missing null check",
    )


def _make_existing_state_with_previous_suggestions(
    files=None,
    commit_hash="old_hash",
    previous_suggestions_map=None,
):
    """Build an existing state with previousSuggestions on specific files."""
    file_entries = {}
    folder_files = {}
    for fp in files or ["/src/a.ts"]:
        folder = fp.split("/")[1] if "/" in fp.lstrip("/") else "root"
        file_entries[fp] = FileEntry(
            threadId=100,
            commentId=1,
            folder=folder,
            fileName=fp.split("/")[-1],
            status=ReviewStatus.APPROVED.value,
            summary="Previously reviewed",
        )
        folder_files.setdefault(folder, []).append(fp)

    # Apply previousSuggestions
    if previous_suggestions_map:
        for fp, suggestions in previous_suggestions_map.items():
            if fp in file_entries:
                file_entries[fp].previousSuggestions = suggestions

    folders = {k: FolderGroup(files=v) for k, v in folder_files.items()}

    return ReviewState(
        prId=_PR_ID,
        repoId=_REPO_ID,
        repoName=_REPO,
        project=_PROJECT,
        organization=_ORG,
        latestIterationId=1,
        scaffoldedUtc="2026-01-01T00:00:00+00:00",
        overallSummary=OverallSummary(threadId=500, commentId=1),
        folders=folders,
        files=file_entries,
        commitHash=commit_hash,
        activityLogThreadId=999,
    )


class TestIncrementalRescaffoldVerificationGate:
    """Tests for the suggestion verification gate in _incremental_rescaffold."""

    def _run_rescaffold_with_mocks(
        self,
        existing_state,
        current_files,
        changed_paths=None,
        dry_run=False,
        fetch_threads_return=None,
        categorize_return=None,
    ):
        """Run _incremental_rescaffold with mocked verification functions."""
        requests_mock = MagicMock()
        id_gen = count(1000)

        def make_resp(*args, **kwargs):
            i = next(id_gen)
            return _make_post_response(i, i + 1)

        requests_mock.post.side_effect = make_resp

        get_resp = MagicMock()
        get_resp.raise_for_status = MagicMock()
        get_resp.json.return_value = {"comments": [{"id": 1, "content": "Old content"}]}
        requests_mock.get.return_value = get_resp

        patch_resp = MagicMock()
        patch_resp.raise_for_status = MagicMock()
        requests_mock.patch.return_value = patch_resp

        save_mock = MagicMock()

        with patch("agentic_devtools.cli.azure_devops.review_scaffold.detect_file_changes") as mock_detect:
            from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

            existing_files = set(existing_state.files.keys())
            current_set = set(current_files)
            changed_set = set(changed_paths or [])

            result_obj = FileChangeResult(
                new_files=sorted(current_set - existing_files),
                modified_files=sorted(existing_files & current_set & changed_set),
                deleted_files=sorted(existing_files - current_set),
                unchanged_files=sorted((existing_files & current_set) - changed_set),
            )
            mock_detect.return_value = result_obj

            with patch("agentic_devtools.cli.azure_devops.review_scaffold.fetch_threads_lookup") as mock_fetch:
                mock_fetch.return_value = fetch_threads_return

                with patch(
                    "agentic_devtools.cli.azure_devops.review_scaffold.categorize_all_suggestions"
                ) as mock_categorize:
                    mock_categorize.return_value = categorize_return or []

                    with patch("agentic_devtools.cli.azure_devops.review_scaffold.save_review_state", save_mock):
                        result = _incremental_rescaffold(
                            existing_state=existing_state,
                            pull_request_id=_PR_ID,
                            files=current_files,
                            config=_make_config(),
                            repo_id=_REPO_ID,
                            repo_name=_REPO,
                            latest_iteration_id=5,
                            requests_module=requests_mock,
                            headers={},
                            dry_run=dry_run,
                            commit_hash="new_hash",
                            model_id="gpt-5",
                        )

        return result, requests_mock, save_mock, mock_fetch, mock_categorize

    def test_abort_gate_fires_with_unaddressed_suggestions(self, capsys):
        """Abort gate blocks review when unaddressed suggestions exist."""
        from agentic_devtools.cli.azure_devops.suggestion_verification import (
            CATEGORY_NEEDS_REVIEW,
            CATEGORY_UNADDRESSED,
            SuggestionVerificationResult,
        )

        suggestion = _make_suggestion(thread_id=300)
        existing = _make_existing_state_with_previous_suggestions(
            files=["/src/a.ts"],
            previous_suggestions_map={"/src/a.ts": [suggestion]},
        )

        unaddressed = SuggestionVerificationResult(
            suggestion=suggestion,
            file_path="/src/a.ts",
            category=CATEGORY_UNADDRESSED,
            has_reply=False,
            file_changed=False,
            thread_status="active",
        )
        needs_review = SuggestionVerificationResult(
            suggestion=_make_suggestion(thread_id=301),
            file_path="/src/a.ts",
            category=CATEGORY_NEEDS_REVIEW,
            has_reply=True,
            file_changed=False,
            thread_status="active",
        )

        result, requests_mock, save_mock, _, _ = self._run_rescaffold_with_mocks(
            existing,
            ["/src/a.ts"],
            fetch_threads_return={300: {"id": 300, "comments": [{"id": 1}]}},
            categorize_return=[unaddressed, needs_review],
        )

        # Should return existing_state (early return, not None)
        assert result is not None
        # Session should be marked as failed
        assert len(result.sessions) == 1
        assert result.sessions[0].status == "failed"
        assert result.sessions[0].completedUtc is not None
        # Commit hash should still be updated
        assert result.commitHash == "new_hash"
        # State should be saved
        save_mock.assert_called_once()
        # Should print abort message
        out = capsys.readouterr().out
        assert "Review blocked" in out

    def test_abort_gate_records_session_without_activity_log_thread(self, capsys):
        """Abort gate records failed session even when activityLogThreadId is falsy."""
        from agentic_devtools.cli.azure_devops.suggestion_verification import (
            CATEGORY_UNADDRESSED,
            SuggestionVerificationResult,
        )

        suggestion = _make_suggestion(thread_id=300)
        existing = _make_existing_state_with_previous_suggestions(
            files=["/src/a.ts"],
            previous_suggestions_map={"/src/a.ts": [suggestion]},
        )
        # Set activityLogThreadId to 0 (falsy)
        existing.activityLogThreadId = 0

        unaddressed = SuggestionVerificationResult(
            suggestion=suggestion,
            file_path="/src/a.ts",
            category=CATEGORY_UNADDRESSED,
            has_reply=False,
            file_changed=False,
            thread_status="active",
        )

        result, requests_mock, save_mock, _, _ = self._run_rescaffold_with_mocks(
            existing,
            ["/src/a.ts"],
            fetch_threads_return={300: {"id": 300, "comments": [{"id": 1}]}},
            categorize_return=[unaddressed],
        )

        # Session should still be created and marked as failed
        assert result is not None
        assert len(result.sessions) == 1
        assert result.sessions[0].status == "failed"
        assert result.sessions[0].completedUtc is not None
        # State should be saved
        save_mock.assert_called_once()
        # Abort message should be printed
        out = capsys.readouterr().out
        assert "Review blocked" in out

    def test_all_needs_review_sets_pending_verification(self):
        """When all suggestions are needs_review, files get pending_verification status."""
        from agentic_devtools.cli.azure_devops.suggestion_verification import (
            CATEGORY_NEEDS_REVIEW,
            SuggestionVerificationResult,
        )

        suggestion = _make_suggestion(thread_id=300)
        existing = _make_existing_state_with_previous_suggestions(
            files=["/src/a.ts"],
            previous_suggestions_map={"/src/a.ts": [suggestion]},
        )

        needs_review = SuggestionVerificationResult(
            suggestion=suggestion,
            file_path="/src/a.ts",
            category=CATEGORY_NEEDS_REVIEW,
            has_reply=True,
            file_changed=False,
            thread_status="active",
        )

        result, _, _, _, _ = self._run_rescaffold_with_mocks(
            existing,
            ["/src/a.ts"],
            fetch_threads_return={300: {"id": 300, "comments": [{"id": 1}, {"id": 2}]}},
            categorize_return=[needs_review],
        )

        assert result is not None
        assert result.files["/src/a.ts"].suggestionVerificationStatus == "pending_verification"

    def test_fetch_threads_returns_none_proceeds(self, capsys):
        """When fetch_threads_lookup returns None, proceed without blocking."""
        suggestion = _make_suggestion(thread_id=300)
        existing = _make_existing_state_with_previous_suggestions(
            files=["/src/a.ts"],
            previous_suggestions_map={"/src/a.ts": [suggestion]},
        )

        result, _, _, mock_fetch, mock_categorize = self._run_rescaffold_with_mocks(
            existing,
            ["/src/a.ts"],
            fetch_threads_return=None,
        )

        # Should proceed to normal scaffolding (not abort)
        assert result is not None
        # categorize_all_suggestions should NOT have been called
        mock_categorize.assert_not_called()
        # Warning should be printed
        err = capsys.readouterr().err
        assert "Could not fetch PR threads" in err

    def test_dry_run_skips_verification(self):
        """Dry-run mode skips the verification gate entirely."""
        suggestion = _make_suggestion(thread_id=300)
        existing = _make_existing_state_with_previous_suggestions(
            files=["/src/a.ts"],
            previous_suggestions_map={"/src/a.ts": [suggestion]},
        )

        result, _, _, mock_fetch, mock_categorize = self._run_rescaffold_with_mocks(
            existing,
            ["/src/a.ts"],
            dry_run=True,
            fetch_threads_return={300: {"id": 300, "comments": [{"id": 1}]}},
        )

        # Returns None in dry-run mode
        assert result is None
        # fetch_threads_lookup should NOT have been called (dry_run short-circuits)
        mock_fetch.assert_not_called()
        mock_categorize.assert_not_called()

    def test_no_previous_suggestions_skips_verification(self):
        """No files with previousSuggestions → verification is skipped entirely."""
        existing = _make_existing_state(files=["/src/a.ts"])
        assert existing.files["/src/a.ts"].previousSuggestions is None

        result, _, _, mock_fetch, mock_categorize = self._run_rescaffold_with_mocks(
            existing,
            ["/src/a.ts"],
            fetch_threads_return={},
        )

        assert result is not None
        mock_fetch.assert_not_called()
        mock_categorize.assert_not_called()

    def test_abort_gate_posts_unaddressed_thread_comments(self):
        """Abort gate posts reply comments on each unaddressed suggestion thread."""
        from agentic_devtools.cli.azure_devops.suggestion_verification import (
            CATEGORY_UNADDRESSED,
            SuggestionVerificationResult,
        )

        suggestion = _make_suggestion(thread_id=300)
        existing = _make_existing_state_with_previous_suggestions(
            files=["/src/a.ts"],
            previous_suggestions_map={"/src/a.ts": [suggestion]},
        )

        unaddressed = SuggestionVerificationResult(
            suggestion=suggestion,
            file_path="/src/a.ts",
            category=CATEGORY_UNADDRESSED,
            has_reply=False,
            file_changed=False,
            thread_status="active",
        )

        result, requests_mock, _, _, _ = self._run_rescaffold_with_mocks(
            existing,
            ["/src/a.ts"],
            fetch_threads_return={300: {"id": 300, "comments": [{"id": 1}]}},
            categorize_return=[unaddressed],
        )

        # Verify POST calls were made (thread comment + activity log)
        assert requests_mock.post.call_count >= 1
        assert result is not None

    def test_abort_gate_thread_comment_exception_handled(self, capsys):
        """Exception posting unaddressed thread comment is caught and logged."""
        from agentic_devtools.cli.azure_devops.suggestion_verification import (
            CATEGORY_UNADDRESSED,
            SuggestionVerificationResult,
        )

        suggestion = _make_suggestion(thread_id=300)
        existing = _make_existing_state_with_previous_suggestions(
            files=["/src/a.ts"],
            previous_suggestions_map={"/src/a.ts": [suggestion]},
        )

        unaddressed = SuggestionVerificationResult(
            suggestion=suggestion,
            file_path="/src/a.ts",
            category=CATEGORY_UNADDRESSED,
            has_reply=False,
            file_changed=False,
            thread_status="active",
        )

        requests_mock = MagicMock()
        # POST fails (thread comment + activity log)
        requests_mock.post.side_effect = Exception("API error")
        get_resp = MagicMock()
        get_resp.raise_for_status = MagicMock()
        get_resp.json.return_value = {"comments": [{"id": 1, "content": "Old"}]}
        requests_mock.get.return_value = get_resp
        patch_resp = MagicMock()
        patch_resp.raise_for_status = MagicMock()
        requests_mock.patch.return_value = patch_resp

        save_mock = MagicMock()

        with patch("agentic_devtools.cli.azure_devops.review_scaffold.detect_file_changes") as mock_detect:
            from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

            mock_detect.return_value = FileChangeResult(unchanged_files=["/src/a.ts"])
            with patch("agentic_devtools.cli.azure_devops.review_scaffold.fetch_threads_lookup") as mock_fetch:
                mock_fetch.return_value = {300: {"id": 300, "comments": [{"id": 1}]}}
                with patch("agentic_devtools.cli.azure_devops.review_scaffold.categorize_all_suggestions") as mock_cat:
                    mock_cat.return_value = [unaddressed]
                    with patch("agentic_devtools.cli.azure_devops.review_scaffold.save_review_state", save_mock):
                        result = _incremental_rescaffold(
                            existing_state=existing,
                            pull_request_id=_PR_ID,
                            files=["/src/a.ts"],
                            config=_make_config(),
                            repo_id=_REPO_ID,
                            repo_name=_REPO,
                            latest_iteration_id=5,
                            requests_module=requests_mock,
                            headers={},
                            dry_run=False,
                            commit_hash="new_hash",
                            model_id="gpt-5",
                        )

        err = capsys.readouterr().err
        assert "Could not post unaddressed comment on thread 300" in err
        assert "Could not post activity log" in err
        assert result is not None

    def test_abort_gate_summary_exception_handled(self, capsys):
        """Exception posting abort summary is caught and logged."""
        from agentic_devtools.cli.azure_devops.suggestion_verification import (
            CATEGORY_UNADDRESSED,
            SuggestionVerificationResult,
        )

        suggestion = _make_suggestion(thread_id=300)
        existing = _make_existing_state_with_previous_suggestions(
            files=["/src/a.ts"],
            previous_suggestions_map={"/src/a.ts": [suggestion]},
        )

        unaddressed = SuggestionVerificationResult(
            suggestion=suggestion,
            file_path="/src/a.ts",
            category=CATEGORY_UNADDRESSED,
            has_reply=False,
            file_changed=False,
            thread_status="active",
        )

        requests_mock = MagicMock()
        post_resp = MagicMock()
        post_resp.raise_for_status = MagicMock()
        post_resp.json.return_value = {"id": 999, "comments": [{"id": 1}]}
        requests_mock.post.return_value = post_resp
        # GET fails (used by _demote_main_comment for abort summary)
        requests_mock.get.side_effect = Exception("GET error")

        save_mock = MagicMock()

        with patch("agentic_devtools.cli.azure_devops.review_scaffold.detect_file_changes") as mock_detect:
            from agentic_devtools.cli.azure_devops.review_scaffold import FileChangeResult

            mock_detect.return_value = FileChangeResult(unchanged_files=["/src/a.ts"])
            with patch("agentic_devtools.cli.azure_devops.review_scaffold.fetch_threads_lookup") as mock_fetch:
                mock_fetch.return_value = {300: {"id": 300, "comments": [{"id": 1}]}}
                with patch("agentic_devtools.cli.azure_devops.review_scaffold.categorize_all_suggestions") as mock_cat:
                    mock_cat.return_value = [unaddressed]
                    with patch("agentic_devtools.cli.azure_devops.review_scaffold.save_review_state", save_mock):
                        result = _incremental_rescaffold(
                            existing_state=existing,
                            pull_request_id=_PR_ID,
                            files=["/src/a.ts"],
                            config=_make_config(),
                            repo_id=_REPO_ID,
                            repo_name=_REPO,
                            latest_iteration_id=5,
                            requests_module=requests_mock,
                            headers={},
                            dry_run=False,
                            commit_hash="new_hash",
                            model_id="gpt-5",
                        )

        err = capsys.readouterr().err
        assert "Could not post abort summary" in err
        assert result is not None

    def test_deleted_file_with_suggestions_included_in_changed_set(self, capsys):
        """Deleted file with previous suggestions should be in changed_set, avoiding false unaddressed."""
        from agentic_devtools.cli.azure_devops.suggestion_verification import (
            CATEGORY_NEEDS_REVIEW,
            SuggestionVerificationResult,
        )

        suggestion = _make_suggestion(thread_id=500)
        # File /src/removed.ts exists in state but will NOT be in current_files (deleted).
        existing = _make_existing_state_with_previous_suggestions(
            files=["/src/a.ts", "/src/removed.ts"],
            previous_suggestions_map={"/src/removed.ts": [suggestion]},
        )

        needs_review = SuggestionVerificationResult(
            suggestion=suggestion,
            file_path="/src/removed.ts",
            category=CATEGORY_NEEDS_REVIEW,
            has_reply=False,
            file_changed=True,  # deleted = changed
            thread_status="active",
        )

        result, _, _, _, mock_categorize = self._run_rescaffold_with_mocks(
            existing,
            ["/src/a.ts"],  # /src/removed.ts deleted
            fetch_threads_return={500: {"id": 500, "comments": [{"id": 1}]}},
            categorize_return=[needs_review],
        )

        # Verify categorize_all_suggestions was called with changed_set including the deleted file
        call_args = mock_categorize.call_args
        changed_files_arg = call_args[0][1]  # second positional arg
        assert "/src/removed.ts" in changed_files_arg
        # No abort — file is needs_review, not unaddressed
        assert result is not None
        assert not any(s.status == "failed" for s in result.sessions)
