"""Opik 기록 계층의 단일 창구 — uid·축 태그·trace scope.

litellm 의 opik 통합이 `metadata["opik"]` 에서 읽는 키는 넷뿐이다:
`project_name` · `current_span_data` · `tags` · `thread_id`.
`trace_name` 은 litellm 소스에 없다 — trace 이름은 항상
`response_obj["object"]`("chat.completion")로 못박혀 있다.

그래서 이름 있는 trace 는 **우리가 만들고**, litellm 호출에는
`current_span_data={"trace_id": <우리 것>}` 을 실어 **span 만** 붙게 한다.

설계: docs/superpowers/specs/2026-08-23-opik-trace-taxonomy-design.md
"""
from __future__ import annotations

import logging
import os
import threading
import time
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import Any, Dict, Iterator, List, Optional

logger = logging.getLogger(__name__)

#: 태그 축 — 이름 순서가 곧 표시 순서다.
_AXIS_ORDER = ("step", "op", "kind", "model", "provider", "status")

# 스텝 축 태그의 접두사 — **여기 하나만 참이다.**
# `build_axis_tags(step=X)` 가 이걸 붙이고, 태그에서 스텝 이름을 되찾는
# 쪽(`image_tracer.resolve_step_name`)이 이걸 벗긴다. 두 벌로 두면 한쪽만
# 고쳐져 `step:step:` 같은 것이 생긴다 — 실제로 생겼다(2026-08-28).
STEP_TAG_PREFIX = f"{_AXIS_ORDER[0]}:"


def new_trace_uid() -> str:
    """Opik trace/span id — **UUIDv7 이어야 한다**.

    2026-08-23 실측: uuid4 를 주면 서버가
    `400 "Trace id must be a version 7 UUID"` 로 거부한다.
    litellm 도 같은 방식(`litellm.integrations.opik.utils.create_uuid7`)을 쓴다.
    그쪽 함수를 빌리지 않는 이유: litellm 내부 경로라 판이 바뀌면 조용히
    사라진다 — 기록이 죽는 자리를 남의 사정에 걸지 않는다.
    """
    ns = time.time_ns()
    sixteen_secs = 16_000_000_000
    t1, rest1 = divmod(ns, sixteen_secs)
    t2, rest2 = divmod(rest1 << 16, sixteen_secs)
    t3, _ = divmod(rest2 << 12, sixteen_secs)
    t3 |= 7 << 12                      # version 7
    seq = int.from_bytes(os.urandom(2), "big") & 0x3FFF
    t4 = (2 << 14) | seq               # variant 0b10
    rand = os.urandom(6)
    return f"{t1:>08x}-{t2:>04x}-{t3:>04x}-{t4:>04x}-{rand.hex()}"


def build_axis_tags(
    *,
    step: Optional[str] = None,
    op: Optional[str] = None,
    kind: Optional[str] = None,
    model: Optional[str] = None,
    provider: Optional[str] = None,
    status: Optional[str] = None,
) -> List[str]:
    """축을 접두사로 못박은 태그 목록.

    지금은 스텝·모델·제공자·프로젝트·에피소드·상태가 **한 자루**에 섞여
    (실측 81종) 태그를 봐도 그게 무슨 축인지 모른다. 접두사를 붙이면 축이
    갈린다.

    ★프로젝트 이름·에피소드 제목은 여기에 **안 넣는다** — 한글이고
    카디널리티가 커진다. 그것은 metadata 로 간다.

    ★litellm 이 span 태그에 제공자 이름을 **맨 이름으로 덧붙인다**
    (`extract_tags` 의 `tags.append(custom_llm_provider)`). 그것은 못 막는다.
    trace 태그는 우리가 전부 만드므로 깨끗하다.
    """
    values = {"step": step, "op": op, "kind": kind,
              "model": model, "provider": provider, "status": status}
    out: List[str] = []
    for axis in _AXIS_ORDER:
        v = values.get(axis)
        if v is None:
            continue
        text = str(v).strip()
        if not text:
            continue
        tag = f"{axis}:{text}"
        if tag not in out:
            out.append(tag)
    return out


def is_axis_tag(tag: object) -> bool:
    """허용 축 접두사가 붙은 태그인가.

    ★`":" in tag` 로 보면 안 된다 — 동적 이름·URL·시각 표기가 축 태그로
    둔갑해 고카디널리티 맨 태그가 그대로 통과한다
    (`http://192.168.0.9:5173/x`, `붉은 벽:2층`). 2026-08-24 Codex 재리뷰.
    """
    text = str(tag)
    return any(text.startswith(f"{axis}:") for axis in _AXIS_ORDER)


def episode_thread_id(
    *, project_name: str, episode_title: str, episode_id: str
) -> str:
    """주행 묶음 키 — **에피소드 단위**로 고정한다.

    지금은 `build_opik_context` 가 부를 때마다 run_tag 를 새로 만들고
    (`analysis_dispatch_service.py:47`), 단일 스텝 API 가 그것을 스텝마다
    부른다(`api/v1/steps.py:253`). 215샷 주행을 30번 재개하면 thread 가
    31개로 갈린다.

    에피소드에서 바로 만들면 재개·재기동·단일 스텝 호출이 전부 같은 값이
    되고, 저장할 것도 없다. 한 dispatch 를 따로 보고 싶으면 metadata 의
    `run_tag` 로 가른다.
    """
    head = "_".join(x for x in (project_name or "", episode_title or "") if x)
    tail = (episode_id or "")[:8] or "unknown"
    return f"{head}_{tail}" if head else tail


@dataclass(frozen=True)
class TraceHandle:
    """지금 열려 있는 trace 의 손잡이. frozen — scope 안에서 안 바뀐다."""

    uid: str
    name: str
    thread_id: Optional[str] = None


_trace_ctx: ContextVar[Optional[TraceHandle]] = ContextVar(
    "opik_trace_ctx", default=None
)

_client = None
_client_lock = threading.Lock()


def _get_client():
    """Opik SDK 클라이언트 — 프로젝트 이름을 명시해서 만든다.

    ★인자 없이 만들면 기본 프로젝트로 쌓여 텍스트 호출과 갈린다
    (2026-08-07 에 실제로 그래서 「이미지 기록이 통째로 없다」고 잘못 판단했다).
    """
    global _client
    if _client is not None:
        return _client
    with _client_lock:
        if _client is None:
            import opik

            from app.core.config import settings

            if settings.opik_url_override:
                os.environ.setdefault(
                    "OPIK_URL_OVERRIDE", settings.opik_url_override)
                os.environ.setdefault(
                    "OPIK_WORKSPACE", settings.opik_workspace)
            _client = opik.Opik(project_name=settings.opik_project_name)
    return _client


def current_trace() -> Optional[TraceHandle]:
    """지금 열려 있는 trace (없으면 None)."""
    return _trace_ctx.get()


def bind_trace(handle: Optional[TraceHandle]) -> Token:
    """worker thread 명시 전파용 — 호출자가 reset_trace 로 되돌린다."""
    return _trace_ctx.set(handle)


def reset_trace(token: Token) -> None:
    _trace_ctx.reset(token)


def bind_current_trace(fn):
    """지금 trace 를 캡처해 worker thread 안에서 다시 세우는 wrapper.

    `ContextVar` 는 thread 를 넘지 않는다. 병렬 롤은
    `multiroll_select.py:1385` 에서 budget·generation_context 를 명시
    전파하는데, trace 핸들은 **세 번째 ContextVar** 라 같이 실어야 한다.
    안 실으면 worker 호출이 부모 없이 떨어져 **계층이 병렬 구간에서만
    조용히 무너진다.**

    ★worker 가 끝나면 반드시 되돌린다 — thread pool 은 thread 를 재사용해서,
    안 되돌리면 다음 작업이 남의 부모를 물려받는다.
    """
    import functools

    captured = current_trace()

    @functools.wraps(fn)
    def _wrapped(*args, **kwargs):
        token = _trace_ctx.set(captured)
        try:
            return fn(*args, **kwargs)
        finally:
            _trace_ctx.reset(token)

    return _wrapped


#: uid → SDK trace 객체. finish 할 때 되찾는다.
#: ★`open_trace` 가 성공·예외 양쪽에서 반드시 `finish_trace` 를 불러 pop 하므로
#: 누수되지 않는다. `bind_trace` 로만 세운 handle 은 여기 없다 — 그 경우
#: `update_trace_output` 은 조용히 넘어간다(부모를 만든 쪽이 닫는다).
_LIVE: Dict[str, Any] = {}


def finish_trace(
    handle: Optional[TraceHandle],
    *,
    output: Optional[Dict[str, Any]] = None,
    error: Optional[str] = None,
) -> None:
    """trace 를 닫는다. 실패는 삼킨다."""
    if handle is None:
        return
    try:
        live = _LIVE.pop(handle.uid, None)
        if live is None:
            return
        payload: Dict[str, Any] = {}
        if output is not None:
            payload["output"] = output
        if error:
            payload["error_info"] = {
                "exception_type": "PipelineError",
                "message": str(error)[:500],
                "traceback": "",
            }
        if payload:
            live.update(**payload)
        live.end()
    except Exception as exc:  # noqa: BLE001 — 기록은 본 작업을 안 막는다
        logger.debug("finish_trace 실패 (non-fatal): %s", exc)


def current_shot_uid() -> Optional[str]:
    """지금 열려 있는 trace 의 uid — 세 곳에 같이 적을 값.

    Opik trace id 를 그대로 쓴다. 별도 uid 를 하나 더 만들면 둘이 어긋날
    자리가 생긴다 — 같은 것을 두 이름으로 부르지 않는다.
    """
    from app.core.config import settings

    if not getattr(settings, "opik_trace_v2_enabled", False):
        return None
    handle = current_trace()
    return handle.uid if handle is not None else None


def build_influence_summary(record: Dict[str, Any]) -> Dict[str, Any]:
    """샷 trace 의 `output` 에 실을 **영향 요약**.

    ★프롬프트 전문·판정 전문은 **안 싣는다.** 그것은 span 에 이미 있다 —
    두 벌로 저장하면 어느 쪽이 진짜인지 갈린다. 여기에는 「무엇이 들어갔고
    무엇이 골랐나」만 남긴다.
    """
    refs = []
    for r in (record.get("refs") or []):
        if not isinstance(r, dict):
            continue
        refs.append({"label": r.get("label"),
                     "asset_id": r.get("asset_id"),
                     "role": r.get("role")})
    verdicts = []
    for v in (record.get("verdicts") or []):
        if isinstance(v, dict):
            verdicts.append({"label": v.get("label"), "score": v.get("score")})

    critique = record.get("critique") or {}
    issue_count = len(critique.get("issues") or []) if isinstance(
        critique, dict) else 0

    # ★수리 승패는 **canonical 판정 하나**만 쓴다 (2026-08-24 Codex BLOCK 2).
    #   「fix_rejudge 가 있으면 True」로 재구현하면 재판정에서 **수정본이 진**
    #   샷(winner=="A")까지 「수리 적용」이 되어, 자산 provenance 의 단일
    #   판정과 갈린다. 그 갈림을 없애려고 만든 함수가 fix_stage_won 이다.
    try:
        from app.modules.pipeline.still_recipe import fix_stage_won

        fix_applied = fix_stage_won(record)
    except Exception as exc:  # noqa: BLE001 — 기록은 본 작업을 안 막는다
        logger.debug("fix_stage_won 판정 실패 (non-fatal): %s", exc)
        fix_applied = False

    return {
        "shot_run_uid": record.get("shot_run_uid"),
        "input_fingerprint": record.get("input_fingerprint"),
        "ref_mode": record.get("ref_mode"),
        "share_plan": record.get("share_plan"),
        "refs": refs,
        "selected": record.get("selected"),
        "ranking": record.get("ranking"),
        "totals": record.get("totals"),
        "verdicts": verdicts,
        "issue_count": issue_count,
        "fix_applied": fix_applied,
        "fix_skip_reason": record.get("fix_skip_reason"),
        "cine_applied": bool((record.get("cine") or {}).get("applied"))
        if isinstance(record.get("cine"), dict) else False,
        "needs_reshoot": bool(record.get("needs_reshoot")),
    }


def update_trace_output(output: Dict[str, Any]) -> None:
    """지금 열려 있는 trace 의 output 을 채운다. 실패는 삼킨다."""
    handle = current_trace()
    if handle is None:
        return
    try:
        live = _LIVE.get(handle.uid)
        if live is not None:
            live.update(output=output)
    except Exception as exc:  # noqa: BLE001
        logger.debug("update_trace_output 실패 (non-fatal): %s", exc)


@contextmanager
def open_trace(
    *,
    name: str,
    tags: List[str],
    metadata: Dict[str, Any],
    thread_id: Optional[str],
    input_data: Optional[Dict[str, Any]] = None,
) -> Iterator[Optional[TraceHandle]]:
    """이름 있는 trace 를 열고 scope 에 세운다.

    설정이 꺼져 있으면 **아무것도 안 하고 None 을 준다** — 그 경우 하위
    호출은 지금처럼 각자 trace 를 만든다(바이트 동일).

    실패해도 None 을 줄 뿐 예외를 안 낸다 — 부모가 없으면 하위가 홀로
    설 뿐이고, 그것이 기록 없는 것보다 낫다.
    """
    from app.core.config import settings

    if not getattr(settings, "opik_trace_v2_enabled", False):
        yield None
        return

    handle: Optional[TraceHandle] = None
    try:
        uid = new_trace_uid()
        live = _get_client().trace(
            id=uid, name=name, tags=list(tags),
            metadata=dict(metadata), thread_id=thread_id,
            input=input_data or {},
        )
        _LIVE[uid] = live
        handle = TraceHandle(uid=uid, name=name, thread_id=thread_id)
    except Exception as exc:  # noqa: BLE001
        logger.debug("open_trace 실패 (non-fatal): %s", exc)
        handle = None

    if handle is None:
        yield None
        return

    token = _trace_ctx.set(handle)
    try:
        yield handle
    except Exception as exc:
        finish_trace(handle, error=str(exc))
        raise
    else:
        finish_trace(handle)
    finally:
        _trace_ctx.reset(token)
