"""Phase 4-B v1 세션 분석 worker — 자유 마크다운 요약 1건 생성.

Plan: ~/.claude/plans/nifty-sparking-spindle.md
study_quiz_session_jobs (kind 단일 — 'analysis') row 1건 처리:
  1. 이 세션의 wrong/unsure attempts + 연결 question 메타 fetch
  2. wrong/unsure < 5 → insufficient_attempts skip
  3. RAG (documents 만, 없어도 호출 진행 — 4-A 와 다른 정책)
  4. envelope JSON 호출
  5. 가드 — GUARD_PATTERN 정규식 + 800자 hard cap + confidence normalize
  6. study_quiz_session_analysis UPSERT (PK = session_id)

terminal status (completed/failed/skipped) 모두 completed_at 기록.
재시도는 4-A 동일 — guard_fail/insufficient/evidence_missing 자동 X,
llm_timeout/parse_fail/unknown 만 attempts < max_attempts 면 pending.
"""

from __future__ import annotations

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

import httpx
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession

from ai.client import AIClient, parse_json_response
from models.study_question import StudyQuestion, StudyQuestionAttempt
from models.study_quiz_session import StudyQuizSession
from models.study_quiz_session_analysis import StudyQuizSessionAnalysis
from models.study_quiz_session_job import StudyQuizSessionJob
from services.search.llm_gate import Priority, acquire_mlx_gate
from services.study.session_summary_guard import (
    GUARD_PATTERN,
    calibrate_confidence,
    normalize_confidence,
)
from services.study.session_summary_rag import gather_session_summary_context

logger = logging.getLogger(__name__)

# 4-A 와 동일 안전 마진 (26B 평균 ~10s, gate 직렬화 고려)
LLM_TIMEOUT_S = 30.0
# wrong/unsure 5 미만은 분석 의미 X — insufficient_attempts skip
MIN_ATTEMPTS_FOR_ANALYSIS = 5
# 큰 세션 (84건 등) 에서 prompt 과대 + LLM timeout 방어. 가장 최근 attempt 기준 cap.
# plan 가정 5~30건 — 30건이면 prompt 6~8K 자 안에 들어옴.
MAX_ATTEMPTS_IN_PROMPT = 30
# 응답 800자 hard cap (말줄임표 포함 ≤ 801)
SUMMARY_MAX_CHARS = 800

_PROMPT_FILE = "study_session_summary_envelope.txt"
_prompt_template_cache: str | None = None


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


def _format_subject_distribution(dist: dict | None) -> str:
    if not dist:
        return "(분포 없음)"
    return ", ".join(f"{k}: {v}건" for k, v in dist.items() if v)


def _format_wrong_unsure_block(attempts_data: list[dict]) -> str:
    """각 attempt 를 prompt 1 항목 line 으로 변환."""
    lines: list[str] = []
    for a in attempts_data:
        q = a["question"]
        outcome = a["outcome"]
        head = f"- Q{q.id} [{q.subject or '(미분류)'} / {q.scope or '(scope 없음)'}] " \
               f"outcome={outcome} 정답 {q.correct_choice}번 " \
               f"(사용자 선택 {a['selected_choice'] if a['selected_choice'] is not None else '-'}번)"
        lines.append(head)
        qt = (q.question_text or "")[:80].replace("\n", " ")
        if qt:
            lines.append(f"  · {qt}")
        if a.get("ai_explanation"):
            ae = a["ai_explanation"][:200].replace("\n", " ")
            lines.append(f"  · 풀이: {ae}")
    return "\n".join(lines) if lines else "(없음)"


def _format_evidence_block(items) -> str:
    if not items:
        return "(없음)"
    return "\n".join(f"- [{it.title}] {it.snippet}" for it in items)


def _render_session_summary_prompt(qs: StudyQuizSession, attempts_data: list[dict], ctx_docs) -> str:
    total_n = len(qs.question_ids or [])
    return (
        _load_envelope_prompt()
        .replace("{total}", str(total_n))
        .replace("{correct}", str(qs.correct_count))
        .replace("{wrong}", str(qs.wrong_count))
        .replace("{unsure}", str(qs.unsure_count))
        .replace("{newly_correct}", str(qs.newly_correct_count))
        .replace("{relapsed}", str(qs.relapsed_count))
        .replace("{recovered}", str(qs.recovered_count))
        .replace("{chronic_remaining}", str(qs.chronic_remaining_count))
        .replace("{subject_distribution_block}", _format_subject_distribution(qs.subject_distribution))
        .replace("{wrong_unsure_block}", _format_wrong_unsure_block(attempts_data))
        .replace("{documents_evidence_block}", _format_evidence_block(ctx_docs))
    )


async def _fetch_wrong_unsure_attempts(session: AsyncSession, study_quiz_session_id: int) -> list[dict]:
    """이 세션의 wrong/unsure attempts + 연결 question + (있으면) ready ai_explanation."""
    rows = (
        await session.execute(
            select(StudyQuestionAttempt, StudyQuestion)
            .join(StudyQuestion, StudyQuestion.id == StudyQuestionAttempt.study_question_id)
            .where(
                StudyQuestionAttempt.quiz_session_id == study_quiz_session_id,
                StudyQuestionAttempt.outcome.in_(("wrong", "unsure")),
                StudyQuestion.deleted_at.is_(None),
            )
            .order_by(StudyQuestionAttempt.answered_at.asc())
        )
    ).all()
    out: list[dict] = []
    for attempt, q in rows:
        ae = q.ai_explanation if q.ai_explanation_status == "ready" else None
        out.append({
            "question": q,
            "outcome": attempt.outcome,
            "selected_choice": attempt.selected_choice,
            "ai_explanation": ae,
        })
    return out


async def _upsert_analysis(
    session: AsyncSession,
    *,
    study_quiz_session_id: int,
    user_id: int,
    summary_md: str,
    confidence: str,
    model_name: str,
    now: datetime,
) -> None:
    """ON CONFLICT DO UPDATE — session_id PK. 재생성 시 기존 row 갱신 + is_stale=False."""
    stmt = pg_insert(StudyQuizSessionAnalysis).values(
        study_quiz_session_id=study_quiz_session_id,
        user_id=user_id,
        summary_md=summary_md,
        confidence=confidence,
        model_name=model_name,
        generated_at=now,
        is_stale=False,
    )
    stmt = stmt.on_conflict_do_update(
        index_elements=["study_quiz_session_id"],
        set_=dict(
            summary_md=stmt.excluded.summary_md,
            confidence=stmt.excluded.confidence,
            model_name=stmt.excluded.model_name,
            generated_at=stmt.excluded.generated_at,
            is_stale=False,
        ),
    )
    await session.execute(stmt)


async def run_session_analysis_job(session: AsyncSession, job: StudyQuizSessionJob) -> None:
    """Phase 4-B v1 worker. caller 가 commit. job.status 호출 전 'pending' 가정."""
    now = lambda: datetime.now(timezone.utc)  # noqa: E731

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

    try:
        qs = await session.get(StudyQuizSession, job.study_quiz_session_id)
        if qs is None:
            job.error_code = "evidence_missing"
            job.error_message = "session not found"
            job.status = "skipped"
            job.completed_at = now()
            return
        if qs.status != "done":
            job.error_code = "evidence_missing"
            job.error_message = f"session status={qs.status} (not done)"
            job.status = "skipped"
            job.completed_at = now()
            return

        # 1. wrong/unsure attempts fetch
        attempts_data = await _fetch_wrong_unsure_attempts(session, qs.id)
        if len(attempts_data) < MIN_ATTEMPTS_FOR_ANALYSIS:
            job.error_code = "insufficient_attempts"
            job.error_message = f"wrong/unsure count={len(attempts_data)} < {MIN_ATTEMPTS_FOR_ANALYSIS}"
            job.status = "skipped"
            job.completed_at = now()
            return

        # 2. evidence_missing — question_text/outcome/subject 자체가 부족한 예외만
        valid_attempts = [
            a for a in attempts_data
            if a["question"].question_text and a["outcome"]
        ]
        if len(valid_attempts) < MIN_ATTEMPTS_FOR_ANALYSIS:
            job.error_code = "evidence_missing"
            job.error_message = "wrong/unsure attempts lack question_text or outcome"
            job.status = "skipped"
            job.completed_at = now()
            return

        # 3. prompt 입력 cap — 큰 세션 (84건 등) 에서 prompt 과대 방지.
        # 가장 최근 attempts 우선 (이미 answered_at asc 정렬되어 있어 끝쪽 = 최근)
        prompt_attempts = valid_attempts[-MAX_ATTEMPTS_IN_PROMPT:]

        # 4. RAG (documents 만, 없어도 호출 진행)
        questions = [a["question"] for a in prompt_attempts]
        ctx_docs = await gather_session_summary_context(
            session,
            user_id=qs.user_id,
            study_topic_id=qs.study_topic_id,
            wrong_unsure_questions=questions,
        )

        # 5. prompt 조립 + MLX
        prompt = _render_session_summary_prompt(qs, prompt_attempts, ctx_docs)
        ai_client = AIClient()
        try:
            async with acquire_mlx_gate(Priority.BACKGROUND):  # 2026-05-17 B-1
                async with asyncio.timeout(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

        # 5. envelope 파싱
        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"
            return

        summary_md = (envelope.get("summary_md") or "").strip()
        confidence_raw = envelope.get("confidence")
        if not summary_md:
            job.error_code = "parse_fail"
            job.error_message = "empty summary_md"
            return

        # 6. 환각 가드 — services/study/session_summary_guard 모듈 import.
        if GUARD_PATTERN.search(summary_md):
            job.error_code = "guard_fail"
            job.error_message = "numeric/range pattern detected in summary_md"
            job.status = "failed"
            job.completed_at = now()
            return

        # 7. 길이 hard cap
        if len(summary_md) > SUMMARY_MAX_CHARS:
            summary_md = summary_md[:SUMMARY_MAX_CHARS].rstrip() + "…"

        # 8. Phase 4-D: confidence calibrate — 자료 부족 토픽 과신 방지.
        # 문서 evidence 0 + ready ai_explanation 0 → 'low' cap.
        # 문서 0 + ready 만 있음 → 'medium' cap.
        # 문서 1+ → 모델 값 그대로.
        ctx_docs_count = len(ctx_docs)
        ready_expl_count = sum(1 for a in prompt_attempts if a.get("ai_explanation"))
        confidence = calibrate_confidence(
            confidence_raw,
            ctx_docs_count=ctx_docs_count,
            ready_explanation_count=ready_expl_count,
        )

        # job.payload 에 evidence count + 모델 raw confidence 보존 (운영 분석)
        job_payload = dict(job.payload or {})
        job_payload.update({
            "ctx_docs_count": ctx_docs_count,
            "ready_explanation_count": ready_expl_count,
            "model_confidence_raw": normalize_confidence(confidence_raw),
            "calibrated_confidence": confidence,
            "prompt_attempts": len(prompt_attempts),
            "valid_attempts_total": len(valid_attempts),
            "summary_len": len(summary_md),
        })
        job.payload = job_payload

        # 9. UPSERT
        ts = now()
        await _upsert_analysis(
            session,
            study_quiz_session_id=qs.id,
            user_id=qs.user_id,
            summary_md=summary_md,
            confidence=confidence,
            model_name=f"mlx:{primary_name}",
            now=ts,
        )

        # 이전 attempt 의 error_code (예: llm_timeout) 가 잔존하면 운영 분석 혼선.
        # completed 박을 때 명시 clear.
        job.error_code = None
        job.error_message = None
        job.status = "completed"
        job.completed_at = ts
        return

    except (asyncio.TimeoutError, httpx.HTTPError) as e:
        job.error_code = "llm_timeout"
        job.error_message = f"{type(e).__name__}: {e}"
        logger.warning(
            "session_analysis_timeout job_id=%s sid=%s: %s",
            job.id, job.study_quiz_session_id, e,
        )
    except (json.JSONDecodeError, ValueError) as e:
        job.error_code = "parse_fail"
        job.error_message = f"{type(e).__name__}: {e}"
        logger.warning(
            "session_analysis_parse_fail job_id=%s sid=%s: %s",
            job.id, job.study_quiz_session_id, e,
        )
    except Exception as e:
        # 예상 못한 예외 — 명시 'unknown' 박아 finally 가 None 을 retryable 로 안 보게.
        job.error_code = "unknown"
        job.error_message = f"{type(e).__name__}: {e}"
        logger.exception(
            "session_analysis_unknown_fail job_id=%s sid=%s",
            job.id, job.study_quiz_session_id,
        )
    finally:
        # 재시도 분기 — guard_fail/evidence_missing/insufficient_attempts 는 위 try 에서 종결.
        # 여기 도달 케이스는 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()
