"""STT 마이크로서비스 — faster-whisper (GPU) 기반 음성 전사.

filePath → {text, segments:[{start,end,text}]}.
기본 모델 large-v3 (VRAM ~3GB, float16). 환경변수로 교체 가능.

D-1 (plan crawl-24x7-1, 2026-06-10) — idle-unload 운영 전환:
  STT_PRELOAD=0          : startup eager preload 끔 (첫 요청 시 lazy load)
  STT_IDLE_UNLOAD_MINUTES: N분 유휴 시 모델 해제 (0=비활성, 기존 동작).
                           faster-whisper=CTranslate2 라 torch 미설치 — 해제는
                           참조 제거 + gc (CTranslate2 가 소멸 시 VRAM 반환).
콜드로드 수초~수십 초는 호출측(stt_worker read=1800s)이 흡수. healthcheck 는
cuda 가용성 기준 (compose) — 모델 적재는 더 이상 상시 상태가 아니다.
"""

import asyncio
import gc
import logging
import os
import threading
import time
import unicodedata
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI

logger = logging.getLogger("stt")

_IDLE_UNLOAD_MINUTES = int(os.getenv("STT_IDLE_UNLOAD_MINUTES", "0"))


@asynccontextmanager
async def lifespan(_app: FastAPI):
    # startup: 모델 eager preload 시도. 실패해도 프로세스는 살아 있고
    # /ready 의 models_loaded 가 false 로 남는다.
    if os.getenv("STT_PRELOAD", "1") != "0":
        try:
            _load_model()
            logger.info("stt model preloaded: %s (%s, %s)", _MODEL_NAME, _DEVICE, _COMPUTE_TYPE)
        except Exception as e:
            logger.exception("stt model preload failed: %s", e)
    reaper = None
    if _IDLE_UNLOAD_MINUTES > 0:
        reaper = asyncio.create_task(_idle_reaper())
        logger.info("stt idle-unload 활성: %d분", _IDLE_UNLOAD_MINUTES)
    yield
    if reaper:
        reaper.cancel()


app = FastAPI(lifespan=lifespan)

_model = None
_MODEL_NAME = os.getenv("WHISPER_MODEL", "large-v3")
_DEVICE = os.getenv("WHISPER_DEVICE", "cuda")
_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "float16")

# load/unload/inflight 상태 전이는 전부 이 lock 아래 (cold 동시 요청 이중 로드 방지 포함)
_model_lock = threading.Lock()
_inflight = 0
_last_used = time.monotonic()


def _resolve_path(file_path: str) -> Path | None:
    """NFC(DB) vs NFD(NFS) 한글 경로 정규화 차이 흡수. OCR 서비스와 동일 패턴."""
    candidates = [
        file_path,
        unicodedata.normalize("NFD", file_path),
        unicodedata.normalize("NFC", file_path),
    ]
    for c in candidates:
        p = Path(c)
        if p.exists():
            return p
    # 마지막 fallback: parent 디렉토리에서 이름을 NFC 로 매칭
    parent = Path(file_path).parent
    if parent.exists():
        target = unicodedata.normalize("NFC", Path(file_path).name)
        for child in parent.iterdir():
            if unicodedata.normalize("NFC", child.name) == target:
                return child
    return None


def _load_model():
    """faster-whisper lazy loading — 첫 호출 시만 VRAM 점유. lock 으로 이중 로드 방지."""
    global _model
    if _model is not None:
        return _model
    with _model_lock:
        if _model is None:
            from faster_whisper import WhisperModel
            logger.info("stt model loading: %s (%s, %s)", _MODEL_NAME, _DEVICE, _COMPUTE_TYPE)
            _model = WhisperModel(_MODEL_NAME, device=_DEVICE, compute_type=_COMPUTE_TYPE)
        return _model


def _maybe_unload() -> None:
    """유휴 시 모델 해제. 처리 중(inflight>0)이면 절대 해제하지 않는다."""
    global _model
    with _model_lock:
        if _model is None or _inflight > 0:
            return
        if time.monotonic() - _last_used < _IDLE_UNLOAD_MINUTES * 60:
            return
        _model = None
    gc.collect()
    logger.info("stt idle-unload: whisper 모델 해제 (유휴 %d분 초과)", _IDLE_UNLOAD_MINUTES)


async def _idle_reaper():
    while True:
        await asyncio.sleep(60)
        try:
            _maybe_unload()
        except Exception:
            logger.exception("stt idle reaper 오류")


def _cuda_device_count() -> int:
    try:
        import ctranslate2
        return ctranslate2.get_cuda_device_count()
    except Exception:
        return 0


@app.get("/health")
def health():
    """Liveness — Docker healthcheck 용, 프로세스 생존 확인."""
    return {"status": "ok", "service": "stt-faster-whisper"}


@app.get("/ready")
def ready():
    """Readiness — CUDA + 모델 상태. healthcheck 는 cuda 만 본다 (D-1 idle-unload)."""
    count = _cuda_device_count()
    cuda_ok = count > 0
    models_loaded = _model is not None
    return {
        "ready": cuda_ok and models_loaded,
        "cuda": cuda_ok,
        "cuda_device_count": count,
        "models_loaded": models_loaded,
        "model": _MODEL_NAME,
        "compute_type": _COMPUTE_TYPE,
        "idle_unload_minutes": _IDLE_UNLOAD_MINUTES,
        "inflight": _inflight,
    }


@app.post("/transcribe")
async def transcribe(body: dict):
    """오디오 파일 전사.

    입력:
      {
        "filePath": "/documents/PKM/Recordings/2026-04-23_회의.mp3",
        "langs":    ["ko"]?,     # 단일 언어 지정 or 생략(자동감지)
        "beamSize": 5?           # 기본 5
      }

    출력:
      {
        "text": "전체 전사 텍스트",
        "segments": [{"start": 0.0, "end": 2.4, "text": "..."}, ...],
        "language": "ko",
        "language_probability": 0.99,
        "duration": 1832.5
      }
    """
    global _inflight, _last_used
    raw_path = body["filePath"]
    langs = body.get("langs")
    beam_size = int(body.get("beamSize", 5))

    resolved = _resolve_path(raw_path)
    if resolved is None:
        return {"error": f"파일 없음: {raw_path}", "text": "", "segments": []}

    with _model_lock:
        _inflight += 1
    try:
        model = _load_model()

        language = None
        if isinstance(langs, list) and len(langs) == 1:
            language = langs[0]

        segments_iter, info = model.transcribe(
            str(resolved),
            beam_size=beam_size,
            language=language,
            vad_filter=True,
        )

        segments = []
        parts = []
        for seg in segments_iter:
            segments.append({
                "start": round(float(seg.start), 2),
                "end": round(float(seg.end), 2),
                "text": seg.text.strip(),
            })
            parts.append(seg.text)
    finally:
        with _model_lock:
            _inflight -= 1
            _last_used = time.monotonic()

    return {
        "text": " ".join(p.strip() for p in parts).strip(),
        "segments": segments,
        "language": getattr(info, "language", None),
        "language_probability": float(getattr(info, "language_probability", 0.0) or 0.0),
        "duration": float(getattr(info, "duration", 0.0) or 0.0),
    }
