#!/usr/bin/env python3
"""Course-level detection and Bloom-expectation calibration.

Pure functions, no I/O, no Canvas API calls. Built for the assurance-audit
skill (docs/plan-assurance-accuracy-v2.md workstream 3) so the report can show
a course-level badge (e.g. "100-level") and calibrate the Bloom read: recall
and understand at an introductory course is the expected floor, not a design
defect, and this module is what lets an author say so instead of flagging low
Bloom everywhere uniformly.

This is intentionally separate from the general course.level/course.tier
detection in CLAUDE.md Setup Gate Check 3.5 (a coarser 3-bucket
introductory/advanced/graduate split used across the whole plugin for
assessment-difficulty calibration). This module keeps the finer 100/200/300/
400/500/600/700 band so the report masthead can say "100-level" rather than
the coarser "introductory", and so assurance-audit does not depend on
course-config.json's tier having already been populated.

Public API
----------
detect_level(course_code: str) -> dict
    {"level": int|None, "band": str|None, "label": str|None}

bloom_expectation(level: int|None) -> dict
    {"floor": str, "expected_ceiling": str, "note": str} or {} when level is None.
"""
from __future__ import annotations

import re

_LEVEL_RE = re.compile(r"\d+")

# 500/600/700 read as "graduate" in the masthead label rather than "500-level".
_GRADUATE_BANDS = frozenset({"500", "600", "700"})


def detect_level(course_code: str) -> dict:
    """Parse the first number in a course code into a level/band/label.

    Floors to the nearest hundred and clamps to 100..700 (a code that floors
    above 700 reads as 700; a code with no digits at all returns level=None --
    callers must not guess a level from an unparseable code such as
    "Foundations of Practice").

    Examples: "COM 100" -> 100, "PHI 101" -> 100, "SOC 334" -> 300,
    "LAW 524" -> 500 (label "graduate"), "ARS101" -> 100 (no space),
    "OGL 720" -> 700.
    """
    raw = (course_code or "").strip()
    match = _LEVEL_RE.search(raw)
    if not match:
        return {"level": None, "band": None, "label": None}

    number = int(match.group(0))
    floored = (number // 100) * 100
    floored = max(100, min(700, floored))

    band = str(floored)
    label = "graduate" if band in _GRADUATE_BANDS else f"{band}-level"
    return {"level": floored, "band": band, "label": label}


def bloom_expectation(level: int | None) -> dict:
    """Return the Bloom band a course at this level is expected to emphasize.

    This calibrates the audit's Bloom read so recall/understand-heavy
    assessment at an introductory level is not flagged as a design gap: Bloom
    at or below the course's expected ceiling is appropriate, not a defect.
    Returns {} (no calibration) when level is None -- an unparseable course
    code carries no level assumption either way, so the Bloom read stays
    uncalibrated rather than guessing.
    """
    if level is None:
        return {}
    if level <= 200:
        return {
            "floor": "Remember",
            "expected_ceiling": "Apply",
            "note": "Recall and understand are appropriate at the introductory level.",
        }
    if level <= 400:
        return {
            "floor": "Apply",
            "expected_ceiling": "Analyze",
            "note": "Application and analysis are the expected range at the advanced undergraduate level.",
        }
    return {
        "floor": "Analyze",
        "expected_ceiling": "Create",
        "note": "Analysis through creation are the expected range at the graduate level.",
    }
