"""Qwen VLM 판정 클라이언트 — DashScope OpenAI 호환 직접 호출 (2026-08-10).

`backend/qwen_vlm_pilot.py` 에서 검증된 부품의 프로덕션 이관이다. Router
(`call_structured`)를 타지 않는 이유: 이 저장소의 구조화 호출은
`response_format={"type":"json_schema","strict":true}` 를 보내는데 DashScope 는
그것을 지원하지 않는다 — `json_object` 만 되고, 메시지 어딘가에 "json" 이라는
낱말이 없으면 400, thinking 모드는 구조화 출력과 충돌한다. 그래서 스키마
전문을 시스템에 동봉하고 여기서 `jsonschema` 로 검증한 뒤, 실패하면 오류를
되먹여 한 번 다시 묻는다.

★litellm 우회 직접 호출이므로 **모든 API 왕복을 `record_provider_call` 로
남긴다** (llm_call_log+Opik — 2026-08-07 전수 기록 지시). 우회 호출 AST
스캐너(`test_all_provider_calls_are_traced`)가 이 배선을 검사한다.
"""
from __future__ import annotations

import json
import logging
import re
import time
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

_FENCE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.S)

# DashScope 는 메시지에 "json" 이라는 낱말이 없으면 400 을 낸다. 판정 계약은
# 호출자가 주고, 출력 형식 문장만 여기서 덧붙인다.
JSON_CLAUSE = """

OUTPUT FORMAT — return one single json object and nothing else. No prose
before or after it, no markdown code fence. It must match this json schema
exactly, including every required key:

{schema}"""


class QwenNotConfigured(RuntimeError):
    pass


def qwen_configured() -> bool:
    from app.core.config import settings

    return bool((settings.dashscope_api_key or "").strip())


def _client():
    from app.core.config import settings

    key = (settings.dashscope_api_key or "").strip()
    if not key:
        raise QwenNotConfigured(
            "DASHSCOPE_API_KEY 가 비어 있다 — backend/.env 에 키를 넣어라."
        )
    from openai import OpenAI

    return OpenAI(
        api_key=key,
        base_url=(settings.dashscope_base_url or "").strip(),
        timeout=300.0,
    )


def qwen_model() -> str:
    from app.core.config import settings

    return (settings.qwen_vlm_model or "").strip()


def extract_json(text: str) -> Dict[str, Any]:
    """추론 텍스트가 앞뒤에 섞여 와도 본체 json 을 꺼낸다.

    thinking 모드가 content 에 추론을 흘리는 사례가 보고돼 있어 관용 파싱이
    필요하다. ①코드펜스 안 ②첫 `{` 부터 균형 잡힌 마지막 `}` 까지 순으로
    시도. (pilot 검증 부품 — 256샷 실측에서 전 샷 파싱 성공)
    """
    text = (text or "").strip()
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass
    m = _FENCE.search(text)
    if m:
        try:
            return json.loads(m.group(1))
        except json.JSONDecodeError:
            pass
    start = text.find("{")
    if start >= 0:
        depth, in_str, esc = 0, False, False
        for i, ch in enumerate(text[start:], start):
            if in_str:
                if esc:
                    esc = False
                elif ch == "\\":
                    esc = True
                elif ch == '"':
                    in_str = False
                continue
            if ch == '"':
                in_str = True
            elif ch == "{":
                depth += 1
            elif ch == "}":
                depth -= 1
                if depth == 0:
                    return json.loads(text[start:i + 1])
    raise ValueError(f"json 을 찾지 못했다: {text[:200]!r}")


def _prompt_text_of(parts: List[Dict[str, Any]]) -> str:
    """기록용 프롬프트 — 텍스트 파트만 잇는다 (base64 이미지는 DB 에 안 싣는다)."""
    return "\n".join(
        str(p.get("text") or "") for p in parts if p.get("type") == "text"
    )


def ask_qwen_structured(
    step_tag: str,
    system: str,
    parts: List[Dict[str, Any]],
    schema: Dict[str, Any],
    *,
    max_retry: int = 1,
    opik_metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """Qwen 에 멀티모달 판정을 묻고 스키마 검증을 통과한 payload 를 돌려준다.

    parts 는 `call_structured` 멀티모달 parts 와 같은 형식(`png_part`/
    `ref_parts` 산출 — OpenAI chat `image_url` data URI) — 실측 갤러리에서
    같은 parts 를 Gemini·Qwen 두 경로에 태워 호환 확인됨.

    스키마 검증 실패 = 오류를 되먹여 교정 재질의 `max_retry` 회. 네트워크
    재시도는 여기 없다 — 필요가 실측되면 그때 더한다(선제 확장 금지).
    """
    from jsonschema import ValidationError, validate as _js_validate

    from app.modules.llm.image_tracer import (
        ambient_call_meta, record_provider_call, resolve_step_name)

    client, model = _client(), qwen_model()
    sys_prompt = system + JSON_CLAUSE.format(
        schema=json.dumps(schema, ensure_ascii=False, indent=2))
    messages: List[Dict[str, Any]] = [
        {"role": "system", "content": sys_prompt},
        {"role": "user", "content": parts},
    ]
    meta = dict(ambient_call_meta())
    for k, v in (opik_metadata or {}).items():
        if v is not None and k not in meta:
            meta[k] = v
    step = resolve_step_name(step_tag, meta)
    log_prompt = _prompt_text_of(parts)

    def _record(status: str, dur_ms: int, *, output: Optional[str] = None,
                error: Optional[str] = None) -> None:
        record_provider_call(
            step=step, model=model, prompt=log_prompt, status=status,
            duration_ms=dur_ms, meta=meta, provider="dashscope",
            operation=step_tag, output_text=output, error=error)

    last_err = ""
    for attempt in range(max_retry + 1):
        resp = None
        # thinking 을 끈다 — 구조화 출력과 충돌한다. 이 파라미터를 모르는
        # 배포본이면 400 이 오므로 그때는 빼고 한 번 더 시도한다.
        for extra in ({"enable_thinking": False}, None):
            t0 = time.time()
            try:
                resp = client.chat.completions.create(
                    model=model,
                    messages=messages,
                    response_format={"type": "json_object"},
                    **({"extra_body": extra} if extra else {}),
                )
                break
            except Exception as exc:  # noqa: BLE001
                dur = int((time.time() - t0) * 1000)
                if extra is None or "400" not in repr(exc):
                    _record("error", dur, error=repr(exc)[:500])
                    raise
                _record("error", dur,
                        error="enable_thinking 미지원(400) — 빼고 재시도")
        if resp is None:  # 폴백 루프 계약상 도달 불가 — 방어
            raise RuntimeError("qwen 호출이 응답 없이 폴백 루프를 벗어났다")
        dur = int((time.time() - t0) * 1000)
        # ★유료 왕복이 성공한 뒤의 모든 실패도 그 왕복의 기록을 정확히
        #  1건 남긴다 (2026-08-12, openrouter 클라이언트 Codex BLOCK-1 과
        #  같은 부류의 선례 부채 이식 수리): choices 결손·content shape
        #  이상은 스키마 재질의 대상이 아니라 provider 이상 — error 기록
        #  후 그대로 전파. 기록 없는 유료 왕복은 resume 이중 지출의 창.
        try:
            content = resp.choices[0].message.content or ""
            if not isinstance(content, str):
                raise TypeError(
                    "message.content 가 str 이 아니다: "
                    f"{type(content).__name__}")
        except Exception as exc:  # noqa: BLE001
            _record("error", dur,
                    error=f"unexpected response shape: {exc!r}"[:400])
            raise
        try:
            payload = extract_json(content)
            _js_validate(payload, schema)
            _record("success", dur, output=content)
            return payload
        except (ValueError, ValidationError, json.JSONDecodeError) as exc:
            last_err = f"{type(exc).__name__}: {exc}"[:400]
            _record("error", dur, error=f"schema violation: {last_err}")
            if attempt >= max_retry:
                raise
            # 오류를 되먹여 다시 묻는다 — json_object 는 문법만 보장하고
            # 스키마 준수는 보장하지 않으므로 한 번의 교정 기회를 준다.
            messages += [
                {"role": "assistant", "content": content[:4000]},
                {"role": "user", "content": (
                    "That reply did not satisfy the json schema: "
                    f"{last_err}\nReturn the corrected json object only."
                )},
            ]
        except Exception as exc:  # noqa: BLE001
            # ★모델 출력 문제가 아닌 응답 후 실패 — 대표례 SchemaError
            #  (로컬 스키마 자체 불량; ValidationError 의 subclass 가
            #  아니다, Codex 재확인 BLOCK). 재질의 대상이 아니므로 그
            #  왕복의 error 기록 1건 후 그대로 전파한다 (기록 없는 유료
            #  왕복 금지 계약). _record 는 자체 실패를 삼키므로 위 성공
            #  기록이 이 갈래로 떨어질 일은 없다.
            _record("error", dur,
                    error=f"unexpected post-response failure: {exc!r}"[:400])
            raise
    raise RuntimeError("unreachable")
