"""Tests for the Drill screen (predict-output challenges)."""

import pytest
from unittest.mock import MagicMock, patch

from textual.widgets import Static

from skilark_cli.tui.app import SkilarkApp


MOCK_CHALLENGE = {
    "id": "ch-1",
    "source": "python",
    "title": "List Comprehension",
    "code_display": "nums = [1, 2, 3]\nresult = [x**2 for x in nums]\nprint(result)",
    "question": "What does this code output?",
    "expected_answer": "[1, 4, 9]",
    "explanation": "List comprehension squares each element.",
    "deep_link": "https://skilark.com/refreshers/python#list-comp",
    "hints": '["Think about the ** operator.", "It applies to each element."]',
    "challenge_type": "predict_output",
    "choices": None,
}

MOCK_CHALLENGE_2 = {
    "id": "ch-2",
    "source": "python",
    "title": "Dict Keys",
    "code_display": "d = {'a': 1, 'b': 2}\nprint(list(d.keys()))",
    "question": "What does this code output?",
    "expected_answer": "['a', 'b']",
    "explanation": "dict.keys() returns the keys.",
    "deep_link": "https://skilark.com/refreshers/python#dicts",
    "hints": '["Think about what .keys() returns."]',
    "challenge_type": "predict_output",
    "choices": None,
}

MOCK_MC_CHALLENGE = {
    "id": "ch-mc",
    "source": "kubernetes",
    "title": "Pod Restart Policy",
    "code_display": "",
    "question": "What is the default restart policy for a Kubernetes Pod?",
    "expected_answer": "A",
    "explanation": "The default restart policy is Always.",
    "deep_link": "https://skilark.com/refreshers/kubernetes#restart",
    "hints": '["Think about what happens when a container crashes."]',
    "challenge_type": "multiple_choice",
    "choices": '["A) Always", "B) OnFailure", "C) Never", "D) Depends"]',
}

MOCK_STATS = {
    "total_completed": 5,
    "streak": 3,
    "week_completed": 3,
    "week_goal": 10,
    "topic_counts": {"python": 5},
}


def _make_client(challenge=None, second_challenge=None):
    client = MagicMock()
    if second_challenge:
        client.get_next_challenge.side_effect = [
            challenge or MOCK_CHALLENGE,
            second_challenge,
        ]
    else:
        client.get_next_challenge.return_value = (
            challenge if challenge is not None else MOCK_CHALLENGE
        )
    client.get_stats.return_value = MOCK_STATS
    client.complete_challenge.return_value = None
    return client


# --- AC1: Loads challenge on launch ---


@pytest.mark.asyncio
async def test_drill_loads_on_launch():
    """AC1: Drill screen fetches a challenge on app launch."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        client.get_next_challenge.assert_called_once_with(["python"])


# --- AC2: Header shows day/source/title ---


@pytest.mark.asyncio
async def test_drill_header():
    """AC2: Challenge header shows 'Day N . source . title'."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        header = app.query_one("#drill-header", Static)
        assert "Day 6" in header.content  # total_completed=5, so day=6
        assert "python" in header.content.lower()
        assert "List Comprehension" in header.content


# --- AC3: Code displayed ---


@pytest.mark.asyncio
async def test_drill_code_displayed():
    """AC3: Code block is visible with the challenge code."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        code = app.query_one("#drill-code", Static)
        # content is a Rich Syntax object; access .code for the source text
        assert "nums" in code.content.code


# --- AC4: Question shown ---


@pytest.mark.asyncio
async def test_drill_question_shown():
    """AC4: Question text is displayed."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        question = app.query_one("#drill-question", Static)
        assert "output" in question.content.lower()


# --- AC5: Answer input exists ---


@pytest.mark.asyncio
async def test_drill_answer_input():
    """AC5: Answer input is present and focusable."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        answer = app.query_one("#drill-answer")
        assert answer is not None


# --- AC6: Correct answer ---


@pytest.mark.asyncio
async def test_drill_correct_answer():
    """AC6: Correct answer shows green confirmation + explanation."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        answer = app.query_one("#drill-answer")
        answer.focus()
        answer.value = "[1, 4, 9]"
        await pilot.press("enter")
        await pilot.pause(delay=0.5)
        result = app.query_one("#drill-result", Static)
        assert "Correct" in result.content
        assert "squares" in result.content.lower()  # explanation


# --- AC7: Wrong answer ---


@pytest.mark.asyncio
async def test_drill_wrong_answer():
    """AC7: Wrong answer shows expected answer + explanation."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        answer = app.query_one("#drill-answer")
        answer.focus()
        answer.value = "wrong"
        await pilot.press("enter")
        await pilot.pause(delay=0.5)
        result = app.query_one("#drill-result", Static)
        assert "Not quite" in result.content or "[1, 4, 9]" in result.content


# --- AC8: Self-correct ---


@pytest.mark.asyncio
async def test_drill_self_correct_yes():
    """AC8: Self-correct 'y' records the answer as correct."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        answer = app.query_one("#drill-answer")
        answer.focus()
        answer.value = "wrong"
        await pilot.press("enter")
        await pilot.pause(delay=0.5)
        # Self-correct prompt should be visible
        self_correct = app.query_one("#drill-self-correct")
        assert self_correct.display is True
        await pilot.press("y")
        await pilot.pause(delay=0.5)
        # Should record as self_corrected=True
        client.complete_challenge.assert_called_once()
        call_kwargs = client.complete_challenge.call_args
        assert call_kwargs.kwargs.get("self_corrected") is True or (
            call_kwargs[1].get("self_corrected") is True
        )


@pytest.mark.asyncio
async def test_drill_self_correct_no():
    """AC8: Self-correct 'n' records the answer as incorrect."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        answer = app.query_one("#drill-answer")
        answer.focus()
        answer.value = "wrong"
        await pilot.press("enter")
        await pilot.pause(delay=0.5)
        await pilot.press("n")
        await pilot.pause(delay=0.5)
        client.complete_challenge.assert_called_once()
        call_kwargs = client.complete_challenge.call_args
        assert call_kwargs.kwargs.get("correct") is False or (
            call_kwargs[1].get("correct") is False
        )
        assert call_kwargs.kwargs.get("self_corrected") is False or (
            call_kwargs[1].get("self_corrected") is False
        )


# --- AC9: Hints ---


@pytest.mark.asyncio
async def test_drill_escape_unfocuses_input():
    """Escape unfocuses the Input so h/s/n shortcuts work."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        answer = app.query_one("#drill-answer")
        answer.focus()
        await pilot.pause()
        assert answer.has_focus
        await pilot.press("escape")
        await pilot.pause()
        assert not answer.has_focus


@pytest.mark.asyncio
async def test_drill_skip_after_escape():
    """Skip works after pressing Escape to leave the Input."""
    client = _make_client(second_challenge=MOCK_CHALLENGE_2)
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        answer = app.query_one("#drill-answer")
        answer.focus()
        await pilot.pause()
        await pilot.press("escape")  # unfocus
        await pilot.pause()
        await pilot.press("s")
        await pilot.pause(delay=0.5)
        assert client.get_next_challenge.call_count == 2


@pytest.mark.asyncio
async def test_drill_hint():
    """AC9: Pressing 'h' shows the first hint."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("h")
        await pilot.pause()
        hint = app.query_one("#drill-hint", Static)
        assert "operator" in hint.content.lower()


@pytest.mark.asyncio
async def test_drill_hint_exhausted():
    """AC9: When all hints used, shows 'No more hints'."""
    client = _make_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("h")  # first hint
        await pilot.pause()
        await pilot.press("h")  # second hint
        await pilot.pause()
        await pilot.press("h")  # no more
        await pilot.pause()
        hint = app.query_one("#drill-hint", Static)
        assert "No more hints" in hint.content


# --- AC10: Skip ---


@pytest.mark.asyncio
async def test_drill_skip():
    """AC10: Pressing 's' skips to the next challenge (after unfocusing input)."""
    client = _make_client(second_challenge=MOCK_CHALLENGE_2)
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("s")
        await pilot.pause(delay=0.5)
        assert client.get_next_challenge.call_count == 2


@pytest.mark.asyncio
async def test_drill_skip_does_not_increment_day():
    """Skip is not a completion — day counter should stay the same."""
    client = _make_client(second_challenge=MOCK_CHALLENGE_2)
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        header = app.query_one("#drill-header", Static)
        assert "Day 6" in header.content
        await pilot.press("s")
        await pilot.pause(delay=0.5)
        header = app.query_one("#drill-header", Static)
        assert "Day 6" in header.content  # still Day 6, not Day 7


# --- AC11: Next after result ---


@pytest.mark.asyncio
async def test_drill_next_after_correct():
    """AC11: After correct answer, pressing 'n' loads next challenge."""
    client = _make_client(second_challenge=MOCK_CHALLENGE_2)
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        answer = app.query_one("#drill-answer")
        answer.focus()
        answer.value = "[1, 4, 9]"
        await pilot.press("enter")
        await pilot.pause(delay=0.5)
        await pilot.press("n")
        await pilot.pause(delay=0.5)
        header = app.query_one("#drill-header", Static)
        assert "Day 7" in header.content  # incremented


# --- AC12: No challenges ---


@pytest.mark.asyncio
async def test_drill_no_challenges():
    """AC12: When no challenges available, shows empty message."""
    client = MagicMock()
    client.get_next_challenge.return_value = None
    client.get_stats.return_value = {"total_completed": 0}
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        empty = app.query_one("#drill-empty", Static)
        assert empty.display is True
        assert "No more challenges" in empty.content or "No coding problems" in empty.content


# --- AC13: Multiple choice ---


@pytest.mark.asyncio
async def test_drill_multiple_choice():
    """AC13: Multiple choice challenge shows options."""
    client = _make_client(challenge=MOCK_MC_CHALLENGE)
    app = SkilarkApp(client=client, topics=["kubernetes"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        choices = app.query_one("#drill-choices", Static)
        assert "Always" in choices.content
        assert "OnFailure" in choices.content


# --- AC14: Day increments ---


@pytest.mark.asyncio
async def test_drill_day_increments():
    """AC14: Day counter increments after completing a challenge."""
    client = _make_client(second_challenge=MOCK_CHALLENGE_2)
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        # Day should start at 6 (total_completed=5 + 1)
        header = app.query_one("#drill-header", Static)
        assert "Day 6" in header.content

        answer = app.query_one("#drill-answer")
        answer.focus()
        answer.value = "[1, 4, 9]"
        await pilot.press("enter")
        await pilot.pause(delay=0.5)
        await pilot.press("n")
        await pilot.pause(delay=0.5)

        header = app.query_one("#drill-header", Static)
        assert "Day 7" in header.content


# --- Coding mode mock data ---

MOCK_CODING_PROBLEM = {
    "id": "cp-1",
    "title": "Two Sum",
    "difficulty": 2,
    "category": "arrays",
    "description": "Given an array of integers nums and an integer target, return indices of the two numbers that add up to target.",
    "languages": {
        "python": {
            "function_name": "two_sum",
            "starter_code": "def two_sum(nums, target):\n    pass\n",
        }
    },
    "test_cases": [
        {"input": {"nums": [2, 7, 11], "target": 9}, "expected_output": [0, 1], "hidden": False},
        {"input": {"nums": [3, 3], "target": 6}, "expected_output": [0, 1], "hidden": False},
    ],
    "hints": ["Use a hash map to store seen values."],
    "explanation": "Store each number's index in a dict. For each number, check if target - number exists.",
}


def _make_coding_client(problem=None):
    """Create a mock client for coding problem tests."""
    client = MagicMock()
    client.get_next_challenge.return_value = MOCK_CHALLENGE  # quiz mode fallback
    client.get_next_coding_problem.return_value = problem or MOCK_CODING_PROBLEM
    client.get_stats.return_value = MOCK_STATS
    client.complete_challenge.return_value = None
    client.submit_coding_problem.return_value = None
    return client


# --- AC-C1: Mode toggle ---


@pytest.mark.asyncio
async def test_drill_mode_toggle():
    """Pressing 'm' toggles between quiz and code mode."""
    client = _make_coding_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        # Starts in quiz mode — answer input should be visible
        assert app.query_one("#drill-answer").display is True
        # Toggle to code mode
        await pilot.press("m")
        await pilot.pause(delay=0.5)
        # Editor should be visible, answer input hidden
        from textual.widgets import TextArea
        editor = app.query_one("#drill-editor", TextArea)
        assert editor is not None
        assert app.query_one("#drill-answer").display is False


# --- AC-C2: Code mode shows problem description ---


@pytest.mark.asyncio
async def test_drill_code_mode_description():
    """Code mode shows the problem description."""
    client = _make_coding_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")
        await pilot.pause(delay=0.5)
        desc = app.query_one("#drill-description", Static)
        assert "two numbers" in desc.content.lower() or "indices" in desc.content.lower()


# --- AC-C3: Editor has starter code ---


@pytest.mark.asyncio
async def test_drill_code_mode_starter_code():
    """Code mode editor is pre-filled with starter code."""
    client = _make_coding_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")
        await pilot.pause(delay=0.5)
        from textual.widgets import TextArea
        editor = app.query_one("#drill-editor", TextArea)
        assert "def two_sum" in editor.text


# --- AC-C4: Toggle back to quiz ---


@pytest.mark.asyncio
async def test_drill_toggle_back_to_quiz():
    """Pressing 'm' again returns to quiz mode."""
    client = _make_coding_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")  # to code
        await pilot.pause(delay=0.5)
        await pilot.press("escape")  # unfocus editor first
        await pilot.pause()
        await pilot.press("m")  # back to quiz
        await pilot.pause(delay=0.5)
        assert app.query_one("#drill-answer").display is True


# --- AC-C5: No coding problems available ---


@pytest.mark.asyncio
async def test_drill_code_mode_no_problems():
    """When no coding problems, shows empty message in code mode."""
    client = _make_coding_client(problem=None)
    client.get_next_coding_problem.return_value = None
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")
        await pilot.pause(delay=0.5)
        empty = app.query_one("#drill-empty", Static)
        assert empty.display is True


# --- AC-C6: Header shows code mode indicator ---


@pytest.mark.asyncio
async def test_drill_code_mode_header():
    """Code mode header shows difficulty and [Code] indicator."""
    client = _make_coding_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")
        await pilot.pause(delay=0.5)
        header = app.query_one("#drill-header", Static)
        assert "Code" in header.content
        assert "Two Sum" in header.content


# --- AC-C7: Run code shows results ---


@pytest.mark.asyncio
async def test_drill_code_run_shows_results(tmp_path):
    """ctrl+r runs code and shows test results."""
    client = _make_coding_client()
    app = SkilarkApp(client=client, topics=["python"], code_cache_dir=tmp_path)

    mock_result = {
        "all_passed": False,
        "passed_count": 1,
        "total_count": 2,
        "results": [
            {"case": 0, "passed": True, "expected": "[0, 1]", "actual": "[0, 1]", "time_ms": 1.0, "hidden": False},
            {"case": 1, "passed": False, "expected": "[0, 1]", "actual": "None", "time_ms": 0.5, "hidden": False},
        ],
        "error": None,
    }

    with patch("skilark_cli.tui.screens.drill.run_code_local", return_value=mock_result):
        with patch("skilark_cli.tui.screens.drill.build_python_harness", return_value="# harness"):
            async with app.run_test() as pilot:
                await pilot.pause(delay=0.5)
                await pilot.press("m")  # code mode
                await pilot.pause(delay=0.5)
                await pilot.press("escape")  # unfocus editor
                await pilot.pause()
                await pilot.press("ctrl+r")
                await pilot.pause(delay=1.0)
                results = app.query_one("#drill-test-results", Static)
                assert results.display is True
                assert "1/2" in results.content


# --- AC-C8: All pass auto-submits ---


@pytest.mark.asyncio
async def test_drill_code_all_pass_submits(tmp_path):
    """When all tests pass, auto-submits to API."""
    client = _make_coding_client()
    app = SkilarkApp(client=client, topics=["python"], code_cache_dir=tmp_path)

    mock_result = {
        "all_passed": True,
        "passed_count": 2,
        "total_count": 2,
        "results": [
            {"case": 0, "passed": True, "expected": "[0, 1]", "actual": "[0, 1]", "time_ms": 1.0, "hidden": False},
            {"case": 1, "passed": True, "expected": "[0, 1]", "actual": "[0, 1]", "time_ms": 0.5, "hidden": False},
        ],
        "error": None,
    }

    with patch("skilark_cli.tui.screens.drill.run_code_local", return_value=mock_result):
        with patch("skilark_cli.tui.screens.drill.build_python_harness", return_value="# harness"):
            async with app.run_test() as pilot:
                await pilot.pause(delay=0.5)
                await pilot.press("m")
                await pilot.pause(delay=0.5)
                await pilot.press("escape")
                await pilot.pause()
                await pilot.press("ctrl+r")
                await pilot.pause(delay=1.0)
                results = app.query_one("#drill-test-results", Static)
                assert "All" in results.content and "passed" in results.content
                await pilot.pause(delay=0.5)
                client.submit_coding_problem.assert_called_once()


# --- AC-C9: Execution error ---


@pytest.mark.asyncio
async def test_drill_code_execution_error(tmp_path):
    """Execution error shows error message."""
    client = _make_coding_client()
    app = SkilarkApp(client=client, topics=["python"], code_cache_dir=tmp_path)

    mock_result = {
        "all_passed": False,
        "passed_count": 0,
        "total_count": 0,
        "results": [],
        "error": "SyntaxError: invalid syntax",
    }

    with patch("skilark_cli.tui.screens.drill.run_code_local", return_value=mock_result):
        with patch("skilark_cli.tui.screens.drill.build_python_harness", return_value="# harness"):
            async with app.run_test() as pilot:
                await pilot.pause(delay=0.5)
                await pilot.press("m")
                await pilot.pause(delay=0.5)
                await pilot.press("escape")
                await pilot.pause()
                await pilot.press("ctrl+r")
                await pilot.pause(delay=1.0)
                results = app.query_one("#drill-test-results", Static)
                assert "SyntaxError" in results.content


# --- AC-C10: Hint in code mode ---


@pytest.mark.asyncio
async def test_drill_code_hint():
    """Pressing 'h' in code mode shows coding problem hint."""
    client = _make_coding_client()
    app = SkilarkApp(client=client, topics=["python"])
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")
        await pilot.pause(delay=0.5)
        await pilot.press("escape")  # unfocus editor
        await pilot.pause()
        await pilot.press("h")
        await pilot.pause()
        hint = app.query_one("#drill-hint", Static)
        assert "hash map" in hint.content.lower()


# --- Submission failure does not break UI ---


@pytest.mark.asyncio
async def test_drill_code_submit_failure_does_not_break_ui(tmp_path):
    """A failed submission does not prevent the user from continuing."""
    client = _make_coding_client()
    client.submit_coding_problem.side_effect = RuntimeError("network error")
    app = SkilarkApp(client=client, topics=["python"], code_cache_dir=tmp_path)

    mock_result = {
        "all_passed": True,
        "passed_count": 2,
        "total_count": 2,
        "results": [
            {"case": 0, "passed": True, "expected": "[0, 1]", "actual": "[0, 1]", "time_ms": 1.0, "hidden": False},
            {"case": 1, "passed": True, "expected": "[0, 1]", "actual": "[0, 1]", "time_ms": 0.5, "hidden": False},
        ],
        "error": None,
    }

    with patch("skilark_cli.tui.screens.drill.run_code_local", return_value=mock_result):
        with patch("skilark_cli.tui.screens.drill.build_python_harness", return_value="# harness"):
            async with app.run_test() as pilot:
                await pilot.pause(delay=0.5)
                await pilot.press("m")
                await pilot.pause(delay=0.5)
                await pilot.press("escape")
                await pilot.pause()
                await pilot.press("ctrl+r")
                await pilot.pause(delay=1.0)
                # UI should still show success despite submission failure
                results = app.query_one("#drill-test-results", Static)
                assert "All" in results.content and "passed" in results.content
                # User can still press 'n' to proceed
                await pilot.press("n")
                await pilot.pause(delay=0.5)
                # Should have fetched next problem (day incremented)
                assert client.get_next_coding_problem.call_count == 2


# --- Issue #67: Code cache ---

from skilark_cli.code_cache import CodeCache


MOCK_CODING_PROBLEM_2 = {
    "id": "cp-2",
    "title": "Valid Parentheses",
    "difficulty": 1,
    "category": "stacks",
    "description": "Check if parentheses are valid.",
    "languages": {
        "python": {
            "function_name": "is_valid",
            "starter_code": "def is_valid(s):\n    pass\n",
        }
    },
    "test_cases": [
        {"input": {"s": "()"}, "expected_output": True, "hidden": False},
    ],
    "hints": ["Use a stack."],
    "explanation": "Push open brackets, pop on close.",
}


@pytest.mark.asyncio
async def test_code_cache_restores_on_load(tmp_path):
    """Returning to a problem restores cached code instead of starter code."""
    cache = CodeCache(language="python", config_dir=tmp_path)
    cache.save_current("cp-1", "def two_sum(nums, target):\n    # my progress\n")

    client = _make_coding_client()
    app = SkilarkApp(
        client=client, topics=["python"], coding_language="python",
        code_cache_dir=tmp_path,
    )
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")  # code mode
        await pilot.pause(delay=0.5)
        from textual.widgets import TextArea
        editor = app.query_one("#drill-editor", TextArea)
        assert "# my progress" in editor.text


@pytest.mark.asyncio
async def test_code_cache_skip_saves_current(tmp_path):
    """Skipping a problem saves editor text to cache."""
    client = _make_coding_client()
    client.get_next_coding_problem.side_effect = [MOCK_CODING_PROBLEM, MOCK_CODING_PROBLEM_2]
    app = SkilarkApp(
        client=client, topics=["python"], coding_language="python",
        code_cache_dir=tmp_path,
    )
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")  # code mode
        await pilot.pause(delay=0.5)
        from textual.widgets import TextArea
        editor = app.query_one("#drill-editor", TextArea)
        editor.text = "def two_sum(nums, target):\n    # wip\n"
        await pilot.pause()
        await pilot.press("escape")  # unfocus editor
        await pilot.pause()
        await pilot.press("s")  # skip
        await pilot.pause(delay=0.5)

    cache = CodeCache(language="python", config_dir=tmp_path)
    result = cache.load("cp-1")
    assert result["current"] == "def two_sum(nums, target):\n    # wip\n"


@pytest.mark.asyncio
async def test_code_cache_mode_toggle_saves(tmp_path):
    """Toggling from code to quiz saves editor text to cache."""
    client = _make_coding_client()
    app = SkilarkApp(
        client=client, topics=["python"], coding_language="python",
        code_cache_dir=tmp_path,
    )
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")  # code mode
        await pilot.pause(delay=0.5)
        from textual.widgets import TextArea
        editor = app.query_one("#drill-editor", TextArea)
        editor.text = "def two_sum(nums, target):\n    # wip\n"
        await pilot.pause()
        await pilot.press("escape")
        await pilot.pause()
        await pilot.press("m")  # back to quiz
        await pilot.pause(delay=0.5)

    cache = CodeCache(language="python", config_dir=tmp_path)
    result = cache.load("cp-1")
    assert result["current"] == "def two_sum(nums, target):\n    # wip\n"


@pytest.mark.asyncio
async def test_code_cache_unmount_saves(tmp_path):
    """Quitting the app saves editor text to cache."""
    client = _make_coding_client()
    app = SkilarkApp(
        client=client, topics=["python"], coding_language="python",
        code_cache_dir=tmp_path,
    )
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")  # code mode
        await pilot.pause(delay=0.5)
        from textual.widgets import TextArea
        editor = app.query_one("#drill-editor", TextArea)
        editor.text = "def two_sum(nums, target):\n    # quitting\n"
        await pilot.pause()
        # App exits when run_test context closes

    cache = CodeCache(language="python", config_dir=tmp_path)
    result = cache.load("cp-1")
    assert result["current"] == "def two_sum(nums, target):\n    # quitting\n"


@pytest.mark.asyncio
async def test_code_cache_autosave_on_edit(tmp_path):
    """Editor changes trigger debounced autosave to cache."""
    client = _make_coding_client()
    app = SkilarkApp(
        client=client, topics=["python"], coding_language="python",
        code_cache_dir=tmp_path, autosave_delay=0.1,
    )
    async with app.run_test() as pilot:
        await pilot.pause(delay=0.5)
        await pilot.press("m")  # code mode
        await pilot.pause(delay=0.5)
        from textual.widgets import TextArea
        editor = app.query_one("#drill-editor", TextArea)
        editor.text = "def two_sum(nums, target):\n    # autosaved\n"
        await pilot.pause(delay=1.0)  # Wait for 0.1s debounce + margin

        # Verify autosave happened while app is still running
        cache = CodeCache(language="python", config_dir=tmp_path)
        result = cache.load("cp-1")
        assert result["current"] == "def two_sum(nums, target):\n    # autosaved\n"


@pytest.mark.asyncio
async def test_failed_run_submits_to_api(tmp_path):
    """A failed ctrl+r run submits to API with all_passed=False."""
    client = _make_coding_client()
    app = SkilarkApp(
        client=client, topics=["python"], coding_language="python",
        code_cache_dir=tmp_path,
    )

    mock_result = {
        "all_passed": False,
        "passed_count": 1,
        "total_count": 2,
        "results": [
            {"case": 0, "passed": True, "expected": "[0, 1]", "actual": "[0, 1]", "time_ms": 1.0, "hidden": False},
            {"case": 1, "passed": False, "expected": "[0, 1]", "actual": "None", "time_ms": 0.5, "hidden": False},
        ],
        "error": None,
    }

    with patch("skilark_cli.tui.screens.drill.run_code_local", return_value=mock_result):
        with patch("skilark_cli.tui.screens.drill.build_python_harness", return_value="# harness"):
            async with app.run_test() as pilot:
                await pilot.pause(delay=0.5)
                await pilot.press("m")
                await pilot.pause(delay=0.5)
                await pilot.press("escape")
                await pilot.pause()
                await pilot.press("ctrl+r")
                await pilot.pause(delay=1.0)
                client.submit_coding_problem.assert_called_once()
                call_kwargs = client.submit_coding_problem.call_args
                assert call_kwargs.kwargs.get("all_passed") is False or call_kwargs[1].get("all_passed") is False


@pytest.mark.asyncio
async def test_duplicate_run_does_not_resubmit(tmp_path):
    """Running the same code twice only submits once."""
    client = _make_coding_client()
    app = SkilarkApp(
        client=client, topics=["python"], coding_language="python",
        code_cache_dir=tmp_path,
    )

    mock_result = {
        "all_passed": False,
        "passed_count": 1,
        "total_count": 2,
        "results": [
            {"case": 0, "passed": True, "expected": "[0, 1]", "actual": "[0, 1]", "time_ms": 1.0, "hidden": False},
            {"case": 1, "passed": False, "expected": "[0, 1]", "actual": "None", "time_ms": 0.5, "hidden": False},
        ],
        "error": None,
    }

    with patch("skilark_cli.tui.screens.drill.run_code_local", return_value=mock_result):
        with patch("skilark_cli.tui.screens.drill.build_python_harness", return_value="# harness"):
            async with app.run_test() as pilot:
                await pilot.pause(delay=0.5)
                await pilot.press("m")
                await pilot.pause(delay=0.5)
                await pilot.press("escape")
                await pilot.pause()
                await pilot.press("ctrl+r")
                await pilot.pause(delay=1.0)
                await pilot.press("ctrl+r")  # same code again
                await pilot.pause(delay=1.0)
                assert client.submit_coding_problem.call_count == 1


@pytest.mark.asyncio
async def test_failed_run_records_attempt_in_cache(tmp_path):
    """A failed run records the attempt in the cache."""
    client = _make_coding_client()
    app = SkilarkApp(
        client=client, topics=["python"], coding_language="python",
        code_cache_dir=tmp_path,
    )

    mock_result = {
        "all_passed": False,
        "passed_count": 1,
        "total_count": 2,
        "results": [
            {"case": 0, "passed": True, "expected": "[0, 1]", "actual": "[0, 1]", "time_ms": 1.0, "hidden": False},
            {"case": 1, "passed": False, "expected": "[0, 1]", "actual": "None", "time_ms": 0.5, "hidden": False},
        ],
        "error": None,
    }

    with patch("skilark_cli.tui.screens.drill.run_code_local", return_value=mock_result):
        with patch("skilark_cli.tui.screens.drill.build_python_harness", return_value="# harness"):
            async with app.run_test() as pilot:
                await pilot.pause(delay=0.5)
                await pilot.press("m")
                await pilot.pause(delay=0.5)
                await pilot.press("escape")
                await pilot.pause()
                await pilot.press("ctrl+r")
                await pilot.pause(delay=1.0)

    cache = CodeCache(language="python", config_dir=tmp_path)
    result = cache.load("cp-1")
    assert len(result["attempts"]) == 1


@pytest.mark.asyncio
async def test_passed_run_clears_current_in_cache(tmp_path):
    """A successful run records in passed and clears current."""
    client = _make_coding_client()
    app = SkilarkApp(
        client=client, topics=["python"], coding_language="python",
        code_cache_dir=tmp_path,
    )

    mock_result = {
        "all_passed": True,
        "passed_count": 2,
        "total_count": 2,
        "results": [
            {"case": 0, "passed": True, "expected": "[0, 1]", "actual": "[0, 1]", "time_ms": 1.0, "hidden": False},
            {"case": 1, "passed": True, "expected": "[0, 1]", "actual": "[0, 1]", "time_ms": 0.5, "hidden": False},
        ],
        "error": None,
    }

    with patch("skilark_cli.tui.screens.drill.run_code_local", return_value=mock_result):
        with patch("skilark_cli.tui.screens.drill.build_python_harness", return_value="# harness"):
            async with app.run_test() as pilot:
                await pilot.pause(delay=0.5)
                await pilot.press("m")
                await pilot.pause(delay=0.5)
                await pilot.press("escape")
                await pilot.pause()
                await pilot.press("ctrl+r")
                await pilot.pause(delay=1.0)

    cache = CodeCache(language="python", config_dir=tmp_path)
    result = cache.load("cp-1")
    assert result["current"] is None
    assert len(result["passed"]) == 1
