"""qwen_vlm_client — 관용 파싱·교정 재질의·기록 의무·fail-closed.

대역은 **외부 API(openai SDK)와 기록 헬퍼**만이다 — extract_json·검증·
교정 루프·기록 호출 지점은 실물 코드가 돈다.
"""
from __future__ import annotations

from typing import Any, Dict, List

import pytest

import app.modules.llm.qwen_vlm_client as qc
from app.core.config import settings

SCHEMA = {
    "type": "object", "additionalProperties": False,
    "properties": {"winner": {"type": "string"}},
    "required": ["winner"],
}


# ── extract_json 관용 파싱 ────────────────────────────────────────────

def test_extract_json_plain():
    assert qc.extract_json('{"a": 1}') == {"a": 1}


def test_extract_json_fenced():
    assert qc.extract_json('```json\n{"a": 1}\n```') == {"a": 1}


def test_extract_json_with_reasoning_noise():
    text = 'thinking... {"a": {"b": "x}y"}} trailing'
    assert qc.extract_json(text) == {"a": {"b": "x}y"}}


def test_extract_json_none_raises():
    with pytest.raises(ValueError):
        qc.extract_json("no json here")


# ── ask_qwen_structured ───────────────────────────────────────────────

class _FakeResp:
    def __init__(self, content: str):
        self.choices = [type("C", (), {
            "message": type("M", (), {"content": content})()})()]
        self.usage = None


class _FakeClient:
    def __init__(self, contents: List[str]):
        self._contents = list(contents)
        self.calls: List[Dict[str, Any]] = []
        outer = self

        class _Completions:
            def create(self, **kw):
                outer.calls.append(kw)
                return _FakeResp(outer._contents.pop(0))

        self.chat = type("Chat", (), {"completions": _Completions()})()


def _wire(monkeypatch, contents: List[str]):
    fake = _FakeClient(contents)
    monkeypatch.setattr(settings, "dashscope_api_key", "k")
    monkeypatch.setattr(qc, "_client", lambda: fake)
    records: List[Dict[str, Any]] = []

    import app.modules.llm.image_tracer as tracer

    def fake_record(**kw):
        records.append(kw)
        return None

    monkeypatch.setattr(tracer, "record_provider_call", fake_record)
    return fake, records


class _NoChoicesResp:
    def __init__(self):
        self.choices = []
        self.usage = None


def test_unexpected_shape_records_then_raises(monkeypatch):
    """유료 왕복 후 응답 shape 이상(choices=[])도 그 왕복의 error 기록을
    정확히 1건 남긴 뒤 전파한다 — openrouter Codex BLOCK-1 과 같은 부류의
    선례 부채 이식 수리 (2026-08-12)."""
    fake, records = _wire(monkeypatch, [])
    fake._contents = []  # not used
    outer_calls = fake.calls

    class _Completions:
        def create(self, **kw):
            outer_calls.append(kw)
            return _NoChoicesResp()

    fake.chat = type("Chat", (), {"completions": _Completions()})()
    with pytest.raises(IndexError):
        qc.ask_qwen_structured("t", "SYS", [], SCHEMA)
    assert len(fake.calls) == 1 == len(records)
    assert records[0]["status"] == "error"
    assert "unexpected response shape" in records[0]["error"]


def test_invalid_local_schema_records_then_raises(monkeypatch):
    """SchemaError(로컬 스키마 불량)는 ValidationError subclass 가 아니라
    재질의 catch 를 지나쳐 기록 0건으로 탈출하던 창 — error 기록 1건 후
    전파 (openrouter Codex 재확인 BLOCK 과 동형)."""
    from jsonschema import SchemaError

    fake, records = _wire(monkeypatch, ["{}"])
    with pytest.raises(SchemaError):
        qc.ask_qwen_structured("t", "SYS", [], {"type": "bogus"})
    assert len(fake.calls) == 1 == len(records)
    assert records[0]["status"] == "error"
    assert "unexpected post-response failure" in records[0]["error"]


def test_ask_success_records_and_returns_payload(monkeypatch):
    fake, records = _wire(monkeypatch, ['{"winner": "A"}'])
    out = qc.ask_qwen_structured(
        "t_tag", "SYS", [{"type": "text", "text": "p"}], SCHEMA)
    assert out == {"winner": "A"}
    assert len(fake.calls) == 1
    # 스키마 전문이 시스템에 동봉된다(json_object 는 문법만 보장)
    sys_msg = fake.calls[0]["messages"][0]["content"]
    assert "OUTPUT FORMAT" in sys_msg and '"winner"' in sys_msg
    # 기록 의무 — API 왕복 1회 = record 1회(success)
    assert [r["status"] for r in records] == ["success"]
    assert records[0]["provider"] == "dashscope"
    # base64 이미지가 아니라 텍스트 파트만 기록 프롬프트에 실린다
    assert records[0]["prompt"] == "p"


def test_ask_schema_violation_refeeds_error_once(monkeypatch):
    fake, records = _wire(
        monkeypatch, ['{"loser": "A"}', '{"winner": "B"}'])
    out = qc.ask_qwen_structured(
        "t_tag", "SYS", [{"type": "text", "text": "p"}], SCHEMA,
        max_retry=1)
    assert out == {"winner": "B"}
    assert len(fake.calls) == 2
    # 교정 재질의에 위반 내용이 되먹여진다
    retry_msgs = fake.calls[1]["messages"]
    assert any("did not satisfy the json schema" in str(m.get("content"))
               for m in retry_msgs)
    assert [r["status"] for r in records] == ["error", "success"]


def test_ask_retry_exhausted_raises(monkeypatch):
    _, records = _wire(monkeypatch, ['{"x": 1}', '{"x": 2}'])
    with pytest.raises(Exception):
        qc.ask_qwen_structured(
            "t_tag", "SYS", [{"type": "text", "text": "p"}], SCHEMA,
            max_retry=1)
    assert [r["status"] for r in records] == ["error", "error"]


def test_client_without_key_fails_closed(monkeypatch):
    monkeypatch.setattr(settings, "dashscope_api_key", "")
    assert not qc.qwen_configured()
    with pytest.raises(qc.QwenNotConfigured):
        qc._client()
