"""
Exam question selection for course tracks (Part 4).

- Draw N questions (default course.question_count_per_attempt = 100)
- Only is_active + is_verified
- Balance across topic_tags proportionally
- Soft difficulty target ~40/40/20 when enough stock
- Cap overlap with prior attempts for same user+course at ~20%
- Shuffle question order and option order; persist AttemptQuestion rows
"""
from __future__ import annotations

import math
import random
from collections import defaultdict

from django.db import transaction

from apps.courses.models import (
    ATTEMPT_STARTED,
    AttemptQuestion,
    Course,
    CourseExamAttempt,
    CourseQuestion,
    DIFFICULTY_EASY,
    DIFFICULTY_HARD,
    DIFFICULTY_MEDIUM,
    OPTION_A,
    OPTION_B,
    OPTION_C,
    OPTION_D,
)


DISPLAY_LETTERS = [OPTION_A, OPTION_B, OPTION_C, OPTION_D]
ORIGINAL_LETTERS = [OPTION_A, OPTION_B, OPTION_C, OPTION_D]
DIFFICULTY_TARGETS = {
    DIFFICULTY_EASY: 0.40,
    DIFFICULTY_MEDIUM: 0.40,
    DIFFICULTY_HARD: 0.20,
}
MAX_OVERLAP_RATIO = 0.20


class InsufficientQuestionBank(Exception):
    """Raised when the eligible bank cannot fill an attempt."""


def eligible_questions_qs(course: Course):
    return CourseQuestion.objects.filter(
        course=course,
        is_active=True,
        is_verified=True,
    )


def prior_question_ids(user, course) -> set[int]:
    return set(
        AttemptQuestion.objects.filter(
            attempt__user=user,
            attempt__course=course,
        ).values_list('question_id', flat=True)
    )


def _allocate_by_topic(counts: dict[str, int], total: int) -> dict[str, int]:
    """Proportional seats per topic; largest remainder for rounding."""
    if not counts or total <= 0:
        return {}
    grand = sum(counts.values())
    if grand <= 0:
        return {}
    raw = {tag: (counts[tag] / grand) * total for tag in counts}
    base = {tag: int(math.floor(v)) for tag, v in raw.items()}
    rem = total - sum(base.values())
    order = sorted(raw.keys(), key=lambda t: (raw[t] - base[t]), reverse=True)
    for i in range(rem):
        base[order[i % len(order)]] += 1
    return base


def _pick_from_pool(
    pool: list[CourseQuestion],
    n: int,
    avoid: set[int],
    rng: random.Random,
) -> list[CourseQuestion]:
    if n <= 0:
        return []
    preferred = [q for q in pool if q.pk not in avoid]
    fallback = [q for q in pool if q.pk in avoid]
    rng.shuffle(preferred)
    rng.shuffle(fallback)
    return (preferred + fallback)[:n]


def _pick_with_difficulty(
    pool: list[CourseQuestion],
    n: int,
    avoid: set[int],
    rng: random.Random,
) -> list[CourseQuestion]:
    """Prefer ~40/40/20 within a topic pool; fill remaining from leftovers."""
    if n <= 0 or not pool:
        return []
    by_diff: dict[str, list[CourseQuestion]] = defaultdict(list)
    for q in pool:
        by_diff[q.difficulty].append(q)

    targets = {d: int(round(n * ratio)) for d, ratio in DIFFICULTY_TARGETS.items()}
    while sum(targets.values()) > n:
        for d in (DIFFICULTY_HARD, DIFFICULTY_MEDIUM, DIFFICULTY_EASY):
            if targets[d] > 0 and sum(targets.values()) > n:
                targets[d] -= 1
    while sum(targets.values()) < n:
        targets[DIFFICULTY_MEDIUM] += 1

    picked: list[CourseQuestion] = []
    used_ids: set[int] = set()
    for diff, want in targets.items():
        chunk = _pick_from_pool(by_diff.get(diff, []), want, avoid, rng)
        for q in chunk:
            picked.append(q)
            used_ids.add(q.pk)

    if len(picked) < n:
        rest = [q for q in pool if q.pk not in used_ids]
        picked.extend(_pick_from_pool(rest, n - len(picked), avoid, rng))
    return picked[:n]


def select_questions_for_attempt(
    course: Course,
    user,
    count: int | None = None,
    *,
    rng: random.Random | None = None,
) -> list[CourseQuestion]:
    """Return CourseQuestion instances for a new attempt (not yet saved)."""
    rng = rng or random.Random()
    count = count or course.question_count_per_attempt or 100
    qs = list(eligible_questions_qs(course))
    if len(qs) < count:
        raise InsufficientQuestionBank(
            f'Need {count} eligible (active+verified) questions; bank has {len(qs)}.'
        )

    avoid = prior_question_ids(user, course)
    max_reuse = int(math.floor(count * MAX_OVERLAP_RATIO))

    by_topic: dict[str, list[CourseQuestion]] = defaultdict(list)
    for q in qs:
        tag = (q.topic_tag or 'general').strip().lower() or 'general'
        by_topic[tag].append(q)

    seats = _allocate_by_topic({t: len(v) for t, v in by_topic.items()}, count)

    selected: list[CourseQuestion] = []
    selected_ids: set[int] = set()

    for tag, n in seats.items():
        chunk = _pick_with_difficulty(by_topic[tag], n, avoid, rng)
        for q in chunk:
            if q.pk not in selected_ids:
                selected.append(q)
                selected_ids.add(q.pk)

    if len(selected) < count:
        rest = [q for q in qs if q.pk not in selected_ids]
        for q in _pick_from_pool(rest, count - len(selected), avoid, rng):
            selected.append(q)
            selected_ids.add(q.pk)

    selected = selected[:count]

    fresh = [q for q in selected if q.pk not in avoid]
    reused = [q for q in selected if q.pk in avoid]
    if len(reused) > max_reuse:
        unused = [q for q in qs if q.pk not in selected_ids and q.pk not in avoid]
        rng.shuffle(unused)
        keep_reused = reused[:max_reuse]
        drop_n = len(reused) - max_reuse
        replacements = unused[:drop_n]
        selected = fresh + keep_reused + replacements
        if len(selected) < count:
            more = [q for q in qs if q.pk not in {x.pk for x in selected}]
            rng.shuffle(more)
            selected.extend(more[: count - len(selected)])

    selected = selected[:count]
    rng.shuffle(selected)
    if len(selected) < count:
        raise InsufficientQuestionBank(
            f'Could only assemble {len(selected)} of {count} questions after balancing.'
        )
    return selected


def shuffle_options(
    question: CourseQuestion,
    rng: random.Random | None = None,
) -> tuple[list[str], dict[str, str], str]:
    """
    Returns (option_order, presented_map A-D -> text, correct_display_letter).
    option_order is original letters in display order.
    """
    rng = rng or random.Random()
    originals = ORIGINAL_LETTERS[:]
    rng.shuffle(originals)
    opt_map = question.options_map()
    presented = {}
    correct_display = OPTION_A
    for display_letter, original_letter in zip(DISPLAY_LETTERS, originals):
        presented[display_letter] = opt_map[original_letter]
        if original_letter == question.correct_option:
            correct_display = display_letter
    return originals, presented, correct_display


@transaction.atomic
def start_course_exam_attempt(user, course: Course) -> CourseExamAttempt:
    """
    Create attempt + AttemptQuestion rows with shuffled options.
    Fee/enrollment gates are Part 5+.
    """
    rng = random.Random()
    questions = select_questions_for_attempt(course, user, rng=rng)
    attempt = CourseExamAttempt.objects.create(
        user=user,
        course=course,
        status=ATTEMPT_STARTED,
    )
    rows = []
    for idx, question in enumerate(questions):
        option_order, presented, correct_display = shuffle_options(question, rng=rng)
        rows.append(
            AttemptQuestion(
                attempt=attempt,
                question=question,
                order_index=idx,
                option_order=option_order,
                presented_question_text=question.question_text,
                presented_option_a=presented['A'],
                presented_option_b=presented['B'],
                presented_option_c=presented['C'],
                presented_option_d=presented['D'],
                correct_display_option=correct_display,
            )
        )
    AttemptQuestion.objects.bulk_create(rows)
    return attempt
