"""study_questions 자동 임베딩 워커 (PR-4).

별도 큐 테이블 없이 `embedding_status` 자체가 큐 역할:
  status in {none, failed, stale} → cron 처리 대상
  status='pending' → race-safe 마킹 (조건부 UPDATE)
  status='ready' → embedding 완료
  status='failed' → 다음 cron 틱에 재시도

호출:
  - main.py lifespan APScheduler 가 1분 간격 cron 으로 run() 진입
  - 새 문제 입력은 default 'none' → 다음 틱에 자동 처리 (zero-config)
  - PATCH question_text/choice_* 로 'stale' 전이 → 다음 틱에 재계산
  - backfill 별도 명령 없음 — cron 이 'none' 행을 자동 backfill

임베딩 본문 = `문제: <question_text>\n보기:\n1. <c1>\n2. <c2>\n3. <c3>\n4. <c4>`
subject/scope 는 의도적으로 제외 (분류명이 의미 검색에 노이즈).

bge-m3 1024차원, vector_cosine_ops 인덱스 (HNSW partial).
"""

from __future__ import annotations

import asyncio
import logging
from datetime import datetime, timezone

from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession

from ai.client import AIClient
from core.database import async_session
from models.study_question import StudyQuestion
from services.study.related_types import (
    THRESHOLD_VERSION,
    classify_related_for_question,
    serialize_candidates,
)
# 별도 process 진입점(예: docker exec python -c) 에서 워커 단독 실행 시 mapper
# 초기화 실패 방지를 위해 relationship + ForeignKey chain 의 모든 참조 모델 import.
# 운영 path(main lifespan) 는 라우터 import 로 자동 등록되지만 defensive.
#   StudyQuestion.topic → "StudyTopic"
#   StudyTopic.sessions → "StudySession"
#   study_topics.user_id / study_questions.user_id / study_session.user_id → users → User
#   study_topic_documents.document_id / study_session_assets.document_id → documents → Document
from models.user import User  # noqa: F401
from models.document import Document  # noqa: F401
from models.study_topic import StudyTopic  # noqa: F401
from models.study_session import StudySession  # noqa: F401
from models.study_question_progress import StudyQuestionProgress  # noqa: F401

logger = logging.getLogger("study_question_embed_worker")

EMBED_MODEL = "bge-m3"
BATCH_SIZE = 10  # 한 cron 틱에 처리할 최대 행 수
EMBED_TIMEOUT_S = 15.0  # bge-m3 단일 호출 timeout (Ollama, 보통 < 1s)


def _build_embed_text(q: StudyQuestion) -> str:
    """문제 본문 + 보기 1~4. subject/scope 제외 (분류명 노이즈 방지)."""
    return (
        f"문제: {q.question_text}\n"
        f"보기:\n"
        f"1. {q.choice_1}\n"
        f"2. {q.choice_2}\n"
        f"3. {q.choice_3}\n"
        f"4. {q.choice_4}"
    )


async def _claim_question(session: AsyncSession, qid: int) -> bool:
    """status 를 'pending' 으로 race-safe 마킹. 이미 pending 이면 False (다른 cron 인스턴스가 잡았음).
    none/failed/stale 상태에서만 lock 획득.
    """
    result = await session.execute(
        update(StudyQuestion)
        .where(
            StudyQuestion.id == qid,
            StudyQuestion.embedding_status.in_(("none", "failed", "stale")),
        )
        .values(embedding_status="pending", updated_at=datetime.now(timezone.utc))
        .returning(StudyQuestion.id)
    )
    return result.scalar_one_or_none() is not None


async def _process_one(session: AsyncSession, qid: int, client: AIClient) -> bool:
    """단일 question 임베딩. 성공 True, 실패 False."""
    if not await _claim_question(session, qid):
        # 다른 인스턴스가 이미 잡음 — 스킵
        return False
    await session.commit()

    q = await session.get(StudyQuestion, qid)
    if q is None or q.deleted_at is not None:
        # 삭제됨 — pending 그대로 두지 말고 failed 로 (다음 cron 에서 다시 안 잡힘은 아님 — 어쨌든 정리)
        if q is not None:
            q.embedding_status = "failed"
            await session.commit()
        return False

    text = _build_embed_text(q)
    try:
        async with asyncio.timeout(EMBED_TIMEOUT_S):
            vec = await client.embed(text)
    except (asyncio.TimeoutError, Exception) as e:
        logger.warning("study_q_embed_failed qid=%s err=%s: %s", qid, type(e).__name__, e)
        # 실패 — status='failed'. 직전 embedding 보존.
        q.embedding_status = "failed"
        q.updated_at = datetime.now(timezone.utc)
        await session.commit()
        return False

    # 성공
    q.embedding = vec
    q.embedding_status = "ready"
    q.embedding_model = EMBED_MODEL
    q.embedding_updated_at = datetime.now(timezone.utc)
    q.updated_at = q.embedding_updated_at
    await session.commit()
    logger.info("study_q_embed_ok qid=%s len=%d", qid, len(vec) if vec else 0)

    # 임베딩 ready 직후 related-types 캐시 채우고 같은 토픽 다른 문제 invalidate.
    # 실패해도 임베딩 자체는 성공이므로 ok 반환 (다음 cron 틱에서 _refresh_related_for 가 처리).
    try:
        await _fill_related_cache(session, qid)
        await _invalidate_topic_related(session, q.study_topic_id, exclude_qid=qid)
        await session.commit()
    except Exception as e:
        logger.warning("study_q_related_fill_failed qid=%s err=%s: %s", qid, type(e).__name__, e)
        await session.rollback()
    return True


async def _fill_related_cache(session: AsyncSession, qid: int) -> None:
    """단일 문제의 related 결과를 계산해서 study_questions 캐시 컬럼 7개에 저장."""
    q = await session.get(StudyQuestion, qid)
    if q is None or q.deleted_at is not None or q.embedding_status != "ready":
        return
    src_round = (q.exam_round or "").strip()
    if not src_round:
        # 회차 미지정 → 빈 캐시. 다음 회차 입력 시 stale 마킹 후 재계산.
        q.related_repeat = []
        q.related_similar = []
        q.related_repeat_round_count = 0
        q.related_similar_round_count = 0
        q.related_repeat_grade = None
        q.related_computed_at = datetime.now(timezone.utc)
        q.related_threshold_version = THRESHOLD_VERSION
        return
    cls = await classify_related_for_question(session, user_id=q.user_id, source=q)
    q.related_repeat = serialize_candidates(cls.repeat)
    q.related_similar = serialize_candidates(cls.similar)
    q.related_repeat_round_count = cls.repeat_round_count
    q.related_similar_round_count = cls.similar_round_count
    q.related_repeat_grade = cls.repeat_grade
    q.related_computed_at = datetime.now(timezone.utc)
    q.related_threshold_version = THRESHOLD_VERSION


async def _invalidate_topic_related(
    session: AsyncSession, study_topic_id: int, *, exclude_qid: int
) -> None:
    """같은 토픽의 다른 ready 문제들의 related 캐시를 stale 마킹."""
    await session.execute(
        update(StudyQuestion)
        .where(
            StudyQuestion.study_topic_id == study_topic_id,
            StudyQuestion.id != exclude_qid,
            StudyQuestion.embedding_status == "ready",
            StudyQuestion.deleted_at.is_(None),
            StudyQuestion.related_computed_at.is_not(None),  # 이미 stale 인 행 재마킹 안 함
        )
        .values(related_computed_at=None)
    )


async def run() -> None:
    """APScheduler cron 진입점. status in {none, failed, stale} 행을 BATCH_SIZE 만큼 처리."""
    async with async_session() as session:
        rows = (
            await session.execute(
                select(StudyQuestion.id)
                .where(
                    StudyQuestion.deleted_at.is_(None),
                    StudyQuestion.embedding_status.in_(("none", "failed", "stale")),
                )
                .order_by(StudyQuestion.updated_at.asc())
                .limit(BATCH_SIZE)
            )
        ).scalars().all()

        if not rows:
            return

        logger.info("study_q_embed_run candidates=%d", len(rows))
        client = AIClient()
        try:
            ok_count = 0
            for qid in rows:
                if await _process_one(session, qid, client):
                    ok_count += 1
            logger.info("study_q_embed_run done ok=%d/%d", ok_count, len(rows))
        finally:
            await client.close()


# ─── related cache 재계산 워커 (PR-12-A 후속) ───
RELATED_REFRESH_BATCH = 20  # 한 cron 틱에 채울 stale 행 수

async def refresh_stale_related() -> None:
    """related_computed_at IS NULL 인 ready 행을 재계산. 임베딩 워커와 분리한 별도 cron.
    embedding 자체는 안 만지고 related 캐시만 채움 — 외부 호출 없으므로 빠름 (HNSW 검색만)."""
    async with async_session() as session:
        rows = (
            await session.execute(
                select(StudyQuestion.id)
                .where(
                    StudyQuestion.deleted_at.is_(None),
                    StudyQuestion.embedding_status == "ready",
                    StudyQuestion.related_computed_at.is_(None),
                )
                .order_by(StudyQuestion.updated_at.asc())
                .limit(RELATED_REFRESH_BATCH)
            )
        ).scalars().all()
        if not rows:
            return
        logger.info("study_q_related_refresh candidates=%d", len(rows))
        ok = 0
        for qid in rows:
            try:
                await _fill_related_cache(session, qid)
                await session.commit()
                ok += 1
            except Exception as e:
                logger.warning("study_q_related_refresh_failed qid=%s err=%s: %s", qid, type(e).__name__, e)
                await session.rollback()
        logger.info("study_q_related_refresh done ok=%d/%d", ok, len(rows))
