"""
Contextra Analytics — Per-Stream Inference Profile  [MLOps 발판 Phase 1]

역할:
  - "어떤 모델/문턱값이 존재하는지"(카탈로그 = inference_config.ROLES)와
    "각 스트림이 무엇을 쓰는지"(프로파일 = streams.yaml의 inference_profile)를 분리.
  - 프로파일을 검증(fail-fast)하고, nvinferserver cfg 템플릿을 렌더링하며,
    후처리 문턱값(score/class별 최소 신뢰도)을 공급한다.

설계 원칙 (확장성/안전성):
  1. 카탈로그 대조 검증 — 프로파일이 참조하는 모델명이 ROLES 체인에 없으면
     ProfileError(스트림 기동 거부). 오타로 죽는 스트림보다 명확한 실패가 낫다.
  2. 폴백은 런타임 리졸브 — primary가 Triton READY 아님이면 inference_config의
     fallback 체인으로 강등(resolve_active 재사용) + degraded 플래그.
     모델 전환 = 해당 스트림만 재시작 (글로벌 blast radius 없음).
  3. cfg는 템플릿 렌더링 — 하드코딩 txt 대신 {{MODEL_NAME}}/{{UNIQUE_ID}}
     템플릿을 /tmp에 렌더링. 파일 증식 방지 + unique_id 충돌 제거.
  4. 문턱값은 Python 후처리에서 주입 — score threshold는 parse 단계(파이프라인 밖),
     Scenario confidence는 canonical Frame decision에서 독립 적용한다.

streams.yaml 스키마 (모든 필드 optional — 없으면 기존 동작과 동일):
  streams:
    - stream_id: CAM_01
      inference_profile:
        det_model: RFDETRLarge          # 카탈로그 검증 대상
        pose_model: RFDETRKeypointPreview
        seg_model: RFDETRSegMedium
        roles: [det, pose]              # 생략 = det+pose+seg 전부 (VRAM 절약용)
        thresholds:
          score: 0.5                    # parse_boxes_labels 임계값 (기본 0.5)
          class_min_confidence:         # 시나리오 게이트 — 클래스별 최소 신뢰도
            person: 0.4
"""

from __future__ import annotations

import hashlib
import logging
import os
import re
import tempfile
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional


logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# 예외 — fail-fast 신호 (호출부에서 스트림 기동 거부/비활성화 결정)
# ---------------------------------------------------------------------------

class ProfileError(ValueError):
    """프로파일 스키마/카탈로그 검증 실패."""


# ---------------------------------------------------------------------------
# 기본값 & 상수
# ---------------------------------------------------------------------------

DEFAULT_SCORE_THRESHOLD = 0.5   # 기존 하드코딩 값과 동일 (무변경 호환)
ALL_ROLES: tuple[str, ...] = ("det", "pose", "seg")

# cfg 템플릿 디렉터리 — canonical image 또는 host 개발 source에서 조회한다.
_CFG_DIR_CANDIDATES = (
    "/opt/contextra/app/cfg_templates",   # image-owned container source
    os.path.join(os.path.dirname(__file__), "cfg_templates"),  # 호스트 dev
)

# unique_id: nvinferserver는 같은 unique_id의 GIE가 GPU에서 충돌할 수 있으므로
# stream+role 해시로 유일하게 생성. 범위 [1, 2^31-1] (int32 양수).
_UNIQUE_ID_SPACE = 0x7FFFFFFF
_CFG_OUTPUT_DIR = "/tmp/contextra_cfg"


class ProfileValidationError(ProfileError):
    pass


@dataclass(frozen=True)
class StreamProfile:
    """검증 완료된 스트림별 추론 프로파일 (불변)."""
    stream_id: str
    roles: tuple[str, ...]                       # 활성화 역할 (순서 고정 det,pose,seg)
    models: Dict[str, str] = field(default_factory=dict)      # role -> 요청 모델명
    resolved_models: Dict[str, str] = field(default_factory=dict)  # role -> 실제 활성(폴백 적용 후)
    degraded_roles: tuple[str, ...] = ()         # primary가 죽고 fallback으로 강등된 역할
    score_threshold: float = DEFAULT_SCORE_THRESHOLD
    class_min_confidence: Dict[str, float] = field(default_factory=dict)
    # v2 구동 정책 — per-stream 시나리오 오버라이드 (3계층 cfg 최상위).
    # {scenario_name: {"enabled": bool, "cfg": {...}}}
    # enabled=False → 해당 스트림에서 비활성화. cfg → engine defaults/site_cfg 위에 deep-merge.
    scenarios: Dict[str, Any] = field(default_factory=dict)

    def is_degraded(self) -> bool:
        return bool(self.degraded_roles)


# ---------------------------------------------------------------------------
# 검증 + 리졸브
# ---------------------------------------------------------------------------

def _catalog_chain(role: str) -> List[str]:
    """inference_config.ROLES에서 역할의 폴백 *순서* (env 오버라이드 반영).

    자동 강등 경로만 포함(큰→작은). 업그레이드 후보(allowed_extra)는 여기 없음 —
    명시적 per-stream 선택만 허용 (catalog_allowed 참조).
    """
    from app.inference_config import ROLES, _chain  # lazy — Triton 없어도 import 가능
    if role not in ROLES:
        raise ProfileError(f"unknown role {role!r} (expected one of {list(ROLES)})")
    return _chain(role)


def _catalog_allowed(role: str) -> List[str]:
    """역할의 전체 허용 모델 집합 (검증용)."""
    from app.inference_config import catalog_allowed  # lazy
    if role not in ("det", "pose", "seg"):
        raise ProfileError(f"unknown role {role!r}")
    return catalog_allowed(role)


def validate_profile(stream_id: str, raw: Optional[dict]) -> StreamProfile:
    """streams.yaml의 inference_profile dict를 검증해 StreamProfile로 변환.

    - raw=None/{} → 기본값 (roles=전부, 모델=카탈로그 primary, score=0.5)
      → 기존 스트림 동작 무변경 보장.
    - 모델명 카탈로그 대조: 체인(기본+fallback)에 없으면 ProfileError.
    """
    raw = dict(raw or {})

    # -- roles ---------------------------------------------------------------
    roles_raw = raw.get("roles")
    if roles_raw is None:
        roles = ALL_ROLES
    else:
        if not isinstance(roles_raw, (list, tuple)) or not roles_raw:
            raise ProfileError(f"{stream_id}: inference_profile.roles must be a non-empty list of {ALL_ROLES}")
        roles_norm: List[str] = []
        for r in roles_raw:
            rs = str(r).strip().lower()
            if rs not in ALL_ROLES:
                raise ProfileError(f"{stream_id}: unknown role {r!r} (expected one of {ALL_ROLES})")
            if rs not in roles_norm:
                roles_norm.append(rs)
        # 순서 고정 (det, pose, seg) — 파이프라인 생성 순서와 일치.
        roles = tuple(r for r in ALL_ROLES if r in roles_norm)

    # -- 모델명 (카탈로그 검증) ----------------------------------------------
    models: Dict[str, str] = {}
    for role in roles:
        chain = _catalog_chain(role)          # 폴백 순서 (기본값 결정용)
        allowed = _catalog_allowed(role)      # 전체 허용 집합 (검증용 — 업그레이드 포함)
        key = f"{role}_model"
        requested = raw.get(key)
        if requested is None:
            models[role] = chain[0]          # 카탈로그 primary (env 오버라이드 반영됨)
            continue
        req_s = str(requested).strip()
        if not req_s:
            raise ProfileError(f"{stream_id}: {key} must be a non-empty model name")
        if req_s not in allowed:
            raise ProfileError(
                f"{stream_id}: {key}={req_s!r} is not in the catalog for role "
                f"'{role}' (allowed: {allowed})"
            )
        models[role] = req_s

    # -- 문턱값 ---------------------------------------------------------------
    th_raw = raw.get("thresholds") or {}
    if not isinstance(th_raw, dict):
        raise ProfileError(f"{stream_id}: inference_profile.thresholds must be a mapping")

    score = float(th_raw.get("score", DEFAULT_SCORE_THRESHOLD))
    if not (0.0 < score <= 1.0):
        raise ProfileError(f"{stream_id}: thresholds.score={score} out of range (0, 1]")

    class_min: Dict[str, float] = {}
    cmc_raw = th_raw.get("class_min_confidence") or {}
    if not isinstance(cmc_raw, dict):
        raise ProfileError(f"{stream_id}: thresholds.class_min_confidence must be a mapping")
    for cls, v in cmc_raw.items():
        try:
            fv = float(v)
        except (TypeError, ValueError):
            raise ProfileError(f"{stream_id}: class_min_confidence[{cls!r}]={v!r} is not numeric")
        if not (0.0 <= fv <= 1.0):
            raise ProfileError(f"{stream_id}: class_min_confidence[{cls!r}]={fv} out of range [0, 1]")
        class_min[str(cls).strip().lower()] = fv

    # -- v2 시나리오 오버라이드 (per-stream) ---------------------------------
    scenarios_raw = raw.get("scenarios") or {}
    if not isinstance(scenarios_raw, dict):
        raise ProfileError(f"{stream_id}: inference_profile.scenarios must be a mapping")
    scenarios: Dict[str, Any] = {}
    for sname, sraw in scenarios_raw.items():
        sname_s = str(sname).strip()
        if not sname_s:
            continue
        entry: Dict[str, Any] = {}
        if isinstance(sraw, dict):
            if "enabled" in sraw:
                entry["enabled"] = bool(sraw["enabled"])
            cfg_raw = sraw.get("cfg") or {}
            if not isinstance(cfg_raw, dict):
                raise ProfileError(f"{stream_id}: scenarios[{sname!r}].cfg must be a mapping")
            if cfg_raw:
                entry["cfg"] = dict(cfg_raw)
        elif isinstance(sraw, bool):
            # 간결형: scenarios: {intrusion: false} → enabled만.
            entry["enabled"] = sraw
        else:
            raise ProfileError(f"{stream_id}: scenarios[{sname!r}] must be a mapping or boolean")
        if entry:
            scenarios[sname_s] = entry

    return StreamProfile(
        stream_id=stream_id,
        roles=roles,
        models=models,
        score_threshold=score,
        class_min_confidence=class_min,
        scenarios=scenarios,
    )


def resolve_profile(stream_id: str, raw: Optional[dict],
                    is_ready=None) -> StreamProfile:
    """검증 + Triton READY 기반 폴백 리졸브.

    primary가 READY 아님 → 카탈로그 fallback 체인으로 강등 (inference_config.resolve_active 재사용).
    어떤 후보도 READY 아님이면 ProfileError — 스트림은 그 역할 없이 기동하지 않고
    명확한 에러로 실패한다 (silent degradation 금지).
    """
    prof = validate_profile(stream_id, raw)

    if is_ready is None:
        from app.inference_engine import engine  # lazy
        is_ready = engine.is_model_ready

    resolved: Dict[str, str] = {}
    degraded: List[str] = []
    for role in prof.roles:
        chain = _catalog_chain(role)
        allowed = _catalog_allowed(role)
        # 리졸브 순서: 요청 모델 → 폴백(작은) → 업그레이드 후보.
        # primary가 죽으면 어떤 후보든 READY면 가동 유지 (가용성 우선, 결정적 순서).
        order = [prof.models[role]] + [m for m in chain if m != prof.models[role]] \
            + [m for m in allowed if m not in chain and m != prof.models[role]]
        order = [m for m in order if m != 'RFDETRFireSmoke' or prof.models[role] == m]
        crowd_model = 'Awiros_person_and_head_detection'
        order = [crowd_model] if prof.models[role] == crowd_model else [m for m in order if m != crowd_model]
        active = None
        for model in order:
            try:
                if is_ready(model):
                    active = model
                    break
            except Exception:
                continue
        if active is None:
            raise ProfileError(
                f"{stream_id}: no READY model for role '{role}' (tried {order}) — "
                f"check Triton status before starting this stream"
            )
        resolved[role] = active
        if active != prof.models[role]:
            degraded.append(role)

    return StreamProfile(
        stream_id=prof.stream_id,
        roles=prof.roles,
        models=dict(prof.models),
        resolved_models=resolved,
        degraded_roles=tuple(degraded),
        score_threshold=prof.score_threshold,
        class_min_confidence=dict(prof.class_min_confidence),
    )


# ---------------------------------------------------------------------------
# nvinferserver cfg 렌더링
# ---------------------------------------------------------------------------

def _find_template_dir() -> str:
    for d in _CFG_DIR_CANDIDATES:
        if os.path.isdir(d):
            return d
    raise ProfileError(
        f"cfg template dir not found (looked in {_CFG_DIR_CANDIDATES}) — "
        f"the canonical image must contain app/cfg_templates"
    )


def inference_component_id(stream_id: str, role: str) -> int:
    """stream+role → 결정적 unique_id (재시작 시 동일 ID 재사용)."""
    digest = hashlib.sha256(f"{stream_id}:{role}".encode()).digest()
    return 1 + int.from_bytes(digest[:4], "big") % _UNIQUE_ID_SPACE


def render_cfg(stream_id: str, role: str, model_name: str, *, component_key: str | None = None) -> str:
    """역할 템플릿을 (model_name, unique_id)로 렌더링해 /tmp에 기록.

    반환: 렌더링된 cfg 파일 경로 (nvinferserver가 읽는 텍스트).
    동일 (stream, role, model) 조합은 캐시되어 재기동 시 같은 경로를 재사용한다.
    """
    if role not in ALL_ROLES:
        raise ProfileError(f"render_cfg: unknown role {role!r}")

    tdir = _find_template_dir()
    tpl_path = os.path.join(tdir, f"nvinferserver_grpc_{role}.tmpl")
    if not os.path.isfile(tpl_path):
        raise ProfileError(f"cfg template missing: {tpl_path}")

    component_key = role if component_key is None else str(component_key)
    uid = inference_component_id(stream_id, component_key)
    with open(tpl_path, "r", encoding="utf-8") as f:
        text = f.read()
    rendered = (text.replace("{{MODEL_NAME}}", model_name)
                    .replace("{{UNIQUE_ID}}", str(uid)))
    from app.medium_preprocess import NATIVE_PREPROCESS, medium_preprocess_config, model_input_contract
    input_dtype, cuda_sharing = model_input_contract(role, model_name)
    fp16_input = input_dtype == 'fp16'
    rendered = rendered.replace('{{CUDA_BUFFER_SHARING}}', 'true' if cuda_sharing else 'false')
    is_fire = role == 'det' and model_name == 'RFDETRFireSmoke'
    is_crowd = role == 'det' and model_name == 'Awiros_person_and_head_detection'
    if is_fire:
        rendered = rendered.replace('CreateContextraRfDetrProcessor', 'CreateContextraFireProcessor')
    if is_crowd:
        rendered = rendered.replace('CreateContextraRfDetrProcessor', 'CreateContextraCrowdProcessor')
    is_medium = ((role == "det" and model_name in ("RFDETRMedium", 'RFDETRFireSmoke', 'Awiros_person_and_head_detection'))
                 or (role == 'pose' and model_name == 'RFDETRKeypointPreview'))
    if is_medium and '{{PREPROCESS_BLOCK}}' not in text:
        raise ProfileError('Medium preprocessing template contract is stale')
    block = 'input_tensor_from_meta { is_first_dim_batch: true }' if is_medium else NATIVE_PREPROCESS
    rendered = rendered.replace("{{PREPROCESS_BLOCK}}", block)

    out_dir = _CFG_OUTPUT_DIR
    os.makedirs(out_dir, exist_ok=True)
    safe_sid = re.sub(r"[^A-Za-z0-9_-]", "_", stream_id)[:48] or "stream"
    out_path = os.path.join(out_dir, f"{safe_sid}_{role}_{model_name}.txt")
    if is_medium:
        preprocess = medium_preprocess_config(uid, fp16_input=fp16_input, resolution=512 if is_crowd else 576)
        revision = hashlib.sha256((rendered + preprocess).encode()).hexdigest()[:16]
        out_path = os.path.join(out_dir, f"medium_{uid}_{revision}.txt")
        # 내용 기반 이름으로 legacy cfg 재사용과 실행 중 덮어쓰기를 피한다.
        for target, content in ((out_path + '.preprocess', preprocess), (out_path, rendered)):
            if not os.path.isfile(target):
                fd, temporary = tempfile.mkstemp(dir=out_dir)
                try:
                    with os.fdopen(fd, 'w', encoding='utf-8') as output:
                        output.write(content)
                    os.replace(temporary, target)
                finally:
                    if os.path.exists(temporary):
                        os.unlink(temporary)
        return out_path

    # 내용 불변이면 재쓰기 생략 (inode 안정성 — 실행 중 파일 교체 방지).
    if not os.path.isfile(out_path):
        tmp = out_path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(rendered)
        os.replace(tmp, out_path)

    logger.info("[profile] %s role=%s model=%s uid=%d cfg=%s",
                stream_id, role, model_name, uid, out_path)
    return out_path


# ---------------------------------------------------------------------------
# streams.yaml 통합 헬퍼
# ---------------------------------------------------------------------------

def profile_from_spec(stream_id: str, spec: dict) -> StreamProfile:
    """streams.yaml 스트림 항목에서 inference_profile 추출 + 검증 (리졸브 전)."""
    raw = spec.get("inference_profile")
    if raw is not None and not isinstance(raw, dict):
        raise ProfileError(f"{stream_id}: inference_profile must be a mapping")
    return validate_profile(stream_id, raw)


def resolve_from_spec(stream_id: str, spec: dict, is_ready=None) -> StreamProfile:
    """streams.yaml 스트림 항목 → 검증 + 폴백 리졸브까지 완료된 프로파일."""
    from app.pose_config import planned_profile
    raw = planned_profile(spec)
    if raw is not None and not isinstance(raw, dict):
        raise ProfileError(f"{stream_id}: inference_profile must be a mapping")
    if 'pose' in spec or 'fire' in spec:
        from app.pose_config import execution_plan
        expected = execution_plan(spec)['model']
        ready = is_ready
        if ready is None:
            from app.inference_engine import engine
            ready = engine.is_model_ready
        is_ready = lambda model: model == expected and ready(model)
    return resolve_profile(stream_id, raw, is_ready=is_ready)


def profile_summary(prof: StreamProfile) -> Dict[str, Any]:
    """/api/v1/streams?detail=true 및 로그용 요약 dict."""
    return {
        "roles": list(prof.roles),
        "requested_models": prof.models,
        "active_models": prof.resolved_models or prof.models,
        "degraded_roles": list(prof.degraded_roles),
        "score_threshold": prof.score_threshold,
        "class_min_confidence": prof.class_min_confidence,
    }
