"""FINDING 10 — OpenAI response_format schema sanitizer.

scene_detail GPT fallback 경로에서 OpenAI strict structured output 이
`detail_schema` 의 `t2i_variations[].reference_phrase_kinds.uniqueItems` 를
`Invalid schema for response_format ... 'uniqueItems' is not permitted` 으로
거부 (pre-existing latent provider-boundary bug, Area #5 schema_version 11 era).

Fix (provider-boundary, B): OpenAI 계열 model 에 전달하는
`response_format.json_schema.schema` 에서만 `uniqueItems` 를 deep-copy strip.
- caller 의 원본 `response_schema` 객체는 mutate 하지 않는다.
- local jsonschema 검증(`_validate_local_schema`)은 원본 schema(uniqueItems
  포함)로 계속 수행 → enforcement 약화 없음.
- Gemini/비-OpenAI path 는 schema 무변형.
"""
from __future__ import annotations

import json
from typing import Any, Dict
from unittest.mock import MagicMock, patch

import pytest


# ---------------------------------------------------------------------------
# 헬퍼
# ---------------------------------------------------------------------------


def _make_response(content: str) -> MagicMock:
    msg = MagicMock()
    msg.content = content
    choice = MagicMock()
    choice.message = msg
    resp = MagicMock()
    resp.choices = [choice]
    return resp


def _ok_json(payload: Dict[str, Any]) -> MagicMock:
    return _make_response(json.dumps(payload))


def _has_unique_items(node: Any) -> bool:
    """node 트리 어디든 `uniqueItems` 키가 있으면 True."""
    if isinstance(node, dict):
        if "uniqueItems" in node:
            return True
        return any(_has_unique_items(v) for v in node.values())
    if isinstance(node, list):
        return any(_has_unique_items(v) for v in node)
    return False


# scene_detail detail_schema 의 reference_phrase_kinds 와 동일한 중첩 구조 —
# properties.t2i_variations.items.properties.reference_phrase_kinds.uniqueItems
def _scene_detail_shaped_schema() -> Dict[str, Any]:
    return {
        "type": "object",
        "properties": {
            "t2i_variations": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "reference_phrase_kinds": {
                            "type": "array",
                            "items": {
                                "type": "string",
                                "enum": ["character", "background", "prop"],
                            },
                            "uniqueItems": True,
                        },
                    },
                    "required": ["reference_phrase_kinds"],
                    "additionalProperties": False,
                },
            },
        },
        "required": ["t2i_variations"],
        "additionalProperties": False,
    }


def _response_format_kwargs(schema: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "response_format": {
            "type": "json_schema",
            "json_schema": {"name": "scene_detail", "schema": schema, "strict": True},
        },
    }


_VALID_PAYLOAD = {"t2i_variations": [{"reference_phrase_kinds": ["character", "background"]}]}
_DUP_PAYLOAD = {"t2i_variations": [{"reference_phrase_kinds": ["character", "character"]}]}


@pytest.fixture
def mock_router():
    # [2026-08-01 A5 후속] 봉합 지점이 `_get_router` 에서 `_get_router_binding`
    # 으로 옮겼다. Router 는 이제 **그것을 지은 슬롯**과 짝으로만 다뤄진다 —
    # 실패한 슬롯을 전역에서 다시 읽으면 stale Router 의 실패가 남의 슬롯
    # 실패로 보고되기 때문이다.
    from app.modules.llm.llm_client import _RouterBinding

    with patch("app.modules.llm.llm_client._get_router_binding") as mock_get:
        router = MagicMock()
        mock_get.return_value = _RouterBinding(slot="primary", router=router)
        yield router


@pytest.fixture(autouse=True)
def _reset_env(monkeypatch):
    monkeypatch.delenv("LLM_LOCAL_SCHEMA_VALIDATE", raising=False)


# ---------------------------------------------------------------------------
# 1. _sanitize_response_format_for_model 단위 테스트
# ---------------------------------------------------------------------------


class TestSanitizeResponseFormatForModel:
    def test_openai_model_strips_nested_uniqueItems(self):
        """OpenAI 계열 model → response_format schema 의 중첩 uniqueItems 제거."""
        from app.modules.llm.llm_client import _sanitize_response_format_for_model

        kwargs = _response_format_kwargs(_scene_detail_shaped_schema())
        _sanitize_response_format_for_model("gpt", kwargs)
        sent = kwargs["response_format"]["json_schema"]["schema"]
        assert _has_unique_items(sent) is False

    def test_does_not_mutate_caller_original_schema(self):
        """원본 response_schema 객체는 mutate 되지 않는다 (deep copy)."""
        from app.modules.llm.llm_client import _sanitize_response_format_for_model

        original = _scene_detail_shaped_schema()
        kwargs = _response_format_kwargs(original)
        _sanitize_response_format_for_model("gpt", kwargs)
        # 원본은 그대로 uniqueItems 보유.
        assert _has_unique_items(original) is True
        # kwargs 의 schema 는 새 객체로 교체됨.
        assert kwargs["response_format"]["json_schema"]["schema"] is not original

    def test_strip_preserves_non_targeted_content(self):
        """uniqueItems 외 다른 schema 내용은 보존."""
        from app.modules.llm.llm_client import _sanitize_response_format_for_model

        kwargs = _response_format_kwargs(_scene_detail_shaped_schema())
        _sanitize_response_format_for_model("gpt", kwargs)
        rpk = (
            kwargs["response_format"]["json_schema"]["schema"]
            ["properties"]["t2i_variations"]["items"]
            ["properties"]["reference_phrase_kinds"]
        )
        assert rpk["type"] == "array"
        assert rpk["items"]["enum"] == ["character", "background", "prop"]

    def test_gemini_model_is_noop(self):
        """비-OpenAI(Gemini) model → schema 무변형, uniqueItems 보존."""
        from app.modules.llm.llm_client import _sanitize_response_format_for_model

        kwargs = _response_format_kwargs(_scene_detail_shaped_schema())
        _sanitize_response_format_for_model("gemini-pro", kwargs)
        assert _has_unique_items(kwargs["response_format"]["json_schema"]["schema"]) is True


# ---------------------------------------------------------------------------
# 2. call_structured 통합 — OpenAI vs Gemini path
# ---------------------------------------------------------------------------


class TestCallStructuredOpenAISanitize:
    def test_openai_path_strips_uniqueItems_sent_to_router(self, mock_router):
        """call_structured 가 OpenAI model 호출 시 router 로 전달되는
        response_format schema 에 uniqueItems 가 없어야 한다."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.return_value = _ok_json(_VALID_PAYLOAD)
        call_structured(
            step="test",
            system_prompt="sys",
            user_prompt="user",
            response_schema=_scene_detail_shaped_schema(),
            project_config={"test": {"model": "gpt"}},
        )
        sent = mock_router.completion.call_args.kwargs["response_format"]["json_schema"]["schema"]
        assert _has_unique_items(sent) is False

    def test_openai_path_does_not_mutate_caller_schema(self, mock_router):
        """OpenAI 경로라도 caller 가 넘긴 response_schema 원본은 uniqueItems 보존."""
        from app.modules.llm.llm_client import call_structured

        original = _scene_detail_shaped_schema()
        mock_router.completion.return_value = _ok_json(_VALID_PAYLOAD)
        call_structured(
            step="test",
            system_prompt="sys",
            user_prompt="user",
            response_schema=original,
            project_config={"test": {"model": "gpt"}},
        )
        assert _has_unique_items(original) is True

    def test_openai_path_local_validation_still_rejects_uniqueItems_violation(
        self, mock_router,
    ):
        """OpenAI 에 보내는 schema 는 uniqueItems strip 되지만, local 검증은
        원본 schema(uniqueItems 포함)로 수행 → 중복 항목 payload 는 여전히 reject."""
        from app.modules.llm.llm_client import call_structured
        from app.modules.llm.safety import SchemaValidationError

        mock_router.completion.return_value = _ok_json(_DUP_PAYLOAD)
        with pytest.raises(SchemaValidationError):
            call_structured(
                step="test",
                system_prompt="sys",
                user_prompt="user",
                response_schema=_scene_detail_shaped_schema(),
                project_config={"test": {"model": "gpt"}},
                enable_fallback=False,
            )

    def test_gemini_path_keeps_uniqueItems(self, mock_router):
        """비-OpenAI(Gemini) model → router 로 전달되는 schema 에 uniqueItems 보존."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.return_value = _ok_json(_VALID_PAYLOAD)
        call_structured(
            step="test",
            system_prompt="sys",
            user_prompt="user",
            response_schema=_scene_detail_shaped_schema(),
            project_config=None,  # _resolve_model → gemini-pro default
        )
        sent = mock_router.completion.call_args.kwargs["response_format"]["json_schema"]["schema"]
        assert _has_unique_items(sent) is True


# ---------------------------------------------------------------------------
# 3. call_multiturn 통합 — 동일 provider-boundary 보호
# ---------------------------------------------------------------------------


class TestCallMultiturnOpenAISanitize:
    def test_openai_path_strips_uniqueItems_sent_to_router(self, mock_router):
        """call_multiturn structured 모드도 OpenAI model 호출 시 uniqueItems strip."""
        from app.modules.llm.llm_client import call_multiturn

        mock_router.completion.return_value = _ok_json(_VALID_PAYLOAD)
        call_multiturn(
            step="test",
            messages=[{"role": "system", "content": "s"}, {"role": "user", "content": "u"}],
            response_schema=_scene_detail_shaped_schema(),
            project_config={"test": {"model": "gpt"}},
        )
        sent = mock_router.completion.call_args.kwargs["response_format"]["json_schema"]["schema"]
        assert _has_unique_items(sent) is False
