"""학습 진행 상태 (progress) API — review-complete + review-queue + stats.

review-complete: 사용자가 오답/모르겠음 문제를 검토했음을 표시. due_at 최초 부여.
review-queue: 5 탭 (due_today / pending_review / chronic / regressed / mastered) 으로 progress 조회.
stats (Phase 2-D): 통계 대시보드 — 진척도 / 패턴 분포 / 복습 큐 / 세션 추이 / 일별 풀이량 / 과목별.
"""

from __future__ import annotations

from datetime import date, datetime, timedelta, timezone
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import and_, case, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import Date as SQLDate

from core.auth import get_current_user
from core.database import get_session
from models.study_question import StudyQuestion, StudyQuestionAttempt
from models.study_question_progress import StudyQuestionProgress
from models.study_quiz_session import StudyQuizSession
from models.study_topic import StudyTopic
from models.user import User

router = APIRouter(prefix="/study-topics", tags=["study-progress"])

# 1차 due_at 부여 시 디폴트 1일 뒤 — SR 상수는 sr_schedule.py 단일 source (재-export).
from services.study.sr_schedule import DEFAULT_FIRST_DUE_DAYS  # noqa: E402,F401


def _verify_topic_owner(topic: StudyTopic | None, user: User) -> None:
    if topic is None or topic.deleted_at is not None or topic.user_id != user.id:
        raise HTTPException(status_code=404, detail="주제를 찾을 수 없습니다")


@router.post("/{topic_id}/questions/{question_id}/review-complete", status_code=204)
async def review_complete(
    topic_id: int,
    question_id: int,
    user: Annotated[User, Depends(get_current_user)],
    session: Annotated[AsyncSession, Depends(get_session)],
):
    """확인완료 처리 — last_reviewed_at + (wrong/unsure 인 경우) due_at 최초 부여.

    이미 due_at 박힌 문제면 due_at 그대로 유지 (큐 위치 보존).
    정답 맞춘 문제면 due_at 박지 않음 (큐 폭발 방지).
    """
    topic = await session.get(StudyTopic, topic_id)
    _verify_topic_owner(topic, user)

    q = await session.get(StudyQuestion, question_id)
    if q is None or q.deleted_at is not None or q.user_id != user.id or q.study_topic_id != topic_id:
        raise HTTPException(status_code=404, detail="문제를 찾을 수 없습니다")

    progress = (
        await session.execute(
            select(StudyQuestionProgress).where(
                StudyQuestionProgress.user_id == user.id,
                StudyQuestionProgress.study_topic_id == topic_id,
                StudyQuestionProgress.study_question_id == question_id,
            )
        )
    ).scalar_one_or_none()
    if progress is None:
        # attempt 없는데 review-complete 시도. 진척 상태가 없어 의미 없음.
        raise HTTPException(status_code=409, detail="아직 시도한 적이 없는 문제입니다")

    now = datetime.now(timezone.utc)
    progress.last_reviewed_at = now

    # due_at 최초 부여는 wrong/unsure 일 때만. 이미 박혀있으면 유지.
    if progress.last_outcome in ("wrong", "unsure") and progress.due_at is None:
        progress.review_stage = 0
        progress.due_at = now + timedelta(days=DEFAULT_FIRST_DUE_DAYS)

    await session.commit()


# ─── review-queue ───


class ReviewQueueItem(BaseModel):
    question_id: int
    question_text: str
    subject: str | None
    scope: str | None
    exam_round: str | None
    exam_question_number: int | None
    last_outcome: str | None
    last_attempted_at: datetime | None
    last_reviewed_at: datetime | None
    due_at: datetime | None
    review_stage: int | None
    pattern_state: str | None


class ReviewQueueResponse(BaseModel):
    tab: str
    total: int
    items: list[ReviewQueueItem]
    page: int
    page_size: int
    # Phase 2-F: due_today 탭에서만 채움. due_at < today 0시 (UTC) + stage < 4.
    # UI 가 "정체 N건" 경고 + [정리] 버튼 노출 판단에 사용.
    overdue_count: int = 0


def _truncate(text: str, n: int = 80) -> str:
    if not text:
        return ""
    s = text.strip()
    return s if len(s) <= n else s[:n].rstrip() + "…"


@router.get("/{topic_id}/review-queue", response_model=ReviewQueueResponse)
async def review_queue(
    topic_id: int,
    user: Annotated[User, Depends(get_current_user)],
    session: Annotated[AsyncSession, Depends(get_session)],
    tab: str = Query(..., pattern="^(due_today|pending_review|chronic|regressed|mastered)$"),
    page: int = Query(1, ge=1),
    page_size: int = Query(50, ge=1, le=200),
):
    """5 탭 진행 상태 조회.

    - due_today:       progress.due_at <= now() AND review_stage < 4
    - pending_review:  last_outcome IN (wrong, unsure)
                       AND (last_reviewed_at IS NULL OR last_reviewed_at < last_attempted_at)
    - chronic:         pattern_state = 'chronic_wrong'
    - regressed:       pattern_state = 'regressed'
    - mastered:        review_stage >= 4
    """
    topic = await session.get(StudyTopic, topic_id)
    _verify_topic_owner(topic, user)

    base = (
        select(StudyQuestionProgress, StudyQuestion)
        .join(StudyQuestion, StudyQuestion.id == StudyQuestionProgress.study_question_id)
        .where(
            StudyQuestionProgress.user_id == user.id,
            StudyQuestionProgress.study_topic_id == topic_id,
            StudyQuestion.deleted_at.is_(None),
        )
    )

    now = datetime.now(timezone.utc)
    if tab == "due_today":
        base = base.where(
            StudyQuestionProgress.due_at.is_not(None),
            StudyQuestionProgress.due_at <= now,
            or_(
                StudyQuestionProgress.review_stage.is_(None),
                StudyQuestionProgress.review_stage < 4,
            ),
        ).order_by(StudyQuestionProgress.due_at.asc())
    elif tab == "pending_review":
        base = base.where(
            StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
            or_(
                StudyQuestionProgress.last_reviewed_at.is_(None),
                and_(
                    StudyQuestionProgress.last_reviewed_at.is_not(None),
                    StudyQuestionProgress.last_attempted_at.is_not(None),
                    StudyQuestionProgress.last_reviewed_at
                    < StudyQuestionProgress.last_attempted_at,
                ),
            ),
        ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
    elif tab == "chronic":
        base = base.where(
            StudyQuestionProgress.pattern_state == "chronic_wrong",
        ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
    elif tab == "regressed":
        base = base.where(
            StudyQuestionProgress.pattern_state == "regressed",
        ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())
    elif tab == "mastered":
        base = base.where(
            StudyQuestionProgress.review_stage.is_not(None),
            StudyQuestionProgress.review_stage >= 4,
        ).order_by(StudyQuestionProgress.last_attempted_at.desc().nulls_last())

    # total
    total_row = await session.execute(
        select(func.count()).select_from(base.subquery())
    )
    total = int(total_row.scalar() or 0)

    # paged
    rows = (
        await session.execute(
            base.offset((page - 1) * page_size).limit(page_size)
        )
    ).all()

    items = [
        ReviewQueueItem(
            question_id=q.id,
            question_text=_truncate(q.question_text, 80),
            subject=q.subject,
            scope=q.scope,
            exam_round=q.exam_round,
            exam_question_number=q.exam_question_number,
            last_outcome=p.last_outcome,
            last_attempted_at=p.last_attempted_at,
            last_reviewed_at=p.last_reviewed_at,
            due_at=p.due_at,
            review_stage=p.review_stage,
            pattern_state=p.pattern_state,
        )
        for (p, q) in rows
    ]

    # Phase 2-F: due_today 탭일 때 overdue 카운트 (오늘 0시 UTC 이전 due) — UI 경고 노출용
    overdue_count = 0
    if tab == "due_today":
        today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
        overdue_row = await session.execute(
            select(func.count())
            .select_from(StudyQuestionProgress)
            .where(
                StudyQuestionProgress.user_id == user.id,
                StudyQuestionProgress.study_topic_id == topic_id,
                StudyQuestionProgress.due_at.is_not(None),
                StudyQuestionProgress.due_at < today_start,
                or_(
                    StudyQuestionProgress.review_stage.is_(None),
                    StudyQuestionProgress.review_stage < 4,
                ),
            )
        )
        overdue_count = int(overdue_row.scalar() or 0)

    return ReviewQueueResponse(
        tab=tab, total=total, items=items, page=page, page_size=page_size,
        overdue_count=overdue_count,
    )


# ─── redistribute (Phase 2-F due_at 정체 정리) ───


class RedistributeRequest(BaseModel):
    spread_days: int = 7  # 1~14 일 사이. default 7.


class RedistributeResponse(BaseModel):
    redistributed_count: int
    spread_days: int


@router.post(
    "/{topic_id}/review-queue/redistribute", response_model=RedistributeResponse
)
async def redistribute_overdue(
    topic_id: int,
    body: RedistributeRequest,
    user: Annotated[User, Depends(get_current_user)],
    session: Annotated[AsyncSession, Depends(get_session)],
):
    """overdue (due_at < today 0시 UTC + stage < 4) 를 내일~spread_days 일에 round-robin 분산.

    동작:
      - 오늘 0시 이전에 due 된 항목 모두 fetch (오래된 순)
      - i % spread_days + 1 일 후 자정 + i*7분 (분산용 분단위) 로 due_at 갱신
      - review_stage 는 건드리지 않음 (정체 처리는 시간 재배치만)
    """
    if not (1 <= body.spread_days <= 14):
        raise HTTPException(status_code=400, detail="spread_days 는 1~14 사이여야 합니다")

    topic = await session.get(StudyTopic, topic_id)
    _verify_topic_owner(topic, user)

    now = datetime.now(timezone.utc)
    today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)

    overdue = (
        await session.execute(
            select(StudyQuestionProgress)
            .where(
                StudyQuestionProgress.user_id == user.id,
                StudyQuestionProgress.study_topic_id == topic_id,
                StudyQuestionProgress.due_at.is_not(None),
                StudyQuestionProgress.due_at < today_start,
                or_(
                    StudyQuestionProgress.review_stage.is_(None),
                    StudyQuestionProgress.review_stage < 4,
                ),
            )
            .order_by(StudyQuestionProgress.due_at.asc())
        )
    ).scalars().all()

    if not overdue:
        return RedistributeResponse(redistributed_count=0, spread_days=body.spread_days)

    base_day = today_start  # 오늘 0시 기준 — +1일부터 분산
    for i, p in enumerate(overdue):
        days_offset = (i % body.spread_days) + 1
        # 같은 날 안에서도 분산하려고 i*7분 추가 (200건 까지 24시간 안에 겹침 없이 spread)
        minute_offset = (i * 7) % (24 * 60)
        p.due_at = base_day + timedelta(days=days_offset, minutes=minute_offset)

    await session.commit()
    return RedistributeResponse(
        redistributed_count=len(overdue), spread_days=body.spread_days
    )


# ─── stats (Phase 2-D 통계 대시보드) ───


class StatsQuestions(BaseModel):
    total: int
    attempted: int
    unattempted: int


class StatsDue(BaseModel):
    today: int
    this_week: int
    later: int
    mastered: int


class StatsSessionTrendItem(BaseModel):
    id: int
    finished_at: datetime
    total: int
    correct_count: int
    wrong_count: int
    unsure_count: int
    accuracy: int  # 0~100
    newly_correct_count: int
    relapsed_count: int
    recovered_count: int


class StatsDailyAttempt(BaseModel):
    date: date
    count: int


class StatsSubjectBreakdown(BaseModel):
    subject: str
    total: int
    attempted: int
    last_correct: int
    accuracy: int  # 0~100
    pending_review: int
    chronic: int


class StatsAiExplanation(BaseModel):
    """Phase 4-A 운영 관찰 — AI 풀이 캐시 진척 + 최근 7일 worker 결과."""
    # study_questions.ai_explanation_status 분포 (이 토픽 전체)
    status_distribution: dict  # 'none' / 'ready' / 'failed' / 'skipped' / 'stale' / 'pending'
    # wrong/unsure 중 ready 박힌 비율 (캐시 hit 가능성 추정)
    target_total: int        # progress.last_outcome IN (wrong, unsure) 의 qid 수
    target_ready: int        # 그 중 ai_explanation_status='ready' 인 수
    # 최근 7일 study_question_jobs 의 (status, error_code) 분포
    recent_jobs: dict  # {'completed': N, 'failed:guard_fail': N, 'failed:parse_fail': N, 'skipped:evidence_missing': N, 'pending': N, ...}


class StatsResponse(BaseModel):
    questions: StatsQuestions
    pattern_distribution: dict  # state(or "unattempted") → count
    review_stage_distribution: dict  # "0"/"1"/"2"/"3"/"mastered" → count
    due: StatsDue
    session_trend: list[StatsSessionTrendItem]  # 최근 done 세션 newest→oldest
    daily_attempts_30d: list[StatsDailyAttempt]
    subject_breakdown: list[StatsSubjectBreakdown]
    ai_explanation: StatsAiExplanation


@router.get("/{topic_id}/stats", response_model=StatsResponse)
async def topic_stats(
    topic_id: int,
    user: Annotated[User, Depends(get_current_user)],
    session: Annotated[AsyncSession, Depends(get_session)],
    session_trend_limit: int = Query(20, ge=1, le=100),
):
    """통계 대시보드 — progress + quiz_sessions + attempts 한 번에 집계.

    가벼운 쿼리 6~7 묶음. 1인 운영 + 토픽당 progress 수천 행 가정 — 추가 인덱스 없이 OK.
    """
    topic = await session.get(StudyTopic, topic_id)
    _verify_topic_owner(topic, user)

    now = datetime.now(timezone.utc)

    # 1. 문제 진척도 — 토픽의 question 총수 + progress 행 수 (attempted)
    total_q_row = await session.execute(
        select(func.count())
        .select_from(StudyQuestion)
        .where(
            StudyQuestion.user_id == user.id,
            StudyQuestion.study_topic_id == topic_id,
            StudyQuestion.deleted_at.is_(None),
        )
    )
    total_q = int(total_q_row.scalar() or 0)

    attempted_row = await session.execute(
        select(func.count())
        .select_from(StudyQuestionProgress)
        .where(
            StudyQuestionProgress.user_id == user.id,
            StudyQuestionProgress.study_topic_id == topic_id,
            StudyQuestionProgress.last_outcome.is_not(None),
        )
    )
    attempted = int(attempted_row.scalar() or 0)
    unattempted = max(0, total_q - attempted)

    # 2. pattern_state 분포 (NULL 은 "unattempted" 로)
    pattern_rows = (
        await session.execute(
            select(
                func.coalesce(StudyQuestionProgress.pattern_state, "unattempted").label("state"),
                func.count().label("cnt"),
            )
            .where(
                StudyQuestionProgress.user_id == user.id,
                StudyQuestionProgress.study_topic_id == topic_id,
            )
            .group_by("state")
        )
    ).all()
    pattern_distribution = {r.state: int(r.cnt) for r in pattern_rows}
    # 모든 키 default 0 채우기 (UI 가 빈 키 처리 안 해도 되게)
    for k in ("stable", "unstable", "unsure", "regressed", "recovered", "chronic_wrong", "unattempted"):
        pattern_distribution.setdefault(k, 0)
    # 한 번도 시도 안 한 (progress 행 자체 없음) 분량을 unattempted 에 합산
    pattern_distribution["unattempted"] += unattempted

    # 3. review_stage 분포 — 0/1/2/3/mastered (>=4)
    stage_rows = (
        await session.execute(
            select(
                StudyQuestionProgress.review_stage.label("stage"),
                func.count().label("cnt"),
            )
            .where(
                StudyQuestionProgress.user_id == user.id,
                StudyQuestionProgress.study_topic_id == topic_id,
                StudyQuestionProgress.review_stage.is_not(None),
            )
            .group_by(StudyQuestionProgress.review_stage)
        )
    ).all()
    review_stage_distribution = {"0": 0, "1": 0, "2": 0, "3": 0, "mastered": 0}
    for r in stage_rows:
        st = int(r.stage)
        if st >= 4:
            review_stage_distribution["mastered"] += int(r.cnt)
        elif 0 <= st <= 3:
            review_stage_distribution[str(st)] += int(r.cnt)

    # 4. due 분류 — today / this_week / later / mastered
    end_today = now.replace(hour=23, minute=59, second=59, microsecond=999999)
    end_week = end_today + timedelta(days=7)
    due_rows = (
        await session.execute(
            select(
                func.count().filter(
                    and_(
                        StudyQuestionProgress.due_at.is_not(None),
                        StudyQuestionProgress.due_at <= end_today,
                        or_(
                            StudyQuestionProgress.review_stage.is_(None),
                            StudyQuestionProgress.review_stage < 4,
                        ),
                    )
                ).label("today"),
                func.count().filter(
                    and_(
                        StudyQuestionProgress.due_at.is_not(None),
                        StudyQuestionProgress.due_at > end_today,
                        StudyQuestionProgress.due_at <= end_week,
                        or_(
                            StudyQuestionProgress.review_stage.is_(None),
                            StudyQuestionProgress.review_stage < 4,
                        ),
                    )
                ).label("this_week"),
                func.count().filter(
                    and_(
                        StudyQuestionProgress.due_at.is_not(None),
                        StudyQuestionProgress.due_at > end_week,
                        or_(
                            StudyQuestionProgress.review_stage.is_(None),
                            StudyQuestionProgress.review_stage < 4,
                        ),
                    )
                ).label("later"),
                func.count().filter(
                    and_(
                        StudyQuestionProgress.review_stage.is_not(None),
                        StudyQuestionProgress.review_stage >= 4,
                    )
                ).label("mastered"),
            )
            .where(
                StudyQuestionProgress.user_id == user.id,
                StudyQuestionProgress.study_topic_id == topic_id,
            )
        )
    ).first()
    due = StatsDue(
        today=int(due_rows.today or 0),
        this_week=int(due_rows.this_week or 0),
        later=int(due_rows.later or 0),
        mastered=int(due_rows.mastered or 0),
    )

    # 5. 최근 done 세션 추이 (Phase 2-B 4 컬럼 활용)
    sess_rows = (
        await session.execute(
            select(StudyQuizSession)
            .where(
                StudyQuizSession.user_id == user.id,
                StudyQuizSession.study_topic_id == topic_id,
                StudyQuizSession.status == "done",
                StudyQuizSession.finished_at.is_not(None),
            )
            .order_by(StudyQuizSession.finished_at.desc())
            .limit(session_trend_limit)
        )
    ).scalars().all()
    session_trend: list[StatsSessionTrendItem] = []
    for s in sess_rows:
        total_n = len(s.question_ids or [])
        acc = round((s.correct_count / total_n) * 100) if total_n > 0 else 0
        session_trend.append(StatsSessionTrendItem(
            id=s.id,
            finished_at=s.finished_at,
            total=total_n,
            correct_count=s.correct_count,
            wrong_count=s.wrong_count,
            unsure_count=s.unsure_count,
            accuracy=acc,
            newly_correct_count=s.newly_correct_count,
            relapsed_count=s.relapsed_count,
            recovered_count=s.recovered_count,
        ))

    # 6. 일별 풀이량 30일 (date 기준 — UTC, 시간대 차이는 Phase 5 후보)
    start_30d = (now - timedelta(days=29)).replace(hour=0, minute=0, second=0, microsecond=0)
    daily_rows = (
        await session.execute(
            select(
                cast(StudyQuestionAttempt.answered_at, SQLDate).label("d"),
                func.count().label("cnt"),
            )
            .where(
                StudyQuestionAttempt.user_id == user.id,
                StudyQuestionAttempt.study_topic_id == topic_id,
                StudyQuestionAttempt.answered_at >= start_30d,
            )
            .group_by("d")
            .order_by("d")
        )
    ).all()
    daily_attempts_30d = [StatsDailyAttempt(date=r.d, count=int(r.cnt)) for r in daily_rows]

    # 7. 과목별 약점
    subj_rows = (
        await session.execute(
            select(
                func.coalesce(StudyQuestion.subject, "(미분류)").label("subject"),
                func.count(StudyQuestion.id.distinct()).label("total"),
                func.count(StudyQuestionProgress.id.distinct()).filter(
                    StudyQuestionProgress.last_outcome.is_not(None)
                ).label("attempted"),
                func.count(StudyQuestionProgress.id.distinct()).filter(
                    StudyQuestionProgress.last_outcome == "correct"
                ).label("last_correct"),
                func.count(StudyQuestionProgress.id.distinct()).filter(
                    and_(
                        StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
                        or_(
                            StudyQuestionProgress.last_reviewed_at.is_(None),
                            and_(
                                StudyQuestionProgress.last_reviewed_at.is_not(None),
                                StudyQuestionProgress.last_attempted_at.is_not(None),
                                StudyQuestionProgress.last_reviewed_at
                                < StudyQuestionProgress.last_attempted_at,
                            ),
                        ),
                    )
                ).label("pending_review"),
                func.count(StudyQuestionProgress.id.distinct()).filter(
                    StudyQuestionProgress.pattern_state == "chronic_wrong"
                ).label("chronic"),
            )
            .select_from(StudyQuestion)
            .outerjoin(
                StudyQuestionProgress,
                and_(
                    StudyQuestionProgress.user_id == StudyQuestion.user_id,
                    StudyQuestionProgress.study_topic_id == StudyQuestion.study_topic_id,
                    StudyQuestionProgress.study_question_id == StudyQuestion.id,
                ),
            )
            .where(
                StudyQuestion.user_id == user.id,
                StudyQuestion.study_topic_id == topic_id,
                StudyQuestion.deleted_at.is_(None),
            )
            .group_by("subject")
            .order_by(func.count(StudyQuestion.id.distinct()).desc())
        )
    ).all()
    subject_breakdown = [
        StatsSubjectBreakdown(
            subject=r.subject,
            total=int(r.total),
            attempted=int(r.attempted),
            last_correct=int(r.last_correct),
            accuracy=round((int(r.last_correct) / int(r.attempted)) * 100) if int(r.attempted) > 0 else 0,
            pending_review=int(r.pending_review),
            chronic=int(r.chronic),
        )
        for r in subj_rows
    ]

    # 8. Phase 4-A: AI 풀이 캐시 진척 + 최근 7일 worker 결과
    # 8a. study_questions.ai_explanation_status 분포 (토픽 전체)
    ai_status_rows = (
        await session.execute(
            select(
                func.coalesce(StudyQuestion.ai_explanation_status, "none").label("st"),
                func.count().label("cnt"),
            )
            .where(
                StudyQuestion.user_id == user.id,
                StudyQuestion.study_topic_id == topic_id,
                StudyQuestion.deleted_at.is_(None),
            )
            .group_by("st")
        )
    ).all()
    ai_status_distribution = {r.st: int(r.cnt) for r in ai_status_rows}
    for k in ("none", "ready", "failed", "skipped", "stale", "pending"):
        ai_status_distribution.setdefault(k, 0)

    # 8b. wrong/unsure 의 ready 비율 (캐시 hit 가능성)
    target_total_row = await session.execute(
        select(func.count())
        .select_from(StudyQuestionProgress)
        .where(
            StudyQuestionProgress.user_id == user.id,
            StudyQuestionProgress.study_topic_id == topic_id,
            StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
        )
    )
    target_total = int(target_total_row.scalar() or 0)

    target_ready_row = await session.execute(
        select(func.count())
        .select_from(StudyQuestionProgress)
        .join(
            StudyQuestion,
            and_(
                StudyQuestion.id == StudyQuestionProgress.study_question_id,
                StudyQuestion.deleted_at.is_(None),
            ),
        )
        .where(
            StudyQuestionProgress.user_id == user.id,
            StudyQuestionProgress.study_topic_id == topic_id,
            StudyQuestionProgress.last_outcome.in_(("wrong", "unsure")),
            StudyQuestion.ai_explanation_status == "ready",
        )
    )
    target_ready = int(target_ready_row.scalar() or 0)

    # 8c. 최근 7일 study_question_jobs 분포 — terminal status × error_code
    from models.study_question_job import StudyQuestionJob

    recent_cutoff = now - timedelta(days=7)
    job_rows = (
        await session.execute(
            select(
                StudyQuestionJob.status.label("st"),
                func.coalesce(StudyQuestionJob.error_code, "").label("err"),
                func.count().label("cnt"),
            )
            .join(
                StudyQuestion,
                and_(
                    StudyQuestion.id == StudyQuestionJob.study_question_id,
                    StudyQuestion.study_topic_id == topic_id,
                    StudyQuestion.user_id == user.id,
                ),
            )
            .where(
                StudyQuestionJob.user_id == user.id,
                StudyQuestionJob.created_at >= recent_cutoff,
            )
            .group_by("st", "err")
        )
    ).all()
    recent_jobs: dict[str, int] = {}
    for r in job_rows:
        key = f"{r.st}:{r.err}" if r.err else r.st
        recent_jobs[key] = int(r.cnt)

    return StatsResponse(
        questions=StatsQuestions(
            total=total_q, attempted=attempted, unattempted=unattempted
        ),
        pattern_distribution=pattern_distribution,
        review_stage_distribution=review_stage_distribution,
        due=due,
        session_trend=session_trend,
        daily_attempts_30d=daily_attempts_30d,
        subject_breakdown=subject_breakdown,
        ai_explanation=StatsAiExplanation(
            status_distribution=ai_status_distribution,
            target_total=target_total,
            target_ready=target_ready,
            recent_jobs=recent_jobs,
        ),
    )
