"""card_extract 워커 — ready 풀이/근거에서 암기 플래시카드 추출 (공부 암기노트 Phase 1).

study_explanation_worker.run_explanation_job 골격 복제:
  1. ready 게이트 + RAG(gather_explanation_context) + ai_explanation + evidence_refs (in-process)
  2. ai_explanation 미준비면 LLM 호출 X (no_ready_explanation, skipped)
  3. MLX primary (gate BACKGROUND, gate 안에서만 timeout) -> {cards:[...]} JSON
  4. 카드별 가드(study_memo_card_guards) — 근거(정량=원문등장)·누출·dedup
  5. 통과 카드 있으면 supersede(구버전 retire) -> append + evidence. 0장이면 all_dropped(completed).

GPU = in-process RAG provider (explanation 워커와 동일 구조; internal_study card-context
endpoint 는 호출자 0 scaffold). Mac mini = call_primary 생성.
재시도: llm_timeout/parse_fail/unknown 만 (attempts < max_attempts), all_dropped/no_ready 는 종결.
"""

from __future__ import annotations

import asyncio
import json
import logging
from datetime import datetime, timezone
from pathlib import Path

import httpx
from sqlalchemy.ext.asyncio import AsyncSession

from ai.client import AIClient, parse_json_response
from models.study_memo_card import (
    append_card,
    append_card_evidence,
    supersede_old_cards,
)
from models.study_memo_card_job import StudyMemoCardJob
from models.study_question import StudyQuestion
from models.user import User  # noqa: F401  (mapper 초기화 defensive)
from services.search.llm_gate import Priority, acquire_mlx_gate
from services.study.explanation_rag import (
    gather_explanation_context,
    render_evidence_block,
)
from services.study.study_memo_card_guards import guard_cards

logger = logging.getLogger("study_memo_card_worker")

# 다카드 출력이라 explanation(30s)보다 여유. config primary.timeout(180, soft-lock)은 미변경.
CARD_LLM_TIMEOUT_S = 45.0
SOURCE_KIND_QUESTION = "question"

_ENVELOPE_PROMPT_FILE = "study_card_envelope.txt"
_envelope_template_cache: str | None = None


def _load_card_envelope_prompt() -> str:
    global _envelope_template_cache
    if _envelope_template_cache is None:
        prompts_dir = Path(__file__).resolve().parent.parent / "prompts"
        _envelope_template_cache = (
            prompts_dir / _ENVELOPE_PROMPT_FILE
        ).read_text(encoding="utf-8")
    return _envelope_template_cache


def _render_card_envelope_prompt(
    q: StudyQuestion, doc_block: str, q_block: str, ai_explanation: str
) -> str:
    return (
        _load_card_envelope_prompt()
        .replace("{question_text}", q.question_text or "")
        .replace("{choice_1}", q.choice_1 or "")
        .replace("{choice_2}", q.choice_2 or "")
        .replace("{choice_3}", q.choice_3 or "")
        .replace("{choice_4}", q.choice_4 or "")
        .replace("{correct_choice}", str(q.correct_choice))
        .replace("{ai_explanation}", ai_explanation or "")
        .replace("{documents_evidence_block}", doc_block)
        .replace("{questions_evidence_block}", q_block)
    )


async def run_card_extract_job(session: AsyncSession, job: StudyMemoCardJob) -> None:
    """study_memo_card_jobs row 1건 처리. caller(consumer)가 commit 책임.

    종료 시 completed / failed / skipped / pending(재시도) 중 하나.
    """
    now = lambda: datetime.now(timezone.utc)  # noqa: E731

    job.attempts += 1
    job.status = "processing"
    job.started_at = now()
    await session.flush()

    try:
        # P1 은 question source 만. 다른 source_kind 는 미구현 — skipped.
        if job.source_kind != SOURCE_KIND_QUESTION:
            job.error_code = "unknown"
            job.error_message = f"unsupported source_kind: {job.source_kind!r}"
            job.status = "skipped"
            job.completed_at = now()
            return

        question = await session.get(StudyQuestion, job.source_id)
        if question is None or question.deleted_at is not None:
            job.error_code = "no_ready_explanation"
            job.error_message = "source question deleted or missing"
            job.status = "skipped"
            job.completed_at = now()
            return

        # ready 게이트 — explanation 이 ready 가 아니면(정정으로 stale 등) 추출 보류.
        if question.ai_explanation_status != "ready" or not (question.ai_explanation or "").strip():
            job.error_code = "no_ready_explanation"
            job.error_message = f"ai_explanation_status={question.ai_explanation_status}"
            job.status = "skipped"
            job.completed_at = now()
            return

        source_version = question.ai_explanation_generated_at

        # 1. RAG 근거 (in-process). ai_explanation 이 정성 1순위, evidence 가 정량 원문.
        ctx = await gather_explanation_context(session, question.user_id, question)
        evidence_refs = [it.to_dict() for it in ctx.all]
        doc_block = render_evidence_block(ctx.documents)
        q_block = render_evidence_block(ctx.questions)
        prompt = _render_card_envelope_prompt(question, doc_block, q_block, question.ai_explanation)

        # 2. MLX primary
        ai_client = AIClient()
        try:
            async with acquire_mlx_gate(Priority.BACKGROUND):
                async with asyncio.timeout(CARD_LLM_TIMEOUT_S):
                    raw_text = await ai_client.call_primary(prompt)
            primary_name = (
                ai_client.ai.primary.model
                if hasattr(ai_client.ai, "primary") and hasattr(ai_client.ai.primary, "model")
                else "primary"
            )
        finally:
            await ai_client.close()

        if not raw_text or not raw_text.strip():
            job.error_code = "llm_timeout"
            job.error_message = "empty response from primary"
            return

        # 3. {cards:[...]} 파싱
        def _save_raw_preview(reason: str) -> None:
            existing = dict(job.payload or {})
            existing["debug_raw_preview"] = (raw_text or "")[:1000]
            existing["parse_fail_reason"] = reason
            job.payload = existing

        envelope = parse_json_response(raw_text)
        if envelope is None or not isinstance(envelope, dict):
            job.error_code = "parse_fail"
            job.error_message = "envelope JSON parse failed"
            _save_raw_preview("not_dict")
            return
        cards = envelope.get("cards")
        if not isinstance(cards, list):
            job.error_code = "parse_fail"
            job.error_message = f"cards not a list: {type(cards).__name__}"
            _save_raw_preview("cards_not_list")
            return

        # 4. 카드별 가드
        guarded = guard_cards(
            cards,
            source_question_id=question.id,
            ai_explanation=question.ai_explanation,
            evidence_refs=evidence_refs,
        )

        payload = dict(job.payload or {})
        payload["cards_generated"] = len(cards)
        payload["cards_kept"] = len(guarded)

        if not guarded:
            # 전량 drop — completed 로 종결해 같은 버전 재추출 차단(재시도 집합에서 제외).
            payload["cards_inserted"] = 0
            job.payload = payload
            job.error_code = "all_dropped"
            job.status = "completed"
            job.completed_at = now()
            return

        # 5. 성공 — 구버전 카드 retire 후 append (dedup partial unique 충돌 회피).
        await supersede_old_cards(
            session, source_question_id=question.id, keep_generated_at=source_version
        )
        model_name = f"mlx:{primary_name}"
        inserted = 0
        for g in guarded:
            card_id = await append_card(
                session,
                user_id=question.user_id,
                study_topic_id=question.study_topic_id,
                source_kind=SOURCE_KIND_QUESTION,
                source_question_id=question.id,
                format=g.format,
                cue=g.cue,
                fact=g.fact,
                cloze_text=g.cloze_text,
                dedup_hash=g.dedup_hash,
                source_generated_at=source_version,
                model=model_name,
                generated_at=now(),
                needs_review=True,
            )
            if card_id is not None:
                inserted += 1
                if g.matched_evidence:
                    await append_card_evidence(
                        session, card_id=card_id, refs=g.matched_evidence
                    )

        payload["cards_inserted"] = inserted
        job.payload = payload
        job.status = "completed"
        job.completed_at = now()
        return

    except (asyncio.TimeoutError, httpx.HTTPError) as e:
        job.error_code = "llm_timeout"
        job.error_message = f"{type(e).__name__}: {e}"
        logger.warning(
            "card_extract_job_timeout job_id=%s src=%s/%s: %s",
            job.id, job.source_kind, job.source_id, e,
        )
    except (json.JSONDecodeError, ValueError) as e:
        job.error_code = "parse_fail"
        job.error_message = f"{type(e).__name__}: {e}"
        logger.warning(
            "card_extract_job_parse_fail job_id=%s src=%s/%s: %s",
            job.id, job.source_kind, job.source_id, e,
        )
    except Exception as e:
        job.error_code = "unknown"
        job.error_message = f"{type(e).__name__}: {e}"
        logger.exception(
            "card_extract_job_unknown_fail job_id=%s src=%s/%s",
            job.id, job.source_kind, job.source_id,
        )
    finally:
        # 재시도 분기 — all_dropped/no_ready/unsupported 는 위에서 이미 종결.
        # 여기 도달 = llm_timeout / parse_fail / unknown.
        if job.status == "processing":
            retryable = job.error_code in ("llm_timeout", "parse_fail", "unknown")
            if retryable and job.attempts < job.max_attempts:
                job.status = "pending"
            else:
                job.status = "failed"
                job.completed_at = now()
