

# Readiness needs enough answers before a per-category estimate means anything.
READINESS_UNLOCK_ANSWERS = 40
# Shrinkage weight: a category with this many answers sits halfway between its
# own accuracy and the learner's overall accuracy.
READINESS_PRIOR_ANSWERS = 8


def _category_rollup(categories):
    """Map every category to itself plus all of its ancestors, for roll-up counting."""
    parents = {cat.id: cat.parent_id for cat in categories}
    ancestry: dict[int, list[int]] = {}
    for cid in parents:
        chain, cursor, guard = [], cid, 0
        while cursor is not None and guard < 12:
            chain.append(cursor)
            cursor = parents.get(cursor)
            guard += 1
        ancestry[cid] = chain
    return ancestry


@router.get("/recommendations")
def study_recommendations(
    group: Literal["systems", "subtopics"] = "systems",
    limit: int = Query(20, ge=1, le=60),
    db: Session = Depends(get_db),
    user: User = Depends(get_current_user),
):
    """Focus areas ranked by the study time most likely to raise the learner's score.

    Readiness is the learner's accuracy in a category shrunk toward their own
    overall accuracy in proportion to how few answers that category has, so a
    single unlucky question does not read as a knowledge gap. It is a plain
    empirical-Bayes estimate over recorded answers — not a psychometric exam
    score, and not a prediction of any real examination.
    """
    categories = db.query(QuestionCategory).all()
    ancestry = _category_rollup(categories)
    names = {cat.id: cat.name for cat in categories}
    parents = {cat.id: cat.parent_id for cat in categories}
    top_level = {cat.id for cat in categories if cat.parent_id is None}

    # ── What the learner has answered ──────────────────────────────
    answered_rows = db.query(
        AttemptAnswer.question_id, AttemptAnswer.is_correct, Question.question_category_id,
    ).join(QuizAttempt, QuizAttempt.id == AttemptAnswer.attempt_id
    ).join(Quiz, Quiz.id == QuizAttempt.quiz_id
    ).join(Question, Question.id == AttemptAnswer.question_id
    ).filter(
        QuizAttempt.user_id == user.id,
        QuizAttempt.completed_at.isnot(None),
        or_(QuizAttempt.expired == 0, QuizAttempt.expired.is_(None)),
        Quiz.course_id.is_(None),
    ).all()

    extra_links: dict[int, set[int]] = defaultdict(set)
    for question_id, category_id in db.query(
            QuestionCategoryLink.question_id, QuestionCategoryLink.category_id).all():
        extra_links[question_id].add(category_id)

    def categories_for(question_id, primary):
        direct = extra_links.get(question_id, set()) | ({primary} if primary else set())
        rolled = set()
        for cid in direct:
            rolled.update(ancestry.get(cid, [cid]))
        return rolled

    answered: dict[int, int] = defaultdict(int)
    correct: dict[int, int] = defaultdict(int)
    seen_questions: dict[int, set[int]] = defaultdict(set)
    total_answers = len(answered_rows)
    total_correct = sum(1 for _, is_correct, _ in answered_rows if is_correct)
    for question_id, is_correct, primary in answered_rows:
        for cid in categories_for(question_id, primary):
            answered[cid] += 1
            seen_questions[cid].add(question_id)
            if is_correct:
                correct[cid] += 1

    # ── How much bank material each category holds ─────────────────
    available: dict[int, int] = defaultdict(int)
    bank_total = 0
    for question_id, primary in db.query(Question.id, Question.question_category_id).filter(
            shareable_question_predicate()).all():
        bank_total += 1
        for cid in categories_for(question_id, primary):
            available[cid] += 1

    articles = {}
    for article in db.query(Article).filter(Article.status == "published",
                                            Article.category_id.isnot(None)).all():
        articles.setdefault(article.category_id, article)

    overall_accuracy = (total_correct / total_answers) if total_answers else 0.0
    unlocked = total_answers >= READINESS_UNLOCK_ANSWERS

    scope = top_level if group == "systems" else {cat.id for cat in categories if cat.parent_id is not None}
    rows = []
    for cid in scope:
        seen = len(seen_questions.get(cid, ()))
        pool = available.get(cid, 0)
        if pool == 0 and seen == 0:
            continue
        n = answered.get(cid, 0)
        c = correct.get(cid, 0)
        accuracy = round(100 * c / n, 1) if n else None
        readiness = None
        if unlocked and n:
            shrunk = (c + READINESS_PRIOR_ANSWERS * overall_accuracy) / (n + READINESS_PRIOR_ANSWERS)
            readiness = round(100 * shrunk, 1)
        relevance = round(100 * pool / bank_total, 1) if bank_total else 0.0
        coverage = round(100 * seen / pool, 1) if pool else 0.0
        article = articles.get(cid)
        rows.append({
            "category_id": cid,
            "name": names.get(cid, "Uncategorized"),
            "parent_id": parents.get(cid),
            "parent_name": names.get(parents.get(cid)),
            "answered": n,
            "correct": c,
            "seen_questions": seen,
            "available": pool,
            "coverage": coverage,
            "accuracy": accuracy,
            "readiness": readiness,
            "relevance": relevance,
            "status": "no_data" if not n else "focus" if (readiness if readiness is not None else accuracy) < 70 else "proficient",
            "article_id": article.id if article else None,
            "article_title": article.title if article else None,
        })

    # Priority: weak-and-relevant first, then untouched material by relevance.
    baseline = 100 * overall_accuracy if total_answers else 70.0
    for row in rows:
        score = row["readiness"] if row["readiness"] is not None else row["accuracy"]
        gap = (baseline - score) / 100 if score is not None else 0.5  # unseen material sits mid-priority
        unseen = 1 - (row["coverage"] / 100)
        row["priority"] = round(max(gap, 0.0) * (row["relevance"] / 100) + 0.25 * unseen * (row["relevance"] / 100), 5)
    rows.sort(key=lambda row: (-row["priority"], -row["relevance"], row["name"]))
    focus_ids = {row["category_id"] for row in rows[:3] if row["answered"]}
    for row in rows:
        if row["category_id"] in focus_ids:
            row["is_focus_area"] = True
        else:
            row["is_focus_area"] = False

    return {
        "group": group,
        "unlocked": unlocked,
        "answers_needed": max(0, READINESS_UNLOCK_ANSWERS - total_answers),
        "total_answered": total_answers,
        "unique_questions_seen": len({row[0] for row in answered_rows}),
        "bank_total": bank_total,
        "overall_accuracy": round(100 * overall_accuracy, 1) if total_answers else None,
        "focus_areas": rows[:limit],
        "basis": (
            "Your completed, non-expired general-bank answers, rolled up through the category tree. "
            f"Readiness shrinks each category's accuracy toward your overall {round(100 * overall_accuracy)}% "
            "so small samples do not overstate a gap; it unlocks after "
            f"{READINESS_UNLOCK_ANSWERS} answers. Relevance is the share of the question bank a category holds. "
            "These are study hints from your own answers, not an exam score prediction."
        ),
    }
