"""Tests for TruncationResult dataclass and detect_truncation function.

These tests verify the interface contracts from the LLM structured output module,
specifically the behavior of truncation detection and partial data recovery.
"""

from __future__ import annotations

import json
from unittest.mock import MagicMock

import pytest
from pydantic import BaseModel

from unfamiliar.llm.missions.schemas import (
    CategoryMissionsResponse,
    MissionItemResponse,
)
from unfamiliar.llm.structured import (
    TruncationResult,
    _extract_json,
    create_structured_chain,
    detect_truncation,
)


def _make_complete_missions_json(missions: list[dict]) -> str:
    """Build a complete JSON string from mission dicts."""
    return json.dumps({"missions": missions, "chains": []})


def _make_minimal_mission_dict(title: str = "Test Mission") -> dict:
    """Create a minimal valid mission dict for JSON construction."""
    return {
        "title": title,
        "title_plain": title,
        "description": "A test mission.",
        "difficulty": "quick",
        "completion_condition": "Done.",
        "security_tag": False,
        "standard_tag": None,
        "narrative_intro": "Intro.",
        "narrative_outro": "Outro.",
        "file_refs": [],
        "module_index": 0,
        "prerequisite_titles": [],
    }


class TestTruncationResult:
    """Test TruncationResult dataclass field combinations per valid states table."""

    def test_complete_response_state_all_fields_set_correctly(self):
        """Complete response state: valid model, is_truncated=False, all others None."""
        mission = MissionItemResponse(
            title="Test",
            title_plain="Test",
            description="Desc",
            difficulty="quick",
            completion_condition="Done",
            security_tag=False,
            narrative_intro="Intro",
            narrative_outro="Outro",
            file_refs=[],
            module_index=0,
        )
        response = CategoryMissionsResponse(missions=[mission], chains=[])

        result = TruncationResult(
            recovered_data=response,
            is_truncated=False,
        )

        assert result.recovered_data is response
        assert result.is_truncated is False

    def test_recovered_partial_state_all_fields_set_correctly(self):
        """Recovered partial state: valid model, is_truncated=True,
        index set, hint set.
        """
        mission = MissionItemResponse(
            title="Partial",
            title_plain="Partial",
            description="Desc",
            difficulty="quick",
            completion_condition="Done",
            security_tag=False,
            narrative_intro="Intro",
            narrative_outro="Outro",
            file_refs=[],
            module_index=0,
        )
        response = CategoryMissionsResponse(missions=[mission], chains=[])

        result = TruncationResult(
            recovered_data=response,
            is_truncated=True,
        )

        assert result.recovered_data is response
        assert result.is_truncated is True

    def test_nothing_recovered_state_all_fields_set_correctly(self):
        """Nothing recovered state: None, is_truncated=True, index None, hint set."""
        result = TruncationResult(
            recovered_data=None,
            is_truncated=True,
        )

        assert result.recovered_data is None
        assert result.is_truncated is True

    def test_empty_input_state_all_fields_set_correctly(self):
        """Empty input state: None, is_truncated=False, all others None."""
        result = TruncationResult(
            recovered_data=None,
            is_truncated=False,
        )

        assert result.recovered_data is None
        assert result.is_truncated is False


class TestDetectTruncationCompleteResponse:
    """Test detect_truncation with complete, valid JSON responses."""

    def test_complete_json_returns_valid_model_not_truncated(self):
        """Complete JSON with missions array returns parsed model with
        is_truncated=False."""
        mission_dict = _make_minimal_mission_dict("First Mission")
        json_text = _make_complete_missions_json([mission_dict])

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.is_truncated is False
        assert result.recovered_data is not None
        assert isinstance(result.recovered_data, CategoryMissionsResponse)
        assert len(result.recovered_data.missions) == 1
        assert result.recovered_data.missions[0].title_plain == "First Mission"

    def test_json_in_markdown_fence_extracted_and_parsed(self):
        """JSON wrapped in markdown code fence is extracted and parsed."""
        mission_dict = _make_minimal_mission_dict("Fenced Mission")
        inner_json = _make_complete_missions_json([mission_dict])
        fenced_text = f"```json\n{inner_json}\n```"

        result = detect_truncation(fenced_text, CategoryMissionsResponse)

        assert result.is_truncated is False
        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1
        assert result.recovered_data.missions[0].title_plain == "Fenced Mission"

    def test_complete_json_with_multiple_missions_all_recovered(self):
        """Complete JSON with multiple missions returns all with is_truncated=False."""
        mission1 = _make_minimal_mission_dict("Mission One")
        mission2 = _make_minimal_mission_dict("Mission Two")
        mission3 = _make_minimal_mission_dict("Mission Three")
        json_text = _make_complete_missions_json([mission1, mission2, mission3])

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.is_truncated is False
        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 3
        assert result.recovered_data.missions[0].title_plain == "Mission One"
        assert result.recovered_data.missions[1].title_plain == "Mission Two"
        assert result.recovered_data.missions[2].title_plain == "Mission Three"

    def test_complete_json_with_chains_field_parsed(self):
        """Complete JSON with chains field is parsed correctly."""
        mission_dict = _make_minimal_mission_dict("Chained Mission")
        full_json = {
            "missions": [mission_dict],
            "chains": [
                {
                    "name": "Test Chain",
                    "name_plain": "Test Chain",
                    "description": "A chain",
                    "mission_titles": ["Chained Mission"],
                }
            ],
        }
        json_text = json.dumps(full_json)

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.is_truncated is False
        assert result.recovered_data is not None
        assert len(result.recovered_data.chains) == 1
        assert result.recovered_data.chains[0].name_plain == "Test Chain"


class TestDetectTruncationEmptyInput:
    """Test detect_truncation with empty and whitespace-only inputs."""

    def test_empty_string_returns_not_truncated_none_data(self):
        """Empty string returns (None, False, None, None) per contract."""
        result = detect_truncation("", CategoryMissionsResponse)

        assert result.recovered_data is None
        assert result.is_truncated is False

    def test_whitespace_only_returns_not_truncated_none_data(self):
        """Whitespace-only string returns (None, False, None, None) per contract."""
        result = detect_truncation("   \n\t  \n", CategoryMissionsResponse)

        assert result.recovered_data is None
        assert result.is_truncated is False


class TestDetectTruncationTruncatedResponse:
    """Test detect_truncation with truncated JSON that can recover partial data.

    Note: The implementation repairs truncated JSON in _extract_json before
    reaching the _recover_truncated_json_with_info path. This means repaired
    JSON parses as complete (is_truncated=False) rather than partial recovery.
    """

    def test_truncated_mid_mission_recovers_previous_complete_missions(self):
        """Truncated during a mission object: repair extracts complete missions.

        The implementation repairs the JSON by closing the array, so the result
        parses as complete (is_truncated=False) with recovered mission data.
        """
        mission1 = _make_minimal_mission_dict("Complete Mission")
        mission2_partial = (
            '{"title": "Incomplete Mission", "title_plain": "Incomplete Mission", '
            '"description": "This mission got cut off...'
        )
        json_text = '{"missions": [' + json.dumps(mission1) + ", " + mission2_partial

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1
        assert result.recovered_data.missions[0].title_plain == "Complete Mission"

    def test_truncated_after_array_start_no_complete_missions(self):
        """Truncated immediately after missions array start with no complete objects."""
        json_text = '{"missions": [{"title": "Started But Not Finished"'

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.is_truncated is True
        assert result.recovered_data is None

    def test_truncated_response_with_two_complete_missions_recovers_both(self):
        """Truncated after two complete missions: repair extracts both.

        The implementation repairs the JSON, so both complete missions are
        recovered and parsed successfully.
        """
        mission1 = _make_minimal_mission_dict("First Complete")
        mission2 = _make_minimal_mission_dict("Second Complete")
        mission3_partial = '{"title": "Third Incomplete"'
        json_text = (
            '{"missions": ['
            + json.dumps(mission1)
            + ", "
            + json.dumps(mission2)
            + ", "
            + mission3_partial
        )

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 2
        assert result.recovered_data.missions[0].title_plain == "First Complete"
        assert result.recovered_data.missions[1].title_plain == "Second Complete"

    def test_truncated_mid_key_recovers_previous_missions(self):
        """Truncated in the middle of a key name recovers previous complete missions."""
        mission1 = _make_minimal_mission_dict("Complete Mission")
        json_text = '{"missions": [' + json.dumps(mission1) + ', {"tit'

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1
        assert result.recovered_data.missions[0].title_plain == "Complete Mission"

    def test_truncated_mid_string_value_recovers_previous_missions(self):
        """Truncated in the middle of a string value recovers previous missions."""
        mission1 = _make_minimal_mission_dict("Complete Mission")
        json_text = '{"missions": [' + json.dumps(mission1) + ', {"title": "Incomplete'

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1

    def test_truncated_immediately_after_array_start_no_missions(self):
        """Truncated right after 'missions': [ with no objects."""
        json_text = '{"missions": ['

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.is_truncated is True
        assert result.recovered_data is None


class TestDetectTruncationInvalidInput:
    """Test detect_truncation with invalid inputs that cannot recover data."""

    def test_no_json_object_returns_error_hint(self):
        """Plain text with no JSON returns (None, True, None, error hint)."""
        plain_text = "This is just plain text with no JSON structure at all."

        result = detect_truncation(plain_text, CategoryMissionsResponse)

        assert result.recovered_data is None
        assert result.is_truncated is True

    def test_invalid_json_syntax_returns_recovery_failure(self):
        """Malformed JSON that cannot be parsed returns recovery failure."""
        invalid_json = '{"missions": [{this is not valid json at all}'

        result = detect_truncation(invalid_json, CategoryMissionsResponse)

        assert result.recovered_data is None
        assert result.is_truncated is True

    def test_json_without_missions_field_cannot_recover(self):
        """JSON with wrong structure (no missions field) cannot recover."""
        wrong_structure = '{"items": [{"name": "something"}]}'

        result = detect_truncation(wrong_structure, CategoryMissionsResponse)

        assert result.recovered_data is None
        assert result.is_truncated is True

    def test_unclosed_markdown_fence_with_truncated_json_recovers(self):
        """Unclosed markdown fence with truncated JSON: repair extracts mission.

        The implementation detects unclosed fence and repairs the JSON,
        so the result parses successfully with recovered data.
        """
        mission1 = _make_minimal_mission_dict("In Fence")
        truncated_json = _make_complete_missions_json([mission1])[:-10]
        fenced_text = f"```json\n{truncated_json}"

        result = detect_truncation(fenced_text, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1
        assert result.recovered_data.missions[0].title_plain == "In Fence"


class TestDetectTruncationStringEscapes:
    """Test that esape characters in strings don't break recovery"""

    def test_escaped_quotes_in_mission_title_recovers_correctly(self):
        """Mission with \"quotes\" in title is recovered without breaking parser."""
        mission1 = _make_minimal_mission_dict('Mission "Alpha"')
        mission2 = _make_minimal_mission_dict("Incomplete Mission")

        complete = _make_complete_missions_json([mission1, mission2])
        truncated = complete[: complete.index("Incomplete") + 5]

        result = detect_truncation(truncated, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1
        assert result.recovered_data.missions[0].title_plain == 'Mission "Alpha"'

    def test_escaped_backslashes_in_mission_description_recovers_correctly(self):
        """Mission with \\backslashes\\ in description is recovered without
        breaking parser.
        """

        mission1 = _make_minimal_mission_dict("First Mission")
        mission1["description"] = "Path: C:\\Users\\test\\file.txt"
        mission2 = _make_minimal_mission_dict("Second Mission")

        complete = _make_complete_missions_json([mission1, mission2])
        truncated = complete[: complete.index("Second Mission") + 3]

        result = detect_truncation(truncated, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1
        assert (
            "C:\\Users\\test\\file.txt" in result.recovered_data.missions[0].description
        )

    def test_mixed_escapes_in_same_mission_recovers_correctly(self):
        """Multiple escape types in same mission don't confuse parser.."""
        mission1 = _make_minimal_mission_dict('She said "Hello"')
        mission1["description"] = "Path: C:\\Users\\test\\file.txt"
        mission2 = _make_minimal_mission_dict("Truncated Mission")

        complete = _make_complete_missions_json([mission1, mission2])
        truncated = complete[: complete.index("Truncated") + 3]

        result = detect_truncation(truncated, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1
        assert result.recovered_data.missions[0].title_plain == 'She said "Hello"'
        assert (
            "C:\\Users\\test\\file.txt" in result.recovered_data.missions[0].description
        )

    def test_newlines_in_description_recovers_correctly(self):
        """Embedded newlines (escaped as \\n) don't break parsing."""
        mission1 = _make_minimal_mission_dict("First")
        mission1["description"] = "Line one\nLine two\nLine three"
        mission2 = _make_minimal_mission_dict("Second")

        complete = _make_complete_missions_json([mission1, mission2])
        truncated = complete[: complete.index("Second") + 3]

        result = detect_truncation(truncated, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1
        assert result.recovered_data.missions[0].title_plain == "First"
        assert (
            result.recovered_data.missions[0].description
            == "Line one\nLine two\nLine three"
        )

    def test_escaped_quotes_dont_prematurely_close_string_tracking(self):
        """Parser doesn't treat \" as string terminator."""
        mission1 = _make_minimal_mission_dict("First")
        # JSON will encode the quotes as \", creating escaped quotes
        # If the parser treats \" as string-end, the {fake json} will
        # be counted as real depth, breaking object detection
        mission1["description"] = 'She said "hello" then {fake json} appeared'
        mission2 = _make_minimal_mission_dict("Second")

        complete = _make_complete_missions_json([mission1, mission2])
        truncated = complete[: complete.index("Second") + 3]

        result = detect_truncation(truncated, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1
        assert result.recovered_data.missions[0].title_plain == "First"
        assert (
            result.recovered_data.missions[0].description
            == 'She said "hello" then {fake json} appeared'
        )


class TestExtractJson:
    """Test _extract_json internal function for extracting JSON from LLM text.

    Contract behaviors to verify:
    - Pure JSON object returns unchanged
    - JSON in ```json fence extracts content
    - JSON in ``` fence (no lang) extracts content
    - Unclosed fence (truncated) extracts content
    - JSON embedded in text extracts by brace matching
    - Truncated JSON attempts recovery
    - No JSON at all raises ValueError

    Negative test ratio: 2 error/edge cases for 5 happy paths (exceeds 1:2 minimum)
    """

    def test_pure_json_object_returns_unchanged(self):
        """Pure JSON object input returns the exact same string."""
        pure_json = '{"name": "Alice", "count": 42}'

        result = _extract_json(pure_json)

        assert result == '{"name": "Alice", "count": 42}'

    def test_json_in_backtick_json_fence_extracts_content(self):
        """JSON wrapped in markdown code fence with json lang is extracted."""
        inner_json = '{"status": "ok", "value": 123}'
        fenced = f"```json\n{inner_json}\n```"

        result = _extract_json(fenced)

        assert result == '{"status": "ok", "value": 123}'

    def test_json_in_backtick_fence_no_lang_extracts_content(self):
        """JSON wrapped in markdown code fence without lang is extracted."""
        inner_json = '{"status": "ok", "value": 123}'
        fenced = f"```\n{inner_json}\n```"

        result = _extract_json(fenced)

        assert result == '{"status": "ok", "value": 123}'

    def test_unclosed_fence_truncated_raises_valueerror_when_recovery_fails(self):
        """Unclosed fence with truncated JSON raises ValueError when recovery fails."""
        truncated = '```json\n{"partial": true'

        with pytest.raises(ValueError) as exc_info:
            _extract_json(truncated)

        assert "Could not extract JSON from LLM response" in str(exc_info.value)

    def test_json_embedded_in_text_extracts_by_brace_matching(self):
        """JSON embedded within surrounding text is extracted via brace matching."""
        text_with_json = (
            'Here is the response: {"key": "value", "num": 99} and that is all.'
        )

        result = _extract_json(text_with_json)

        assert result == '{"key": "value", "num": 99}'

    def test_truncated_json_mid_value_raises_valueerror_when_recovery_fails(self):
        """Truncated JSON mid-value raises ValueError when recovery fails."""
        truncated = '{"name": "Alice", "incomplete":'

        with pytest.raises(ValueError) as exc_info:
            _extract_json(truncated)

        assert "Could not extract JSON from LLM response" in str(exc_info.value)

    def test_no_json_at_all_raises_valueerror(self):
        """Plain text with no JSON object raises ValueError with first 500
        chars in message."""
        plain_text = "This is just plain text with no JSON structure at all."

        with pytest.raises(ValueError) as exc_info:
            _extract_json(plain_text)

        assert "Could not extract JSON from LLM response" in str(exc_info.value)
        assert "First 500 chars:" in str(exc_info.value)

    def test_json_array_also_extracted(self):
        """JSON array starting at text beginning is extracted."""
        array_json = '[{"id": 1}, {"id": 2}]'

        result = _extract_json(array_json)

        assert result == '[{"id": 1}, {"id": 2}]'


class _SimpleTestSchema(BaseModel):
    """Minimal schema for testing create_structured_chain without
    complex dependencies."""

    name: str
    value: int


class TestCreateStructuredChain:
    """Test create_structured_chain function for building provider-agnostic LLM chains.

    Contract behaviors to verify:
    - Tool-calling models (including GLM via ChatOpenAI) use with_structured_output
    - Unknown model classes are assumed to support tool calling
    - Chain invoke returns schema instance for tool-calling model
    - JSON parse failure raises ValueError with specific message format
    - Schema validation failure raises ValueError with specific message format

    Note: The non-tool-calling model invoke path is tested via TestJsonOutputParser
    since LangChain's RunnableSequence is difficult to mock. The parser is the
    actual component that does the JSON extraction and validation.

    Negative test ratio: 2 error tests for 3 happy path tests = exceeds 1:2 minimum
    """

    def test_tool_calling_model_uses_with_structured_output(self):
        """Tool-calling model path calls with_structured_output on the model."""
        mock_model = MagicMock()
        mock_model.__class__.__name__ = "ChatAnthropic"
        mock_structured = MagicMock()
        mock_model.with_structured_output.return_value = mock_structured

        mock_prompt = MagicMock()
        mock_chain = MagicMock()
        mock_prompt.__or__ = MagicMock(return_value=mock_chain)

        create_structured_chain(mock_model, mock_prompt, _SimpleTestSchema)

        mock_model.with_structured_output.assert_called_once_with(_SimpleTestSchema)
        mock_prompt.__or__.assert_called_once_with(mock_structured)

    def test_glm_uses_tool_calling_via_openai(self):
        """GLM now uses ChatOpenAI via init_chat_model, which supports tool calling."""
        expected = _SimpleTestSchema(name="test", value=42)

        mock_model = MagicMock()
        mock_model.__class__.__name__ = "ChatOpenAI"
        mock_structured = MagicMock()
        mock_structured.invoke.return_value = expected
        mock_model.with_structured_output.return_value = mock_structured

        mock_prompt = MagicMock()
        mock_chain = MagicMock()
        mock_chain.invoke.return_value = expected
        mock_prompt.__or__ = MagicMock(return_value=mock_chain)

        chain = create_structured_chain(mock_model, mock_prompt, _SimpleTestSchema)
        result = chain.invoke({"input": "test"})

        mock_model.with_structured_output.assert_called_once_with(_SimpleTestSchema)
        assert isinstance(result, _SimpleTestSchema)
        assert result.name == "test"
        assert result.value == 42

    def test_chain_invoke_returns_schema_instance_for_tool_calling_model(self):
        """Chain invoke on tool-calling model returns validated schema instance."""
        expected = _SimpleTestSchema(name="test", value=42)

        mock_model = MagicMock()
        mock_model.__class__.__name__ = "ChatAnthropic"
        mock_structured = MagicMock()
        mock_structured.invoke.return_value = expected
        mock_model.with_structured_output.return_value = mock_structured

        mock_prompt = MagicMock()
        mock_chain = MagicMock()
        mock_chain.invoke.return_value = expected
        mock_prompt.__or__ = MagicMock(return_value=mock_chain)

        chain = create_structured_chain(mock_model, mock_prompt, _SimpleTestSchema)
        result = chain.invoke({"input": "test"})

        assert isinstance(result, _SimpleTestSchema)
        assert result.name == "test"
        assert result.value == 42

    def test_unknown_model_assumes_tool_calling_works(self):
        """Unknown model class is assumed to support tool calling."""
        mock_model = MagicMock()
        mock_model.__class__.__name__ = "SomeNewModel"
        mock_structured = MagicMock()
        mock_model.with_structured_output.return_value = mock_structured

        mock_prompt = MagicMock()
        mock_chain = MagicMock()
        mock_prompt.__or__ = MagicMock(return_value=mock_chain)

        create_structured_chain(mock_model, mock_prompt, _SimpleTestSchema)

        mock_model.with_structured_output.assert_called_once_with(_SimpleTestSchema)

    def test_json_parse_failure_raises_valueerror(self):
        """JSON parse failure raises ValueError with first 500 chars in message.

        This test verifies the error condition by testing the _JsonOutputParser
        directly, since that is the component responsible for JSON extraction.
        """
        from unfamiliar.llm.structured import _JsonOutputParser

        parser = _JsonOutputParser(_SimpleTestSchema)

        mock_message = MagicMock()
        mock_message.content = "This is not valid JSON at all, just plain text."

        with pytest.raises(ValueError) as exc_info:
            parser.invoke(mock_message)

        error_message = str(exc_info.value)
        assert "Could not extract JSON from LLM response" in error_message
        assert "First 500 chars:" in error_message

    def test_schema_validation_failure_raises_valueerror(self):
        """Schema validation failure raises ValueError with schema name
        and validation errors.

        This test verifies the error condition by testing the _JsonOutputParser
        directly, since that is the component responsible for schema validation.
        """
        from unfamiliar.llm.structured import _JsonOutputParser

        parser = _JsonOutputParser(_SimpleTestSchema)

        mock_message = MagicMock()
        mock_message.content = '{"name": 123, "value": "not_an_int"}'

        with pytest.raises(ValueError) as exc_info:
            parser.invoke(mock_message)

        error_message = str(exc_info.value)
        assert "LLM response did not match expected schema" in error_message
        assert "_SimpleTestSchema" in error_message


class TestTruncatedChain:
    """Test handling of truncated LLM responses with chains field."""

    def test_valid_json_with_empty_chains_recovers_missions(self):
        """Valid JSON with empty chains array recovers all missions."""
        mission1 = _make_minimal_mission_dict("Mission One")
        mission2 = _make_minimal_mission_dict("Mission Two")
        json_text = (
            '{"missions": ['
            + json.dumps(mission1)
            + ", "
            + json.dumps(mission2)
            + '], "chains": []}'
        )

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 2
        assert result.recovered_data.missions[0].title_plain == "Mission One"
        assert result.recovered_data.missions[1].title_plain == "Mission Two"

    def test_valid_json_with_single_mission_and_empty_chains(self):
        """Valid JSON with single mission and empty chains recovers mission."""
        mission1 = _make_minimal_mission_dict("Solo Mission")
        json_text = '{"missions": [' + json.dumps(mission1) + '], "chains": []}'

        result = detect_truncation(json_text, CategoryMissionsResponse)

        assert result.recovered_data is not None
        assert len(result.recovered_data.missions) == 1

    def test_truncated_json_preserves_complete_chains(self):
        """Truncated JSON with complete chains recovers those chains."""
        from unfamiliar.llm.structured import _recover_truncated_json

        # Create 3 complete missions
        m1 = _make_minimal_mission_dict("Mission One")
        m2 = _make_minimal_mission_dict("Mission Two")
        m3 = _make_minimal_mission_dict("Mission Three")

        # Create 2 complete chains
        c1 = {
            "name": "Chain One",
            "name_plain": "Chain One",
            "description": "First chain",
            "mission_titles": ["Mission One", "Mission Two"],
        }
        c2 = {
            "name": "Chain Two",
            "name_plain": "Chain Two",
            "description": "Second chain",
            "mission_titles": ["Mission Two", "Mission Three"],
        }

        # Build truncated JSON: 3 complete missions, 2 complete chains, partial 3rd
        truncated = (
            '{"missions": ['
            + json.dumps(m1)
            + ", "
            + json.dumps(m2)
            + ", "
            + json.dumps(m3)
            + '], "chains": ['
            + json.dumps(c1)
            + ", "
            + json.dumps(c2)
            + ', {"name": "Partial'  # Truncated mid-object
        )

        recovered = _recover_truncated_json(truncated, 0)
        assert recovered is not None

        result = json.loads(recovered)
        assert len(result["missions"]) == 3
        assert len(result["chains"]) == 2
        assert result["chains"][0]["name_plain"] == "Chain One"
        assert result["chains"][1]["name_plain"] == "Chain Two"

    def test_truncated_json_no_chains_section_recovers_missions(self):
        """Truncated JSON without chains section recovers missions with empty chains."""
        from unfamiliar.llm.structured import _recover_truncated_json

        m1 = _make_minimal_mission_dict("Mission One")
        m2 = _make_minimal_mission_dict("Mission Two")

        # Truncated before chains section
        truncated = '{"missions": [' + json.dumps(m1) + ", " + json.dumps(m2) + "]"

        recovered = _recover_truncated_json(truncated, 0)
        assert recovered is not None

        result = json.loads(recovered)
        assert len(result["missions"]) == 2
        assert result["chains"] == []

    def test_truncated_json_partial_chains_recovers_missions_only(self):
        """Truncated JSON with started chains but no complete chains
        recovers missions.
        """
        from unfamiliar.llm.structured import _recover_truncated_json

        m1 = _make_minimal_mission_dict("Mission One")

        # Chains section started but no complete objects
        truncated = (
            '{"missions": [' + json.dumps(m1) + '], "chains": [{"name": "Partial'
        )

        recovered = _recover_truncated_json(truncated, 0)
        assert recovered is not None

        result = json.loads(recovered)
        assert len(result["missions"]) == 1
        assert result["chains"] == []


class TestJsonOutputParser:
    """Test _JsonOutputParser for JSON extraction and schema validation.

    These tests verify the non-tool-calling model fallback path by testing
    the parser component directly.

    Negative test ratio: 2 error tests for 2 happy path tests = 1:1 (exceeds minimum)
    """

    def test_parser_returns_schema_instance_with_valid_json(self):
        """Parser returns validated schema instance for valid JSON input."""
        from unfamiliar.llm.structured import _JsonOutputParser

        parser = _JsonOutputParser(_SimpleTestSchema)

        mock_message = MagicMock()
        mock_message.content = '{"name": "test_name", "value": 42}'

        result = parser.invoke(mock_message)

        assert isinstance(result, _SimpleTestSchema)
        assert result.name == "test_name"
        assert result.value == 42

    def test_parser_handles_json_in_markdown_fence(self):
        """Parser extracts and validates JSON from markdown code fence."""
        from unfamiliar.llm.structured import _JsonOutputParser

        parser = _JsonOutputParser(_SimpleTestSchema)

        mock_message = MagicMock()
        mock_message.content = '```json\n{"name": "fenced", "value": 99}\n```'

        result = parser.invoke(mock_message)

        assert isinstance(result, _SimpleTestSchema)
        assert result.name == "fenced"
        assert result.value == 99

    def test_parser_raises_valueerror_for_invalid_json(self):
        """Parser raises ValueError when JSON cannot be extracted."""
        from unfamiliar.llm.structured import _JsonOutputParser

        parser = _JsonOutputParser(_SimpleTestSchema)

        mock_message = MagicMock()
        mock_message.content = "No JSON here at all, just plain text."

        with pytest.raises(ValueError) as exc_info:
            parser.invoke(mock_message)

        assert "Could not extract JSON from LLM response" in str(exc_info.value)
        assert "First 500 chars:" in str(exc_info.value)

    def test_parser_raises_valueerror_for_schema_validation_failure(self):
        """Parser raises ValueError when JSON does not match schema."""
        from unfamiliar.llm.structured import _JsonOutputParser

        parser = _JsonOutputParser(_SimpleTestSchema)

        mock_message = MagicMock()
        mock_message.content = '{"name": 123, "value": "wrong_type"}'

        with pytest.raises(ValueError) as exc_info:
            parser.invoke(mock_message)

        error_message = str(exc_info.value)
        assert "LLM response did not match expected schema" in error_message
        assert "_SimpleTestSchema" in error_message


class TestSchemaExampleGeneration:
    """Test _example_from_schema for generating placeholder JSON from Pydantic schemas.

    These tests verify that the schema example generation produces correct placeholder
    structures for LLM prompts.
    """

    def test_simple_object_schema_produces_placeholders(self):
        """Simple object schema produces dict with placeholder values."""
        from unfamiliar.llm.structured import _example_from_schema

        schema = {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "User name"},
                "age": {"type": "integer"},
            },
        }

        result = _example_from_schema(schema, {})

        assert isinstance(result, dict)
        assert result["name"] == "<User name>"
        assert result["age"] == 0

    def test_array_schema_produces_list_with_single_item(self):
        """Array schema produces list with single placeholder item."""
        from unfamiliar.llm.structured import _example_from_schema

        schema = {
            "type": "array",
            "items": {"type": "string"},
        }

        result = _example_from_schema(schema, {})

        assert isinstance(result, list)
        assert len(result) == 1
        assert result[0] == "<your value here>"

    def test_nested_object_schema_resolves_correctly(self):
        """Nested object schema with $ref resolves from definitions."""
        from unfamiliar.llm.structured import _example_from_schema

        schema = {"$ref": "#/$defs/NestedType"}
        defs = {
            "NestedType": {
                "type": "object",
                "properties": {
                    "id": {"type": "integer"},
                    "label": {"type": "string"},
                },
            }
        }

        result = _example_from_schema(schema, defs)

        assert isinstance(result, dict)
        assert result["id"] == 0
        assert result["label"] == "<your value here>"

    def test_optional_field_via_anyof_resolves_non_null_variant(self):
        """Optional field via anyOf resolves to the non-null variant."""
        from unfamiliar.llm.structured import _example_from_schema

        schema = {
            "anyOf": [
                {"type": "null"},
                {"type": "string"},
            ]
        }

        result = _example_from_schema(schema, {})

        assert result == "<your value here>"

    def test_string_type_returns_placeholder(self):
        """String type returns generic placeholder."""
        from unfamiliar.llm.structured import _example_from_schema

        schema = {"type": "string"}
        result = _example_from_schema(schema, {})

        assert result == "<your value here>"

    def test_integer_type_returns_zero(self):
        """Integer type returns 0."""
        from unfamiliar.llm.structured import _example_from_schema

        schema = {"type": "integer"}
        result = _example_from_schema(schema, {})

        assert result == 0

    def test_number_type_returns_zero_point_zero(self):
        """Number type returns 0.0."""
        from unfamiliar.llm.structured import _example_from_schema

        schema = {"type": "number"}
        result = _example_from_schema(schema, {})

        assert result == 0.0

    def test_boolean_type_returns_false(self):
        """Boolean type returns False."""
        from unfamiliar.llm.structured import _example_from_schema

        schema = {"type": "boolean"}
        result = _example_from_schema(schema, {})

        assert result is False

    def test_unknown_type_returns_ellipsis(self):
        """Unknown or missing type returns '...' placeholder."""
        from unfamiliar.llm.structured import _example_from_schema

        schema = {}  # No type specified
        result = _example_from_schema(schema, {})

        assert result == "..."

    def test_ref_not_in_defs_returns_ellipsis(self):
        """$ref that doesn't exist in defs returns '...' placeholder."""
        from unfamiliar.llm.structured import _example_from_schema

        schema = {"$ref": "#/$defs/MissingType"}
        result = _example_from_schema(schema, {})

        assert result == "..."


class TestSchemaExampleFromProperty:
    """Test _example_from_property for generating examples from property schemas.

    These tests verify property-specific example generation with hints.
    """

    def test_string_property_with_description_uses_hint(self):
        """String property uses description as hint in placeholder."""
        from unfamiliar.llm.structured import _example_from_property

        prop_schema = {"type": "string", "description": "The user's email address"}
        result = _example_from_property(
            "email", prop_schema, {}, "The user's email address"
        )

        assert result == "<The user's email address>"

    def test_string_property_without_description_uses_generic_placeholder(self):
        """String property without description uses generic placeholder."""
        from unfamiliar.llm.structured import _example_from_property

        prop_schema = {"type": "string"}
        result = _example_from_property("name", prop_schema, {}, "")

        assert result == "<your value here>"

    def test_array_property_returns_list_with_item_example(self):
        """Array property returns list with example of items schema."""
        from unfamiliar.llm.structured import _example_from_property

        prop_schema = {
            "type": "array",
            "items": {"type": "integer"},
        }
        result = _example_from_property("counts", prop_schema, {}, "")

        assert isinstance(result, list)
        assert result == [0]

    def test_object_property_returns_nested_example(self):
        """Object property returns nested object example."""
        from unfamiliar.llm.structured import _example_from_property

        prop_schema = {
            "type": "object",
            "properties": {
                "x": {"type": "integer"},
                "y": {"type": "integer"},
            },
        }
        result = _example_from_property("coords", prop_schema, {}, "")

        assert isinstance(result, dict)
        assert result["x"] == 0
        assert result["y"] == 0

    def test_property_with_ref_resolves_from_defs(self):
        """Property with $ref resolves the referenced type from defs."""
        from unfamiliar.llm.structured import _example_from_property

        prop_schema = {"$ref": "#/$defs/CustomType"}
        defs = {
            "CustomType": {
                "type": "object",
                "properties": {"value": {"type": "string"}},
            }
        }
        result = _example_from_property("custom", prop_schema, defs, "")

        assert isinstance(result, dict)
        assert result["value"] == "<your value here>"

    def test_long_description_wrapped_in_angle_brackets(self):
        """Description is wrapped in angle brackets (no truncation in current impl)."""
        from unfamiliar.llm.structured import _example_from_property

        long_desc = "This is a very long description that exceeds sixty characters"
        prop_schema = {"type": "string", "description": long_desc}
        result = _example_from_property("field", prop_schema, {}, long_desc)

        # Current implementation wraps hint in angle brackets without truncation
        assert result == f"<{long_desc}>"


class TestBuildExampleStructure:
    """Test _build_example_structure for full Pydantic model example generation."""

    def test_builds_example_from_pydantic_model(self):
        """_build_example_structure generates example from real Pydantic model."""
        from unfamiliar.llm.structured import _build_example_structure

        class NestedModel(BaseModel):
            id: int
            label: str

        class ContainerModel(BaseModel):
            name: str
            count: int
            items: list[str]
            nested: NestedModel

        result = _build_example_structure(ContainerModel)

        assert isinstance(result, dict)
        assert "name" in result
        assert "count" in result
        assert "items" in result
        assert "nested" in result
        assert result["count"] == 0
        assert isinstance(result["items"], list)
        assert isinstance(result["nested"], dict)


class TestCodeFenceEdgeCases:
    """Test _extract_from_code_fence for edge cases.

    Negative test ratio: 3 error/edge cases for 2 happy paths (exceeds 1:2 minimum)
    """

    def test_multiple_code_fences_extracts_first(self):
        """Multiple code fences extracts content from the first complete fence."""
        from unfamiliar.llm.structured import _extract_from_code_fence

        text = '```json\n{"first": 1}\n```\n```json\n{"second": 2}\n```'
        result = _extract_from_code_fence(text)

        assert result == '{"first": 1}'

    def test_empty_code_fence_returns_empty_string(self):
        """Empty code fence (``` with nothing inside) returns empty string."""
        from unfamiliar.llm.structured import _extract_from_code_fence

        text = "```\n```"
        result = _extract_from_code_fence(text)

        assert result == ""

    def test_no_code_fence_returns_none(self):
        """Text without code fence returns None."""
        from unfamiliar.llm.structured import _extract_from_code_fence

        text = "Just plain text, no fences here."
        result = _extract_from_code_fence(text)

        assert result is None

    def test_unclosed_code_fence_returns_content(self):
        """Unclosed code fence returns content (may be truncated response)."""
        from unfamiliar.llm.structured import _extract_from_code_fence

        text = '```json\n{"incomplete": true'
        result = _extract_from_code_fence(text)

        assert result == '{"incomplete": true'
