"""
Course exam engine (Part 5): timer, submit/scoring, soft anti-cheat events.

Webcam snapshot monitoring is intentionally stubbed — needs consent + storage decision.
"""
from __future__ import annotations

from decimal import Decimal

from django.db import transaction
from django.db.models import F
from django.utils import timezone
from django.utils.text import capfirst

from apps.courses.exam_generation import (
    InsufficientQuestionBank,
    start_course_exam_attempt,
)
from apps.courses.models import (
    ATTEMPT_EXPIRED,
    ATTEMPT_STARTED,
    ATTEMPT_SUBMITTED,
    AttemptEvent,
    AttemptQuestion,
    Course,
    CourseExamAttempt,
    EVENT_AUTO_SUBMIT,
    EVENT_MANUAL_SUBMIT,
    EVENT_WEBCAM_STUB,
    OPTION_A,
    OPTION_B,
    OPTION_C,
    OPTION_D,
    TAB_SWITCH_EVENT_TYPES,
    TAB_SWITCH_FLAG_THRESHOLD,
)

VALID_DISPLAY_OPTIONS = {OPTION_A, OPTION_B, OPTION_C, OPTION_D}

# Soft anti-cheat: never auto-fail from lockdown events alone.
# TODO(later): enable webcam snapshot monitoring after consent flow + storage-cost decision.
WEBCAM_MONITORING_ENABLED = False


class ExamEngineError(Exception):
    """User-facing exam flow error."""


def get_or_start_attempt(user, course: Course) -> CourseExamAttempt:
    """Resume an open attempt or draw a new one from the bank."""
    open_attempt = (
        CourseExamAttempt.objects.filter(
            user=user,
            course=course,
            status=ATTEMPT_STARTED,
            submitted_at__isnull=True,
        )
        .order_by('-started_at')
        .first()
    )
    if open_attempt:
        if open_attempt.timer_started_at and open_attempt.time_expired():
            submit_course_exam_attempt(open_attempt, {}, auto_submitted=True)
            # Fall through to start a fresh attempt after expiry auto-submit
        else:
            return open_attempt
    try:
        return start_course_exam_attempt(user, course)
    except InsufficientQuestionBank as exc:
        raise ExamEngineError(str(exc)) from exc


def begin_timed_exam(attempt: CourseExamAttempt) -> CourseExamAttempt:
    if attempt.submitted_at or attempt.status != ATTEMPT_STARTED:
        raise ExamEngineError('This attempt is already closed.')
    if attempt.timer_started_at:
        return attempt
    attempt.timer_started_at = timezone.now()
    attempt.save(update_fields=['timer_started_at'])
    return attempt


def _topic_label(tag: str) -> str:
    tag = (tag or 'general').strip() or 'general'
    return capfirst(tag.replace('-', ' ').replace('_', ' '))


def compute_topic_breakdown(attempt: CourseExamAttempt) -> list[dict]:
    rows = attempt.attempt_questions.select_related('question').all()
    buckets: dict[str, dict] = {}
    for aq in rows:
        tag = (aq.question.topic_tag or 'general').strip().lower() or 'general'
        if tag not in buckets:
            buckets[tag] = {
                'topic_tag': tag,
                'label': _topic_label(tag),
                'correct': 0,
                'total': 0,
                'percent': 0.0,
            }
        buckets[tag]['total'] += 1
        if aq.is_correct:
            buckets[tag]['correct'] += 1
    out = []
    for tag in sorted(buckets.keys()):
        b = buckets[tag]
        if b['total']:
            b['percent'] = round(100.0 * b['correct'] / b['total'], 1)
        out.append(b)
    return out


@transaction.atomic
def apply_answers(attempt: CourseExamAttempt, answers_map: dict) -> None:
    """
    Persist selected_display_option from {attempt_question_id: 'A'|'B'|...}
    or {str(id): letter}. Does not score until submit.
    """
    if not answers_map:
        return
    qs = {
        aq.pk: aq
        for aq in AttemptQuestion.objects.select_for_update().filter(attempt=attempt)
    }
    for key, raw in answers_map.items():
        try:
            aq_id = int(key)
        except (TypeError, ValueError):
            continue
        letter = str(raw or '').strip().upper()
        if letter not in VALID_DISPLAY_OPTIONS:
            continue
        aq = qs.get(aq_id)
        if aq:
            aq.selected_display_option = letter
            aq.save(update_fields=['selected_display_option'])


@transaction.atomic
def submit_course_exam_attempt(
    attempt: CourseExamAttempt,
    answers_map: dict | None = None,
    *,
    auto_submitted: bool = False,
) -> CourseExamAttempt:
    """Score answers, store topic breakdown, close the attempt."""
    attempt = CourseExamAttempt.objects.select_for_update().select_related('course').get(
        pk=attempt.pk,
    )
    if attempt.submitted_at:
        return attempt

    if answers_map:
        apply_answers(attempt, answers_map)

    rows = list(attempt.attempt_questions.select_related('question').all())
    correct = 0
    for aq in rows:
        is_ok = (
            bool(aq.selected_display_option)
            and aq.selected_display_option == aq.correct_display_option
        )
        aq.is_correct = is_ok
        if is_ok:
            correct += 1
    AttemptQuestion.objects.bulk_update(rows, ['is_correct'])

    total = len(rows)
    percent = Decimal('0.00') if total == 0 else (
        Decimal(correct * 100) / Decimal(total)
    ).quantize(Decimal('0.01'))
    pass_mark = int(attempt.course.pass_percentage or 70)
    passed = float(percent) >= pass_mark

    now = timezone.now()
    time_taken = None
    if attempt.timer_started_at:
        time_taken = max(0, int((now - attempt.timer_started_at).total_seconds()))

    attempt.correct_count = correct
    attempt.total_asked = total
    attempt.score_percent = percent
    attempt.passed = passed
    attempt.submitted_at = now
    attempt.time_taken_seconds = time_taken
    attempt.auto_submitted = auto_submitted
    attempt.status = ATTEMPT_EXPIRED if auto_submitted else ATTEMPT_SUBMITTED
    attempt.topic_breakdown = compute_topic_breakdown(attempt)
    attempt.save()

    AttemptEvent.objects.create(
        attempt=attempt,
        event_type=EVENT_AUTO_SUBMIT if auto_submitted else EVENT_MANUAL_SUBMIT,
        detail='Timer expired' if auto_submitted else 'Candidate submitted',
    )

    if passed:
        attempt_pk = attempt.pk

        def _issue_cert():
            from apps.courses.certificate_service import enqueue_certificate_for_attempt
            enqueue_certificate_for_attempt(attempt_pk)

        transaction.on_commit(_issue_cert)

    return attempt


@transaction.atomic
def log_attempt_event(
    attempt: CourseExamAttempt,
    event_type: str,
    *,
    detail: str = '',
    metadata: dict | None = None,
) -> AttemptEvent:
    """
    Record an anti-cheat event. Tab/window leaves increment tab_switch_count;
    at TAB_SWITCH_FLAG_THRESHOLD the attempt is flagged for review (not failed).
    """
    attempt = CourseExamAttempt.objects.select_for_update().get(pk=attempt.pk)
    if attempt.submitted_at:
        # Still allow logging late events for audit, but do not mutate flag counters
        return AttemptEvent.objects.create(
            attempt=attempt,
            event_type=event_type,
            detail=(detail or '')[:255],
            metadata=metadata or {},
        )

    event = AttemptEvent.objects.create(
        attempt=attempt,
        event_type=event_type,
        detail=(detail or '')[:255],
        metadata=metadata or {},
    )

    if event_type in TAB_SWITCH_EVENT_TYPES and attempt.timer_started_at:
        CourseExamAttempt.objects.filter(pk=attempt.pk).update(
            tab_switch_count=F('tab_switch_count') + 1,
        )
        attempt.refresh_from_db(fields=['tab_switch_count', 'is_flagged_for_review', 'flag_reason'])
        if (
            attempt.tab_switch_count >= TAB_SWITCH_FLAG_THRESHOLD
            and not attempt.is_flagged_for_review
        ):
            reason = (
                f'{attempt.tab_switch_count} tab/window switches '
                f'(threshold {TAB_SWITCH_FLAG_THRESHOLD}) — manual review'
            )
            attempt.is_flagged_for_review = True
            attempt.flag_reason = reason[:255]
            attempt.save(update_fields=['is_flagged_for_review', 'flag_reason'])

    return event


def maybe_log_webcam_stub(attempt: CourseExamAttempt) -> None:
    """
    Placeholder for future webcam snapshot monitoring.
    Does nothing unless WEBCAM_MONITORING_ENABLED is flipped on after product decisions.
    """
    if not WEBCAM_MONITORING_ENABLED:
        return
    # TODO: capture frame, store blob, attach AttemptEvent(EVENT_WEBCAM_STUB, ...)
    log_attempt_event(
        attempt,
        EVENT_WEBCAM_STUB,
        detail='Stub — webcam monitoring not implemented',
    )


def format_time_taken(seconds: int | None) -> str:
    if seconds is None:
        return '—'
    m, s = divmod(int(seconds), 60)
    h, m = divmod(m, 60)
    if h:
        return f'{h}h {m:02d}m {s:02d}s'
    return f'{m}m {s:02d}s'
