"""
Tests for the implementation progress tracker (Layer 5 of enforcement protocol).

Covers:
- Plan step extraction from assistant messages
- Edit success/failure detection
- Verification result detection
- Plan restatement counting
- Stall detection
- Progress injection generation
- End-to-end extract_implementation_progress
"""

import pytest

from api.services.complete.agentic_controller_v2 import (
    extract_implementation_progress,
    build_progress_injection,
    _extract_plan_steps,
    _is_edit_success,
    _is_verification_failure,
    ImplementationProgress,
    _PlanStep,
)
from api.models import CompleteRequest


def _msg(role, content, *, tool_call_id=None, tool_calls=None):
    return CompleteRequest.Message(
        role=role,
        content=content,
        tool_call_id=tool_call_id,
        tool_calls=tool_calls,
    )


def _tc(name, tool_call_id="tc_1", arguments=None):
    return {
        "id": tool_call_id,
        "function": {"name": name, "arguments": arguments or "{}"},
    }


# ===========================================================================
# _extract_plan_steps
# ===========================================================================

class TestExtractPlanSteps:
    def test_extracts_numbered_steps(self):
        text = (
            "## Implementation Plan\n\n"
            "1. Add composite indexes to database.py\n"
            "2. Create /dashboard/summary endpoint\n"
            "3. Update frontend DashboardPage.tsx\n"
        )
        steps = _extract_plan_steps(text)
        assert len(steps) == 3
        assert steps[0].number == 1
        assert "composite indexes" in steps[0].description
        assert steps[2].number == 3

    def test_extracts_step_prefix_format(self):
        text = (
            "# Action Plan\n\n"
            "Step 1: Read the config file\n"
            "Step 2: Modify the settings\n"
            "Step 3: Run tests\n"
        )
        steps = _extract_plan_steps(text)
        assert len(steps) == 3

    def test_no_plan_heading_returns_empty(self):
        text = "Here is some analysis of the code.\n1. First point\n2. Second point"
        steps = _extract_plan_steps(text)
        assert steps == []

    def test_reasoning_summary_heading(self):
        text = (
            "## REASONING SUMMARY\n\n"
            "1. Optimize the forecast query\n"
            "2. Add database indexes\n"
        )
        steps = _extract_plan_steps(text)
        assert len(steps) == 2

    def test_deduplicates_step_numbers(self):
        text = (
            "# Implementation Plan\n\n"
            "1. Do thing A\n"
            "1. Do thing A again\n"
            "2. Do thing B\n"
        )
        steps = _extract_plan_steps(text)
        assert len(steps) == 2

    def test_empty_text(self):
        assert _extract_plan_steps("") == []
        assert _extract_plan_steps(None) == []


# ===========================================================================
# _is_edit_success / _is_verification_failure
# ===========================================================================

class TestEditSuccess:
    def test_success_result(self):
        assert _is_edit_success("The file has been updated.") is True

    def test_error_result(self):
        assert _is_edit_success("Error: old_string not found in file") is False

    def test_traceback_result(self):
        assert _is_edit_success("Traceback (most recent call last):\n  File...") is False

    def test_empty_result(self):
        assert _is_edit_success("") is False


class TestVerificationFailure:
    def test_passing_tests(self):
        assert _is_verification_failure("5 passed in 0.3s") is False

    def test_failing_tests(self):
        assert _is_verification_failure("2 failed, 3 passed\nERROR: test_foo") is True

    def test_syntax_error(self):
        assert _is_verification_failure("SyntaxError: invalid syntax") is True

    def test_permission_error(self):
        assert _is_verification_failure("PermissionError: [Errno 1] Operation not permitted") is True

    def test_clean_lint(self):
        assert _is_verification_failure("All checks passed.") is False

    def test_empty(self):
        assert _is_verification_failure("") is False


# ===========================================================================
# extract_implementation_progress
# ===========================================================================

class TestExtractImplementationProgress:
    def test_no_plan_no_edits(self):
        msgs = [
            _msg("user", "hello"),
            _msg("assistant", "hi there"),
        ]
        progress = extract_implementation_progress(msgs)
        assert progress.has_plan is False
        assert progress.edits_completed == []

    def test_plan_detected(self):
        msgs = [
            _msg("user", "fix the dashboard"),
            _msg("assistant", (
                "## Implementation Plan\n\n"
                "1. Add indexes to database.py\n"
                "2. Create summary endpoint\n"
                "3. Update frontend\n"
            )),
        ]
        progress = extract_implementation_progress(msgs)
        assert progress.has_plan is True
        assert len(progress.plan_steps) == 3

    def test_successful_edit_tracked(self):
        msgs = [
            _msg("user", "fix it"),
            _msg("assistant", "editing", tool_calls=[
                _tc("strreplace", "tc_1", '{"path": "backend/api/database.py", "old_string": "a", "new_string": "b"}'),
            ]),
            _msg("tool", "The file has been updated.", tool_call_id="tc_1"),
        ]
        progress = extract_implementation_progress(msgs)
        assert len(progress.edits_completed) == 1
        assert progress.edits_completed[0].path == "backend/api/database.py"
        assert progress.edits_completed[0].succeeded is True
        assert "backend/api/database.py" in progress.files_successfully_edited

    def test_failed_edit_tracked(self):
        msgs = [
            _msg("assistant", "editing", tool_calls=[
                _tc("strreplace", "tc_1", '{"path": "foo.py", "old_string": "x", "new_string": "y"}'),
            ]),
            _msg("tool", "Error: old_string not found in file", tool_call_id="tc_1"),
        ]
        progress = extract_implementation_progress(msgs)
        assert len(progress.edits_failed) == 1
        assert progress.edits_completed == []

    def test_verification_tracked(self):
        msgs = [
            _msg("assistant", "running tests", tool_calls=[
                _tc("shell", "tc_v1", '{"command": "python3 -m pytest tests/ -q"}'),
            ]),
            _msg("tool", "5 passed in 0.3s", tool_call_id="tc_v1"),
        ]
        progress = extract_implementation_progress(msgs)
        assert len(progress.verifications) == 1
        assert progress.verifications[0].succeeded is True

    def test_failed_verification_tracked(self):
        msgs = [
            _msg("assistant", "checking", tool_calls=[
                _tc("shell", "tc_v1", '{"command": "python3 -m py_compile foo.py"}'),
            ]),
            _msg("tool", "SyntaxError: invalid syntax\n  File foo.py, line 10", tool_call_id="tc_v1"),
        ]
        progress = extract_implementation_progress(msgs)
        assert len(progress.verifications) == 1
        assert progress.verifications[0].succeeded is False
        assert progress.verifications[0].error_snippet is not None

    def test_plan_restatement_detected(self):
        plan = (
            "## Implementation Plan\n\n"
            "1. Add composite indexes to database.py\n"
            "2. Create /dashboard/summary endpoint\n"
            "3. Update frontend DashboardPage.tsx\n"
        )
        msgs = [
            _msg("user", "fix it"),
            _msg("assistant", plan + "\nLet me start."),
            _msg("user", "continue"),
            _msg("assistant", plan + "\nNow implementing."),
            _msg("user", "continue"),
            _msg("assistant", plan + "\nImplementing for real."),
        ]
        progress = extract_implementation_progress(msgs)
        assert progress.plan_restatement_count == 2

    def test_stall_detection(self):
        msgs = [
            _msg("assistant", "editing", tool_calls=[
                _tc("strreplace", "tc_1", '{"path": "foo.py", "old_string": "a", "new_string": "b"}'),
            ]),
            _msg("tool", "The file has been updated.", tool_call_id="tc_1"),
            _msg("assistant", "Let me think about the next step..."),
            _msg("assistant", "I need to plan the next change..."),
            _msg("assistant", "Here's my updated approach..."),
            _msg("assistant", "Let me reconsider..."),
        ]
        progress = extract_implementation_progress(msgs)
        assert progress.assistant_turns_since_last_edit == 4

    def test_empty_messages(self):
        progress = extract_implementation_progress([])
        assert progress.has_plan is False
        assert progress.edits_completed == []


# ===========================================================================
# build_progress_injection
# ===========================================================================

class TestBuildProgressInjection:
    def test_no_plan_no_edits_returns_none(self):
        progress = ImplementationProgress(
            has_plan=False, plan_steps=[], edits_completed=[], edits_failed=[],
            verifications=[], files_successfully_edited=set(),
            assistant_turns_since_last_edit=0, plan_restatement_count=0,
        )
        assert build_progress_injection(progress) is None

    def test_proactive_gate_fires_when_plan_has_remaining_steps(self):
        progress = ImplementationProgress(
            has_plan=True,
            plan_steps=[_PlanStep(1, "Add indexes"), _PlanStep(2, "Create endpoint")],
            edits_completed=[], edits_failed=[],
            verifications=[], files_successfully_edited=set(),
            assistant_turns_since_last_edit=0, plan_restatement_count=2,
        )
        injection = build_progress_injection(progress)
        assert injection is not None
        assert "IMPLEMENTATION MODE" in injection
        assert "MUST include" in injection
        assert "tool_call" in injection

    def test_proactive_gate_with_partial_progress(self):
        from api.services.complete.agentic_controller_v2 import _EditRecord
        progress = ImplementationProgress(
            has_plan=True,
            plan_steps=[_PlanStep(1, "Edit foo.py"), _PlanStep(2, "Edit bar.py")],
            edits_completed=[_EditRecord(path="foo.py", tool_name="strreplace", succeeded=True)],
            edits_failed=[],
            verifications=[], files_successfully_edited={"foo.py"},
            assistant_turns_since_last_edit=5, plan_restatement_count=0,
        )
        injection = build_progress_injection(progress)
        assert injection is not None
        assert "IMPLEMENTATION MODE" in injection
        assert "NEXT ACTION" in injection
        assert "bar.py" in injection.lower()

    def test_next_step_directive(self):
        from api.services.complete.agentic_controller_v2 import _EditRecord
        progress = ImplementationProgress(
            has_plan=True,
            plan_steps=[
                _PlanStep(1, "Add indexes to database.py"),
                _PlanStep(2, "Create dashboard endpoint"),
                _PlanStep(3, "Update DashboardPage.tsx"),
            ],
            edits_completed=[_EditRecord(path="backend/api/database.py", tool_name="strreplace", succeeded=True)],
            edits_failed=[],
            verifications=[], files_successfully_edited={"backend/api/database.py"},
            assistant_turns_since_last_edit=1, plan_restatement_count=0,
        )
        injection = build_progress_injection(progress)
        assert injection is not None
        assert "NEXT ACTION" in injection
        assert "step 2" in injection.lower()
        assert "Completed edits:" in injection
        assert "database.py" in injection

    def test_all_steps_complete(self):
        from api.services.complete.agentic_controller_v2 import _EditRecord
        progress = ImplementationProgress(
            has_plan=True,
            plan_steps=[
                _PlanStep(1, "Edit database.py"),
                _PlanStep(2, "Edit dashboard.py"),
            ],
            edits_completed=[
                _EditRecord(path="database.py", tool_name="strreplace", succeeded=True),
                _EditRecord(path="dashboard.py", tool_name="write", succeeded=True),
            ],
            edits_failed=[],
            verifications=[], files_successfully_edited={"database.py", "dashboard.py"},
            assistant_turns_since_last_edit=0, plan_restatement_count=0,
        )
        injection = build_progress_injection(progress)
        assert injection is not None
        assert "ALL PLAN STEPS APPEAR COMPLETE" in injection

    def test_progress_with_failed_edits(self):
        from api.services.complete.agentic_controller_v2 import _EditRecord, _VerificationRecord
        progress = ImplementationProgress(
            has_plan=True,
            plan_steps=[_PlanStep(1, "Edit foo.py")],
            edits_completed=[_EditRecord(path="foo.py", tool_name="strreplace", succeeded=True)],
            edits_failed=[_EditRecord(path="bar.py", tool_name="strreplace", succeeded=False)],
            verifications=[
                _VerificationRecord(command="pytest", succeeded=True, error_snippet=None),
                _VerificationRecord(command="ruff check", succeeded=False, error_snippet="E501 line too long"),
            ],
            files_successfully_edited={"foo.py"},
            assistant_turns_since_last_edit=0, plan_restatement_count=0,
        )
        injection = build_progress_injection(progress)
        assert injection is not None
        assert "Failed edits:" in injection
        assert "1 passed, 1 failed" in injection


# ===========================================================================
# End-to-end: the dashboard fix loop scenario
# ===========================================================================

class TestDashboardFixLoopScenario:
    """
    Simulate the exact failure mode from the dashboard fix conversation:
    the model produces the same plan 3 times without making edits.
    """

    def test_detects_restatement_loop(self):
        plan = (
            "## REASONING SUMMARY\n\n"
            "1. Add composite indexes to database.py\n"
            "2. Optimize forecast_service.py with SQL aggregation\n"
            "3. Create /dashboard/summary endpoint\n"
            "4. Add frontend API function in api.ts\n"
            "5. Update DashboardPage.tsx\n"
        )
        msgs = [
            _msg("user", "can you fix the dashboard performance?"),
            _msg("assistant", plan + "\nLet me cache file contents and implement."),
            _msg("user", "continue"),
            _msg("assistant", plan + "\nLet me implement efficiently."),
            _msg("user", "continue"),
            _msg("assistant", plan + "\nImplementing now for real."),
        ]
        progress = extract_implementation_progress(msgs)
        assert progress.plan_restatement_count == 2
        assert progress.has_plan is True
        assert len(progress.plan_steps) == 5
        assert progress.edits_completed == []

        injection = build_progress_injection(progress)
        assert injection is not None
        assert "IMPLEMENTATION MODE" in injection
        assert "NEXT ACTION" in injection
        assert "step 1" in injection.lower()
        assert "tool_call" in injection

    def test_partial_progress_shows_next_step(self):
        plan = (
            "## Implementation Plan\n\n"
            "1. Add composite indexes to database.py\n"
            "2. Create /dashboard/summary endpoint\n"
            "3. Update DashboardPage.tsx\n"
        )
        msgs = [
            _msg("user", "fix the dashboard"),
            _msg("assistant", plan),
            # Step 1 completed
            _msg("assistant", "Adding index", tool_calls=[
                _tc("strreplace", "tc_1", '{"path": "backend/api/database.py", "old_string": "old", "new_string": "new"}'),
            ]),
            _msg("tool", "The file has been updated.", tool_call_id="tc_1"),
            # Verification passed
            _msg("assistant", "Verifying", tool_calls=[
                _tc("shell", "tc_v1", '{"command": "python3 -m py_compile backend/api/database.py"}'),
            ]),
            _msg("tool", "Compilation successful.", tool_call_id="tc_v1"),
            # Model starts re-planning instead of doing step 2
            _msg("assistant", plan + "\nNow moving to step 2."),
        ]
        progress = extract_implementation_progress(msgs)
        assert len(progress.edits_completed) == 1
        assert progress.plan_restatement_count == 1
        assert len(progress.verifications) == 1
        assert progress.verifications[0].succeeded is True

        injection = build_progress_injection(progress)
        assert "IMPLEMENTATION MODE" in injection
        assert "NEXT ACTION" in injection
        assert "step 2" in injection.lower()


# ===========================================================================
# Plan extraction fallbacks
# ===========================================================================

class TestPlanExtractionFallbacks:
    """
    Tests for the fallback plan extraction logic that fires when the model
    doesn't use a recognized heading.
    """

    def test_implicit_plan_with_impl_intent(self):
        """Numbered steps with file extensions / action verbs but no heading."""
        text = (
            "Here's what I'll do:\n\n"
            "1. Add composite indexes to database.py\n"
            "2. Create the /dashboard/summary endpoint in routes/dashboard.py\n"
            "3. Update DashboardPage.tsx to use the new endpoint\n"
        )
        steps = _extract_plan_steps(text)
        assert len(steps) == 3
        assert steps[0].number == 1
        assert "database.py" in steps[0].description

    def test_implicit_plan_without_impl_intent_rejected(self):
        """Numbered steps that are just analysis points, not implementation."""
        text = (
            "Key observations:\n\n"
            "1. The system is slow because of network latency\n"
            "2. Users report timeouts during peak hours\n"
            "3. The logs show high memory usage\n"
        )
        steps = _extract_plan_steps(text)
        assert steps == []

    def test_two_step_plan_with_full_intent(self):
        """2-step plan accepted when both steps have implementation intent."""
        text = (
            "I'll do this:\n\n"
            "1. Fix the SQL query in forecast_service.py\n"
            "2. Update the API endpoint\n"
        )
        steps = _extract_plan_steps(text)
        assert len(steps) == 2

    def test_two_step_plan_partial_intent_rejected(self):
        """2-step plan rejected when only one step has intent."""
        text = (
            "My analysis:\n\n"
            "1. The performance is degraded\n"
            "2. Fix the query in forecast_service.py\n"
        )
        steps = _extract_plan_steps(text)
        assert steps == []

    def test_one_step_plan_with_intent(self):
        """Single-step plan accepted when it has clear implementation intent."""
        text = (
            "Let me fix this:\n\n"
            "1. Add the missing composite index to database.py\n"
        )
        steps = _extract_plan_steps(text)
        assert len(steps) == 1
        assert "database.py" in steps[0].description

    def test_one_step_analysis_rejected(self):
        """Single numbered point without intent is not a plan."""
        text = (
            "Key finding:\n\n"
            "1. The system is running out of memory under load\n"
        )
        steps = _extract_plan_steps(text)
        assert steps == []

    def test_expanded_headings_recognized(self):
        """New heading patterns: todo, action items, changes needed."""
        for heading in ["## TODO", "## Action Items", "## Changes Needed",
                        "## What I'll Do", "## Changes to Make"]:
            text = f"{heading}\n\n1. First thing\n2. Second thing\n"
            steps = _extract_plan_steps(text)
            assert len(steps) == 2, f"Failed for heading: {heading}"

    def test_synthesized_plan_from_edits(self):
        """When no explicit plan exists, edits create a pseudo-plan."""
        msgs = [
            _msg("user", "fix the bug"),
            _msg("assistant", "I see the issue, let me fix it.", tool_calls=[
                _tc("strreplace", "tc_1", '{"path": "backend/api/routes/usage.py", "old_string": "a", "new_string": "b"}'),
            ]),
            _msg("tool", "The file has been updated.", tool_call_id="tc_1"),
            _msg("assistant", "Now fixing the other file.", tool_calls=[
                _tc("strreplace", "tc_2", '{"path": "backend/api/config.py", "old_string": "x", "new_string": "y"}'),
            ]),
            _msg("tool", "The file has been updated.", tool_call_id="tc_2"),
        ]
        progress = extract_implementation_progress(msgs)
        assert progress.has_plan is True
        assert len(progress.plan_steps) == 2
        assert "usage.py" in progress.plan_steps[0].description
        assert "config.py" in progress.plan_steps[1].description

    def test_synthesized_plan_complete_shows_verification(self):
        """When all synthesized plan steps are done, prompt for verification."""
        msgs = [
            _msg("user", "fix it"),
            _msg("assistant", "editing", tool_calls=[
                _tc("strreplace", "tc_1", '{"path": "foo.py", "old_string": "a", "new_string": "b"}'),
            ]),
            _msg("tool", "The file has been updated.", tool_call_id="tc_1"),
            _msg("assistant", "Let me think about what else to do..."),
            _msg("assistant", "I need to reconsider the approach..."),
        ]
        progress = extract_implementation_progress(msgs)
        assert progress.has_plan is True

        injection = build_progress_injection(progress)
        assert injection is not None
        assert "ALL PLAN STEPS APPEAR COMPLETE" in injection

    def test_proactive_gate_fires_with_remaining_synthesized_steps(self):
        """Proactive gate fires when synthesized plan has unfinished steps."""
        msgs = [
            _msg("user", "fix both files"),
            _msg("assistant", "editing foo", tool_calls=[
                _tc("strreplace", "tc_1", '{"path": "foo.py", "old_string": "a", "new_string": "b"}'),
            ]),
            _msg("tool", "The file has been updated.", tool_call_id="tc_1"),
            _msg("assistant", "now bar", tool_calls=[
                _tc("strreplace", "tc_2", '{"path": "bar.py", "old_string": "x", "new_string": "y"}'),
            ]),
            _msg("tool", "Error: old_string not found", tool_call_id="tc_2"),
            _msg("assistant", "Let me reconsider..."),
            _msg("assistant", "Hmm, let me think about this differently..."),
        ]
        progress = extract_implementation_progress(msgs)
        assert progress.has_plan is True
        assert len(progress.edits_completed) == 1
        assert len(progress.edits_failed) == 1

        injection = build_progress_injection(progress)
        assert injection is not None
        assert "IMPLEMENTATION MODE" in injection

    def test_no_edits_no_plan_still_noop(self):
        """Without edits or an explicit plan, the tracker correctly returns nothing."""
        msgs = [
            _msg("user", "explain how the dashboard works"),
            _msg("assistant", "The dashboard loads data from 8 API endpoints..."),
        ]
        progress = extract_implementation_progress(msgs)
        assert progress.has_plan is False
        assert build_progress_injection(progress) is None
