#!/usr/bin/env python3
"""Assessment Assurance Inventory -- Component 1 of the Assessment Assurance Audit.

Builds a normalized, weight-aware live inventory of all graded assessments in a
Canvas course.  This is the foundation layer; all scoring (Components 3-4) runs
off the records produced here.

Public API
----------
build_inventory(config) -> dict
    Returns:
      {
        "assessments":          list[AssessmentRecord],
        "course_weighting_mode": "weighted_groups" | "points_only",
        "groups":               list[GroupMeta],   # see "Drop rules and choice groups" below
        "totals": {
            "counted_points":   float,
            "grade_weight_pct_sum": float,   # should be ~100.0
        },
        "course_clos": {"clos": list[str], "bloom_index": int, "bloom_level": str, "source": str},
        "module_mlos": {module_name: {"mlos": list[str], "bloom_index": int, "bloom_level": str}},
        "syllabus_html": str,   # raw syllabus_body HTML, fetched alongside course
                                 # flags so callers (assurance_audit.score_course)
                                 # do not need a second /courses/:id GET
        "assumptions": list[str],
      }

AssessmentRecord shape (all keys always present; missing data uses None/False/[]):
    {
        "id":                   int,
        "title":                str,
        "canvas_type":          "assignment" | "quiz" | "discussion" | "external_tool",
        "engine":               "classic_quiz" | "new_quizzes" | "new_quizzes_opaque"
                                | "graded_discussion" | "assignment" | "external_tool" | None,
        "points":               float,
        "grade_weight_pct":     float,          # 0.0-100.0; 0 for uncounted items AND dropped_by_rule items
        "submission_types":     list[str],
        "has_rubric":           bool,
        "peer_reviews":         bool,
        "omit_from_final_grade": bool,
        "published":            bool,
        "module":               str | None,     # module name from Canvas
        "group":                str | None,     # assignment group name
        "is_external_tool":     bool,
        "proctoring_signal":    bool,
        "dropped_by_rule":      bool,           # True when the group's drop_lowest/drop_highest
                                                 # rule excluded this item -- see "Drop rules" below
        "bloom_level":          str,            # title verb, else module MLO (fill-Unknown)
        "bloom_index":          int,            # 0-6 (0 = Unknown)
        "bloom_source":         "title" | "mlo" | "unknown",
        "content_excerpt":      str,            # prompt text (assignment/discussion/quiz stems), HTML-stripped, capped
        "rubric_excerpt":       str,            # rubric criteria description + long_description, HTML-stripped, capped
        "bloom_content_available": bool,        # True when prompt or rubric text was obtained; False for bank-locked objective items
    }

Drop rules and choice groups (assurance accuracy v2, workstreams 1-2)
-----------------------------------------------------------------------
Assignment groups may carry Canvas ``rules``: {"drop_lowest": k, "drop_highest": k,
"never_drop": [ids]}. Weight computation (see "Grade-weight formulas" below) now
excludes the k dropped items from the group's counted-points denominator --
e.g. 12 quizzes at 10 points with drop_lowest=2 counts 10 quizzes against a
100-point denominator, not 12 against 120. Dropped items get grade_weight_pct=0
and dropped_by_rule=True. WHICH items an individual student drops depends on
their scores (unavailable at audit time); this models the group's max
achievable points via points_possible instead -- see _compute_group_drops().

GroupMeta shape (one entry per assignment group, in the top-level "groups" list):
    {
        "id": int, "name": str, "group_weight": float,
        "rules": {"drop_lowest": int, "drop_highest": int, "never_drop": list[int]},
        "counted_count": int,               # counted items before drops
        "counted_after_drops": int,         # counted items retained after drops
        "dropped_count": int,               # k actually dropped
        "counted_points_before_drops": float,
        "counted_points_after_drops": float,  # the denominator actually used
        "dropped_ids": list[int],
        "possible_choice_group": bool,      # flag only -- see below; never changes counts/weights
        "choice_signal": str | None,        # the matched phrase, or None
    }

1-of-N choice (e.g. "submit 1 of these 10 papers") is not exposed by the Canvas
API. ``possible_choice_group`` flags a group with >=3 counted siblings sharing
identical points_possible AND a sign-up/"of your choice"/choose-one signal in
any sibling's name or description (a 0-point "Sign Up" assignment counts as a
sibling for the text signal even though it is not itself counted). This is
flag-only: counts and weights are never recomputed from it. An author must
verify against the syllabus before stating a deliverable count -- see
_detect_choice_group().

Deep-pass cognitive demand (rubric_excerpt + bloom_content_available)
--------------------------------------------------------------------
The deterministic Bloom (title verb -> MLO inheritance -> caps -> objective floor)
stays as the baseline/fallback. On top of it, every weighted assessment carries the
real task text Claude needs to classify the Bloom level the task REQUIRES:

  - content_excerpt: the assignment description, the discussion message (the prompt,
    fetched via the discussion topic), or quiz question stems where the API exposes them.
  - rubric_excerpt:  the rubric criteria (description + long_description per criterion).
    Rubrics are the strongest cognitive-demand signal.
  - bloom_content_available: False for objective quizzes/exams whose items come from a
    question bank the API does not expose -- those are marked for format inference, not
    a content read.

Grade-weight formulas
---------------------
Canvas has two grading modes controlled by the course's
``apply_assignment_group_weights`` flag:

    Weighted groups (apply_assignment_group_weights=True):
        per_item_weight = group_weight_pct * (item_points / group_counted_points_sum)

    Points only (apply_assignment_group_weights=False):
        per_item_weight = (item_points / total_counted_points_sum) * 100

"Counted" means the item is:
  - published (workflow_state == "published" OR published == True)
  - NOT omit_from_final_grade
  - points_possible > 0

Uncounted items still appear in the output with grade_weight_pct=0 so callers
can audit zero-weight or omitted items.

New Quizzes detection
---------------------
Canvas represents New Quizzes as assignments with:
  - is_quiz_assignment == True  (set by Canvas for all quiz-backed assignments)
  - submission_types == ["external_tool"]  (New Quizzes) vs ["online_quiz"] (Classic)
  - external_tool_tag present with a tool URL containing "quiz-lti" or "quizzes/next"
A Classic quiz also has is_quiz_assignment==True but submission_types==["online_quiz"].
If a quiz is detected as New Quizzes and the quiz-detail API is unreachable
(returns non-200 or raises), the record is emitted with engine="new_quizzes_opaque".

Proctoring signal
-----------------
Signals True when any of:
  - submission_types contains "external_tool" AND external_tool_tag URL contains
    a known proctoring keyword (respondus, lockdown, proctor, examity, honorlock,
    proctortrack, examplify, meazure)
  - (Classic quiz only) access_code or ip_filter is non-empty on the quiz detail

No import of criterion_evaluator.  No zip/IMSCC code.
"""

from __future__ import annotations

import logging
import os
import re
import sys
from typing import Any

# ---- path setup so this module is importable both as a script and in tests ----
_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
if _SCRIPTS_DIR not in sys.path:
    sys.path.insert(0, _SCRIPTS_DIR)

import canvas_api
from blooms import classify_blooms, BLOOMS_LEVELS
import objective_extractor

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

# Proctoring tool URL substrings (lower-cased match)
_PROCTORING_URL_KEYWORDS = frozenset({
    "respondus", "lockdown", "proctor", "examity",
    "honorlock", "proctortrack", "examplify", "meazure",
})

# New Quizzes is identified by these tool URL substrings
_NEW_QUIZZES_URL_KEYWORDS = frozenset({"quiz-lti", "quizzes/next"})

# Workstream 2 (assurance accuracy v2): signals a group looks like "choose one
# of N" -- a Sign Up assignment, "of your choice", "choose one", etc. Flag
# only; see _detect_choice_group().
_CHOICE_SIGNAL_RE = re.compile(
    r"sign[\s-]?up|of your choice|choose (?:one|1)|your choice|pick (?:one|1)",
    re.IGNORECASE,
)

# A group needs at least this many counted siblings tied at the same
# points_possible before the choice-group text signal is even checked.
_CHOICE_GROUP_MIN_TIED_SIBLINGS = 3

# Per-course API call budget.  The inventory requires at most:
#   1 (course object) + 1 (assignment_groups+assignments) + 1 (quizzes list)
#   + 1 (discussions list) + N_new_quiz_detail (rare) = ~4 base calls.
# We set a generous ceiling so a batch run can detect runaway per-item GETs.
PER_COURSE_CALL_BUDGET = 40  # raised from 20 to accommodate quiz-question fetches

# Max quiz questions to fetch per Bloom-Unknown quiz (criterion B).
# Taking the first N stems keeps the excerpt bounded and the API call cheap.
MAX_QUIZ_QUESTIONS_TO_FETCH = 10

# Character cap for content_excerpt attached to Bloom-Unknown records (criterion B).
CONTENT_EXCERPT_MAX_CHARS = 2000

# Character cap for rubric_excerpt (deep-bloom pass). Rubric criteria descriptions
# are the strongest cognitive-demand signal; the cap keeps the attached text bounded.
RUBRIC_EXCERPT_MAX_CHARS = 2000

# Grade-weight sum tolerance (percent points)
WEIGHT_SUM_TOLERANCE = 1.5  # allow up to 1.5 pp rounding error


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _is_published(item: dict) -> bool:
    """Return True if the Canvas object is published.

    Canvas uses ``published`` (bool) on most objects and
    ``workflow_state`` on assignments.
    """
    wf = (item.get("workflow_state") or "").lower()
    if wf:
        return wf == "published"
    return bool(item.get("published", False))


def _is_counted(item: dict) -> bool:
    """Return True when an item counts toward the final grade."""
    if item.get("omit_from_final_grade", False):
        return False
    if not _is_published(item):
        return False
    pts = _points(item)
    if pts is None or pts <= 0:
        return False
    return True


def _points(item: dict) -> float | None:
    """Return points_possible as float, or None if absent/invalid."""
    raw = item.get("points_possible")
    if raw is None:
        return None
    try:
        return float(raw)
    except (TypeError, ValueError):
        return None


def _detect_new_quizzes(assignment: dict) -> bool:
    """Return True when the assignment looks like a New Quizzes item."""
    if not assignment.get("is_quiz_assignment", False):
        return False
    sub_types = assignment.get("submission_types") or []
    if "external_tool" not in sub_types:
        return False
    tag = assignment.get("external_tool_tag") or {}
    url = (tag.get("url") or "").lower()
    return any(kw in url for kw in _NEW_QUIZZES_URL_KEYWORDS)


def _detect_proctoring(assignment: dict, quiz_detail: dict | None):
    """Return tri-state proctoring signal: True / False / "unknown".

    True  -- a positive proctoring signal is confirmed:
               * external_tool URL contains a known proctoring keyword, OR
               * classic quiz detail carries access_code or ip_filter, OR
               * classic quiz requires Respondus LockDown Browser
                 (require_lockdown_browser), with or without webcam monitor.
    False -- assessed and no signal found (e.g., non-exam assignment, or a
             classic quiz confirmed to have no access_code/ip_filter).
    "unknown" -- an exam/high-weight quiz with no detectable signal but we
                 cannot confirm absence via API (quiz_detail is None for a
                 classic quiz means we never fetched its detail, so we cannot
                 assert "unproctored").

    Criterion D: "unknown" must never be counted as a secured Lane 1 anchor.
    """
    # External tool URL keyword check (works for New Quizzes + LTI assignments)
    sub_types = assignment.get("submission_types") or []
    if "external_tool" in sub_types:
        tag = assignment.get("external_tool_tag") or {}
        url = (tag.get("url") or "").lower()
        if any(kw in url for kw in _PROCTORING_URL_KEYWORDS):
            return True

    # Classic quiz: use quiz_detail when available for a definitive answer.
    is_classic_quiz = (
        assignment.get("is_quiz_assignment", False)
        and "online_quiz" in sub_types
    )
    if is_classic_quiz:
        if quiz_detail is not None:
            # Definitive: access_code, ip_filter, or a required Respondus
            # LockDown Browser lock (with or without webcam monitor) -> True.
            if (
                quiz_detail.get("access_code")
                or quiz_detail.get("ip_filter")
                or quiz_detail.get("require_lockdown_browser")
            ):
                return True
            return False
        # quiz_detail unavailable: cannot confirm; return "unknown".
        return "unknown"

    return False


def _has_rubric(assignment: dict) -> bool:
    """Return True when the assignment carries a rubric."""
    return bool(assignment.get("rubric") or assignment.get("use_rubric_for_grading"))


def _strip_html(html: str) -> str:
    """Strip HTML tags and collapse whitespace to plain text."""
    from html import unescape
    text = re.sub(r"<[^>]+>", " ", html or "")
    return re.sub(r"\s+", " ", unescape(text)).strip()


def _build_rubric_excerpt(criteria: list[dict] | None) -> str:
    """Render a rubric's criteria into a bounded plain-text excerpt (deep-bloom pass).

    Canvas attaches a rubric to an assignment (or quiz) as a list of criterion
    dicts, each carrying ``description`` (the criterion name) and an optional
    ``long_description`` (the prose that states what the criterion measures). The
    long_description is the strongest cognitive-demand signal -- it spells out the
    verb the task requires ("evaluate", "compare", "summarize") far more reliably
    than the assignment title. Returns "" when no usable criterion text is present.
    """
    if not criteria:
        return ""
    parts: list[str] = []
    for crit in criteria:
        if not isinstance(crit, dict):
            continue
        desc = _strip_html(crit.get("description") or "")
        long_desc = _strip_html(crit.get("long_description") or "")
        if desc and long_desc:
            parts.append(f"{desc}: {long_desc}")
        elif desc:
            parts.append(desc)
        elif long_desc:
            parts.append(long_desc)
    excerpt = " | ".join(p for p in parts if p)
    return excerpt[:RUBRIC_EXCERPT_MAX_CHARS]


def _fetch_quiz_questions_excerpt(
    config: dict,
    quiz_id: int,
    throughput: "_Throughput",
) -> str:
    """Fetch the first MAX_QUIZ_QUESTIONS_TO_FETCH question stems for a classic quiz.

    Returns a plain-text excerpt of up to CONTENT_EXCERPT_MAX_CHARS characters,
    or "" on any failure. Only called for Bloom-Unknown classic quizzes (criterion B).
    """
    import requests as _req
    throughput.charge()
    try:
        url = (
            f"{config['course_url']}/quizzes/{quiz_id}/questions"
            f"?per_page={MAX_QUIZ_QUESTIONS_TO_FETCH}"
        )
        questions = canvas_api.paginated_get(
            url, config["headers"], canvas_domain=config["domain"]
        )
    except Exception as exc:
        logger.debug("quiz questions fetch failed for quiz %s: %s", quiz_id, exc)
        return ""

    if not questions:
        return ""

    stems = []
    for q in questions[:MAX_QUIZ_QUESTIONS_TO_FETCH]:
        stem = _strip_html(q.get("question_text") or "")
        if stem:
            stems.append(stem)

    excerpt = " | ".join(stems)
    return excerpt[:CONTENT_EXCERPT_MAX_CHARS]


# ---------------------------------------------------------------------------
# Module membership map
# ---------------------------------------------------------------------------

def _build_module_map(config: dict, throughput: "_Throughput | None") -> dict[tuple[str, int], str]:
    """Return {(item_type, content_id): module_name} for module items.

    Keys are namespaced by Canvas module-item ``type`` ("Assignment", "Quiz",
    "Discussion", ...). Assignment-id and quiz-id are SEPARATE integer spaces
    that can collide: a classic quiz's ``quiz_id`` may numerically equal an
    unrelated assignment's id. A merged int keyspace would let that quiz inherit
    the assignment's module; the namespace tag keeps the two spaces apart.

    Uses the course tree from course_navigator when available to avoid an
    extra API call.  Falls back to a direct modules+items fetch.
    """
    try:
        from course_navigator import get_tree
        tree = get_tree(config, force_refresh=False)
    except Exception:
        if throughput:
            throughput.charge()
        tree = canvas_api.get_modules(config, include_items=True)

    mapping: dict[tuple[str, int], str] = {}
    for mod in tree:
        mod_name = mod.get("name", "")
        for item in mod.get("items", []):
            cid = item.get("content_id")
            itype = item.get("type")
            if cid and itype:
                mapping[(itype, int(cid))] = mod_name
    return mapping


# ---------------------------------------------------------------------------
# Throughput (lightweight internal call-budget counter)
# ---------------------------------------------------------------------------

class _Throughput:
    """Minimal call-budget counter used internally when a full Throughput
    object is not supplied by the caller."""

    def __init__(self, budget: int = PER_COURSE_CALL_BUDGET) -> None:
        self.budget = budget
        self.calls = 0

    def charge(self, n: int = 1) -> None:
        self.calls += n
        if self.calls > self.budget:
            logger.warning(
                "assurance_inventory: per-course call budget of %d exceeded "
                "(%d calls so far). Consider increasing PER_COURSE_CALL_BUDGET.",
                self.budget,
                self.calls,
            )


# ---------------------------------------------------------------------------
# Core inventory builder
# ---------------------------------------------------------------------------

def _fetch_course_flags(config: dict, throughput: "_Throughput") -> dict:
    """Fetch course-level flags (apply_assignment_group_weights, name, etc.).

    Also requests include[]=syllabus_body on this same GET /courses/:id call
    so build_inventory() can expose the syllabus HTML to callers (see
    syllabus_html below). assurance_audit.score_course() reuses it instead
    of issuing a second, otherwise-identical /courses/:id GET just for the
    syllabus.
    """
    import requests

    throughput.charge()
    resp = canvas_api._request_with_retry(
        requests.get,
        config["course_url"],
        config["headers"],
        params={"include[]": "syllabus_body"},
    )
    if resp and resp.status_code == 200:
        return resp.json()
    logger.warning("Could not fetch course object (HTTP %s); defaulting to points-only weighting.",
                   resp.status_code if resp else "none")
    return {}


def _fetch_groups_with_assignments(config: dict, throughput: "_Throughput") -> list[dict]:
    """Fetch assignment groups including embedded assignments.

    The Canvas endpoint supports ``include[]=assignments`` which returns full
    assignment objects nested under each group.  We also add
    ``include[]=submission_types`` and ``include[]=observed_users`` but those
    are silently ignored by Canvas if unsupported -- the critical sub-fields
    (points_possible, submission_types, omit_from_final_grade, workflow_state,
    peer_reviews, is_quiz_assignment, external_tool_tag) are part of the
    standard assignment body returned with include[]=assignments.
    """
    throughput.charge()
    url = (
        f"{config['course_url']}/assignment_groups"
        "?per_page=100"
        "&include[]=assignments"
        "&include[]=assignment_visibility"
    )
    try:
        groups = canvas_api.paginated_get(
            url, config["headers"], canvas_domain=config["domain"]
        )
        return groups
    except Exception as exc:
        logger.warning("Failed to fetch assignment groups with assignments: %s. "
                       "Falling back to list_assignments().", exc)
        # Fallback: fetch groups without assignments, then list assignments
        throughput.charge()
        groups = canvas_api.get_assignment_groups(config, include_assignments=False)
        throughput.charge()
        assignments = canvas_api.list_assignments(config) or []
        # Index assignments by group id
        by_group: dict[int, list] = {}
        for a in assignments:
            gid = a.get("assignment_group_id")
            if gid is not None:
                by_group.setdefault(int(gid), []).append(a)
        for g in groups:
            g["assignments"] = by_group.get(int(g.get("id", 0)), [])
        return groups


def _fetch_quizzes_map(config: dict, throughput: "_Throughput") -> dict[int, dict]:
    """Return {quiz_id: quiz_detail} for all Classic quizzes.

    Needed for:
      - access_code / ip_filter (proctoring signal)
      - rubric on a quiz (quiz objects may carry rubric where assignment does not)
    """
    throughput.charge()
    try:
        quizzes = canvas_api.list_quizzes(config) or []
    except Exception as exc:
        logger.warning("list_quizzes failed: %s", exc)
        quizzes = []
    return {int(q["id"]): q for q in quizzes if q.get("id")}


def _fetch_discussions_map(config: dict, throughput: "_Throughput") -> dict[int, dict]:
    """Return {assignment_id: topic} for graded discussion topics.

    Canvas discussion topics that have an assignment back-pointer carry
    ``assignment_id`` in their JSON.  We index by that so we can join
    each assignment back to its topic for metadata (e.g. topic title).
    """
    throughput.charge()
    try:
        topics = canvas_api.list_discussions(config) or []
    except Exception as exc:
        logger.warning("list_discussions failed: %s", exc)
        topics = []
    result: dict[int, dict] = {}
    for t in topics:
        aid = t.get("assignment_id")
        if aid:
            result[int(aid)] = t
    return result


# ---------------------------------------------------------------------------
# Drop rules and choice-group detection (assurance accuracy v2, workstreams 1-2)
# ---------------------------------------------------------------------------

def _extract_drop_rules(group: dict) -> tuple[int, int, set[int]]:
    """Parse a Canvas assignment group's ``rules`` field.

    Canvas returns ``rules`` as {"drop_lowest": k, "drop_highest": k,
    "never_drop": [assignment_id, ...]} when the instructor configured drop
    rules on the group. Absent/malformed values degrade to "no rules"
    (0, 0, empty set) rather than raising -- a course with no drop rules is
    the common case and must behave exactly as before this feature existed.
    """
    rules = group.get("rules")
    if not isinstance(rules, dict):
        return 0, 0, set()

    def _nonneg_int(value: object) -> int:
        try:
            n = int(value)
        except (TypeError, ValueError):
            return 0
        return n if n > 0 else 0

    drop_lowest = _nonneg_int(rules.get("drop_lowest"))
    drop_highest = _nonneg_int(rules.get("drop_highest"))
    never_drop_ids: set[int] = set()
    for raw_id in (rules.get("never_drop") or []):
        try:
            never_drop_ids.add(int(raw_id))
        except (TypeError, ValueError):
            continue
    return drop_lowest, drop_highest, never_drop_ids


def _compute_group_drops(
    counted_items: list[dict],
    drop_lowest: int,
    drop_highest: int,
    never_drop_ids: set[int],
) -> set[int]:
    """Return the assignment ids dropped by a group's drop_lowest/drop_highest rules.

    ``counted_items`` must already be filtered to this ONE group's
    _is_counted() items. never_drop ids are excluded from the droppable pool
    entirely -- they always count, even if they would otherwise be the
    lowest/highest-point item selected for dropping.

    Canvas's real drop-lowest/drop-highest is score-dependent: at grading time
    it drops whichever items maximize each student's percentage. At audit time
    there are no student scores, so this models the group's max achievable
    points instead: drop_lowest removes the k lowest point_possible items,
    drop_highest removes the k highest point_possible items. WHICH items an
    individual student actually drops depends on their scores -- this is a
    structural approximation for weight/coverage reporting, not a grade
    calculation (see the module docstring's "Grade-weight formulas" section
    and SKILL.md's "Drop rules and choice groups" authoring guidance).

    Deterministic tiebreak: among items with equal points_possible, the lowest
    assignment id drops first, so the result is stable without student scores.
    drop_lowest is applied first (against the full droppable pool); drop_highest
    then applies to whatever remains, so the same item is never double-dropped.
    """
    if drop_lowest <= 0 and drop_highest <= 0:
        return set()

    droppable = [a for a in counted_items if int(a.get("id", 0)) not in never_drop_ids]
    dropped: set[int] = set()

    if drop_lowest > 0:
        pool = sorted(droppable, key=lambda a: (_points(a) or 0.0, int(a.get("id", 0))))
        dropped.update(int(a.get("id", 0)) for a in pool[:drop_lowest])

    if drop_highest > 0:
        remaining = [a for a in droppable if int(a.get("id", 0)) not in dropped]
        pool = sorted(remaining, key=lambda a: (-(_points(a) or 0.0), int(a.get("id", 0))))
        dropped.update(int(a.get("id", 0)) for a in pool[:drop_highest])

    return dropped


def _detect_choice_group(group: dict) -> tuple[bool, str | None]:
    """Flag-only detector for a likely "choose one of N" menu within a group.

    1-of-N choice is not exposed by the Canvas API -- a "Papers" group with 10
    published, equally-weighted papers may really mean "submit 1 of your
    choice", not "complete all 10". Guessing the real count would silently
    misreport coverage, so this NEVER changes counts or weights (see the
    module docstring); it only sets group meta that an author must read and
    verify against the syllabus before stating a deliverable count.

    Signal (both required):
      - structural: >= 3 counted siblings share an identical points_possible.
      - textual: the name or description of ANY sibling in the group (counted
        or not -- a "Sign Up" assignment is often 0-point/uncounted, so it
        would never appear in the counted pool itself) matches the
        choice-signal regex.

    Returns (possible_choice_group, choice_signal) where choice_signal is the
    literal matched phrase, or None when no signal was found.
    """
    assignments = group.get("assignments", []) or []
    counted = [a for a in assignments if _is_counted(a)]

    points_tally: dict[float, int] = {}
    for a in counted:
        pts = _points(a)
        if pts is None:
            continue
        points_tally[pts] = points_tally.get(pts, 0) + 1
    if not any(count >= _CHOICE_GROUP_MIN_TIED_SIBLINGS for count in points_tally.values()):
        return False, None

    for a in assignments:
        name = a.get("name") or ""
        desc = _strip_html(a.get("description") or "")
        match = _CHOICE_SIGNAL_RE.search(f"{name} {desc}")
        if match:
            return True, match.group(0)

    return False, None


def _build_group_meta(groups: list[dict]) -> dict[int, dict]:
    """Compute per-group drop-rule application and choice-group detection.

    Pure function over the raw groups-with-assignments list (no I/O, no
    mutation of the input). Returns {group_id: {...}}; see the module
    docstring's "New per-group meta in output" section. ``dropped_ids`` is a
    set here for fast membership checks during weight computation;
    build_inventory() sorts it to a list before it reaches the
    JSON-serializable "groups" output.
    """
    meta: dict[int, dict] = {}
    for group in groups:
        gid = int(group.get("id", 0))
        assignments = group.get("assignments", []) or []
        counted_items = [a for a in assignments if _is_counted(a)]

        drop_lowest, drop_highest, never_drop_ids = _extract_drop_rules(group)
        dropped_ids = _compute_group_drops(counted_items, drop_lowest, drop_highest, never_drop_ids)

        counted_points_before = sum((_points(a) or 0.0) for a in counted_items)
        counted_points_after = sum(
            (_points(a) or 0.0)
            for a in counted_items
            if int(a.get("id", 0)) not in dropped_ids
        )

        possible_choice_group, choice_signal = _detect_choice_group(group)

        meta[gid] = {
            "id": gid,
            "name": group.get("name", ""),
            "group_weight": float(group.get("group_weight") or 0.0),
            "rules": {
                "drop_lowest": drop_lowest,
                "drop_highest": drop_highest,
                "never_drop": sorted(never_drop_ids),
            },
            "dropped_ids": dropped_ids,
            "counted_count": len(counted_items),
            "counted_after_drops": len(counted_items) - len(dropped_ids),
            "dropped_count": len(dropped_ids),
            "counted_points_before_drops": round(counted_points_before, 4),
            "counted_points_after_drops": round(counted_points_after, 4),
            "possible_choice_group": possible_choice_group,
            "choice_signal": choice_signal,
        }
    return meta


# ---------------------------------------------------------------------------
# Weight computation
# ---------------------------------------------------------------------------

def _compute_weights(
    groups: list[dict],
    weighted_mode: bool,
    group_meta: dict[int, dict] | None = None,
) -> tuple[dict[int, float], bool]:
    """Return ({assignment_id: grade_weight_pct}, used_points_only_fallback).

    All weights for counted items should sum to approximately 100.0.
    Uncounted items receive weight 0.

    Formulas:

    Weighted groups (apply_assignment_group_weights=True):
        group_counted_pts = sum(points of counted items in this group)
        per_item = group_weight_pct * (item_pts / group_counted_pts)

    Points only (apply_assignment_group_weights=False):
        total_counted_pts = sum(points of counted items across ALL groups)
        per_item = (item_pts / total_counted_pts) * 100

    Weighted-groups fallback (used_points_only_fallback=True):
        A course mid-build (weighting toggled on, no percentages entered yet)
        reports every assignment_group.group_weight as 0. Zeroing every
        item's weight in that state produces a false 0%-coverage / no-anchor
        read even when the course has real counted points (e.g. a proctored
        final). When every group_weight is 0 but the course has counted
        points somewhere, fall back to the points-only formula above instead
        of zeroing everything. A truly empty course (no counted points
        anywhere) still yields all-zero weights -- see group_counted below.

    Drop rules (assurance accuracy v2, workstream 1):
        When a group carries drop_lowest/drop_highest, group_counted_pts (and
        total_counted_pts in the points-only/fallback path) excludes the items
        group_meta[gid]["dropped_ids"] selected for dropping -- see
        _build_group_meta()/_compute_group_drops(). Those items receive
        grade_weight_pct=0 regardless of the formulas above; the remaining
        (n-k) items split the group's full weight against the reduced
        denominator, not the raw total.
    """
    weight_map: dict[int, float] = {}
    used_fallback = False
    group_meta = group_meta or {}

    # Gather all assignments in a flat list, annotated with group info
    all_items: list[dict] = []
    for group in groups:
        gid = int(group.get("id", 0))
        gw = float(group.get("group_weight") or 0.0)
        gname = group.get("name", "")
        for a in group.get("assignments", []):
            a["_group_weight"] = gw
            a["_group_name"] = gname
            a["_group_id"] = gid
            all_items.append(a)

    if weighted_mode:
        # Build per-group counted points sums, net of any rule-dropped items.
        group_counted: dict[int, float] = {}
        for group in groups:
            gid = int(group.get("id", 0))
            dropped_ids = (group_meta.get(gid) or {}).get("dropped_ids") or set()
            counted_pts = sum(
                (_points(a) or 0.0)
                for a in group.get("assignments", [])
                if _is_counted(a) and int(a.get("id", 0)) not in dropped_ids
            )
            group_counted[gid] = counted_pts

        # Canvas normalizes group weights by the sum of weights for groups that
        # have at least one counted item.  Groups whose counted-points sum is 0
        # (all items unpublished, omitted, zero-point, or entirely dropped)
        # drop out of that denominator entirely and their weight redistributes
        # to the remaining groups.  Without this normalization, grade_weight_pct
        # sums to the raw group-weight total (e.g. 90 or 110) rather than 100.
        total_group_weight: float = sum(
            float(group.get("group_weight") or 0.0)
            for group in groups
            if group_counted.get(int(group.get("id", 0)), 0.0) > 0.0
        )

        if total_group_weight == 0.0 and any(pts > 0.0 for pts in group_counted.values()):
            # Every group_weight is 0 but the course has real counted points --
            # the mid-build state, not a genuinely empty course. See docstring.
            used_fallback = True
            weight_map = _points_only_weight_map(all_items, group_meta)
        else:
            for group in groups:
                gid = int(group.get("id", 0))
                gw = float(group.get("group_weight") or 0.0)
                counted_pts = group_counted[gid]
                dropped_ids = (group_meta.get(gid) or {}).get("dropped_ids") or set()
                for a in group.get("assignments", []):
                    aid = int(a.get("id", 0))
                    if (
                        not _is_counted(a)
                        or aid in dropped_ids
                        or counted_pts == 0
                        or total_group_weight == 0
                    ):
                        weight_map[aid] = 0.0
                    else:
                        pts = _points(a) or 0.0
                        # Normalize: (gw / total_group_weight) * 100 * (pts / counted_pts)
                        weight_map[aid] = (gw / total_group_weight) * 100.0 * (pts / counted_pts)
    else:
        weight_map = _points_only_weight_map(all_items, group_meta)

    return weight_map, used_fallback


def _points_only_weight_map(
    all_items: list[dict],
    group_meta: dict[int, dict] | None = None,
) -> dict[int, float]:
    """Points-only: weight by fraction of total counted points.

    Shared by the points-only branch of _compute_weights() and by its
    all-group-weights-zero fallback. Drop rules (workstream 1) still apply per
    group in points-only mode: an item a group's rule dropped is excluded from
    the flat total_counted denominator and receives weight 0, via
    group_meta[gid]["dropped_ids"] (see _build_group_meta()). ``all_items``
    entries must already carry "_group_id" (set by _compute_weights()).
    """
    group_meta = group_meta or {}

    def _is_dropped(a: dict) -> bool:
        gid = int(a.get("_group_id", 0))
        dropped_ids = (group_meta.get(gid) or {}).get("dropped_ids") or set()
        return int(a.get("id", 0)) in dropped_ids

    weight_map: dict[int, float] = {}
    total_counted = sum(
        (_points(a) or 0.0)
        for a in all_items
        if _is_counted(a) and not _is_dropped(a)
    )
    for a in all_items:
        aid = int(a.get("id", 0))
        if not _is_counted(a) or _is_dropped(a) or total_counted == 0:
            weight_map[aid] = 0.0
        else:
            pts = _points(a) or 0.0
            weight_map[aid] = (pts / total_counted) * 100.0
    return weight_map


# ---------------------------------------------------------------------------
# Record builder
# ---------------------------------------------------------------------------

# Bloom level index at which auto-graded objective items cannot be deterministically
# assigned via MLO inheritance -- cap at Understand (index 2) instead and emit a
# counterfeit_objective_mlo / bloom_content needs_ai_review item (criterion B).
_BLOOM_INHERITANCE_CAP_INDEX = 4  # Analyze=4; anything >= 4 is capped for objective items
_BLOOM_CAP_LEVEL = "Understand"   # The ceiling level for MLO inheritance onto objective items


def _is_objective_item(record: dict) -> bool:
    """Return True when the record is an auto-graded objective quiz with no rubric.

    Criterion B: these items cannot measure Analyze+ purely from question stems;
    inheriting that level from the module MLO produces a false Bloom signal.
    """
    engine = record.get("engine", "")
    is_quiz_engine = engine in ("classic_quiz", "new_quizzes", "new_quizzes_opaque")
    has_rubric = bool(record.get("has_rubric", False))
    return is_quiz_engine and not has_rubric


def _is_graded_discussion(record: dict) -> bool:
    """Return True when the record is a graded discussion.

    A readings-based discussion is a comprehension/participation instrument; it does
    not reach Analyze+ from a module-max objective alone. Inheriting the module's top
    level (e.g. a "Formulate your own ethic" Create objective onto a personal-intro
    post) over-claims, so its MLO inheritance is capped at Understand.
    """
    return record.get("canvas_type") == "discussion" or record.get("engine") == "graded_discussion"


def _attach_bloom(record: dict, module_mlos: dict) -> None:
    """Attach bloom_level / bloom_index / bloom_source to a record (fill-Unknown).

    Title verb wins when recognized; the record's module MLO level fills only when
    the title is Unknown. Leaves source 'unknown' when neither resolves. Mutates
    the record in place.

    Criterion B (Bloom inheritance cap): an auto-graded objective quiz (engine
    classic_quiz/new_quizzes, no rubric) may inherit at most Understand from its
    module MLO. If the module MLO is Analyze+ (bloom_index >= 4), cap the inherited
    level at Understand and set bloom_mlo_capped=True so score_course can emit a
    counterfeit_objective_mlo needs_ai_review item. A clear title verb still wins
    deterministically -- only MLO inheritance onto objective items is capped.

    Objective floor: an auto-graded objective quiz/exam whose own module carries
    no MLOs (exam-holder or start-here modules, or no module at all) is classified
    Understand deterministically with bloom_source="objective_floor". An auto-graded
    objective instrument cannot exceed Understand, so this is the same ceiling as
    the criterion-B cap; it eliminates Unknown Bloom weight for exam-holder modules
    (verified on course 151110: Exam 1/2 and the Syllabus Quiz).
    """
    _, title_level = classify_blooms(record.get("title") or "")
    if title_level in BLOOMS_LEVELS:
        record["bloom_level"] = title_level
        record["bloom_index"] = BLOOMS_LEVELS[title_level]
        record["bloom_source"] = "title"
        record["bloom_mlo_capped"] = False
        return

    mod = record.get("module")
    mlo = module_mlos.get(mod) if mod else None
    if mlo and mlo.get("bloom_index", 0) > 0:
        mlo_index = mlo["bloom_index"]
        mlo_level = mlo["bloom_level"]
        # Graded discussions cap at Understand when the module MLO is Analyze+.
        # A readings-based discussion does not reach the module's top objective from
        # inheritance alone; Understand is the expected level, so no review item is
        # emitted (this is not a counterfeit signal, unlike the objective-quiz cap).
        if _is_graded_discussion(record) and mlo_index >= _BLOOM_INHERITANCE_CAP_INDEX:
            record["bloom_level"] = _BLOOM_CAP_LEVEL
            record["bloom_index"] = BLOOMS_LEVELS[_BLOOM_CAP_LEVEL]
            record["bloom_source"] = "mlo"
            record["bloom_mlo_capped"] = False
            return
        # Criterion B: cap objective items at Understand when MLO is Analyze+.
        if _is_objective_item(record) and mlo_index >= _BLOOM_INHERITANCE_CAP_INDEX:
            record["bloom_level"] = _BLOOM_CAP_LEVEL
            record["bloom_index"] = BLOOMS_LEVELS[_BLOOM_CAP_LEVEL]
            record["bloom_source"] = "mlo"
            record["bloom_mlo_capped"] = True
            # Store original MLO level for the needs_ai_review item context.
            record["bloom_mlo_original_level"] = mlo_level
            record["bloom_mlo_original_index"] = mlo_index
        else:
            record["bloom_level"] = mlo_level
            record["bloom_index"] = mlo_index
            record["bloom_source"] = "mlo"
            record["bloom_mlo_capped"] = False
        return

    # Objective floor: an auto-graded objective quiz/exam with no resolvable MLO
    # (no module, or a module whose MLOs are all Unknown) classifies as Understand
    # deterministically. An auto-graded objective instrument cannot exceed Understand.
    if _is_objective_item(record):
        record["bloom_level"] = _BLOOM_CAP_LEVEL
        record["bloom_index"] = BLOOMS_LEVELS[_BLOOM_CAP_LEVEL]
        record["bloom_source"] = "objective_floor"
        record["bloom_mlo_capped"] = False
        return

    record["bloom_level"] = "Unknown"
    record["bloom_index"] = 0
    record["bloom_source"] = "unknown"
    record["bloom_mlo_capped"] = False


def _build_record(
    assignment: dict,
    weight: float,
    *,
    quiz_detail: dict | None,
    discussion_topic: dict | None,
    module_name: str | None,
) -> dict:
    """Normalise one assignment (of any canvas_type) into a record."""
    aid = int(assignment.get("id", 0))
    sub_types: list[str] = list(assignment.get("submission_types") or [])

    # Determine canvas_type and engine
    is_new_quiz = _detect_new_quizzes(assignment)
    is_classic_quiz = (
        assignment.get("is_quiz_assignment", False)
        and "online_quiz" in sub_types
        and not is_new_quiz
    )
    is_graded_discussion = "discussion_topic" in assignment or discussion_topic is not None
    is_external_tool = (
        "external_tool" in sub_types
        and not is_new_quiz
    )

    if is_new_quiz:
        canvas_type = "quiz"
        engine = "new_quizzes"
    elif is_classic_quiz:
        canvas_type = "quiz"
        engine = "classic_quiz"
    elif is_graded_discussion:
        canvas_type = "discussion"
        engine = "graded_discussion"
    elif is_external_tool:
        canvas_type = "external_tool"
        engine = "external_tool"
    else:
        canvas_type = "assignment"
        engine = "assignment"

    # Rubric: check assignment object; for quizzes also check quiz_detail
    rubric_present = _has_rubric(assignment)
    if not rubric_present and quiz_detail:
        rubric_present = _has_rubric(quiz_detail)

    # Rubric excerpt (deep-bloom pass): render the assignment's (or quiz's) rubric
    # criteria descriptions into a bounded plain-text excerpt. Rubric long_descriptions
    # are the strongest cognitive-demand signal -- Claude reads them inline to classify
    # the Bloom level the task REQUIRES, rather than the level inherited from the module.
    rubric_criteria = assignment.get("rubric")
    if not rubric_criteria and quiz_detail:
        rubric_criteria = quiz_detail.get("rubric")
    rubric_excerpt = _build_rubric_excerpt(rubric_criteria)

    # Proctoring signal (tri-state: True / False / "unknown")
    proc_signal = _detect_proctoring(assignment, quiz_detail if is_classic_quiz else None)

    # Content excerpt for Bloom grounding (criterion B).
    # Priority: assignment description > quiz_detail description > discussion message.
    # We attach whatever is already fetched; quiz question stems are added later
    # by the Bloom-Unknown pass in build_inventory.
    raw_desc = (
        assignment.get("description")
        or (quiz_detail.get("description") if quiz_detail else None)
        or (discussion_topic.get("message") if discussion_topic else None)
        or ""
    )
    content_excerpt = _strip_html(raw_desc)[:CONTENT_EXCERPT_MAX_CHARS] if raw_desc else ""

    # bloom_content_available (deep-bloom pass): True when prompt text or rubric text
    # was obtained, so Claude can read the task and classify the Bloom level it requires.
    # Provisionally set here from what _build_record has on hand; the quiz-question
    # stem pass in build_inventory may flip it True after attaching stems. It stays
    # False for objective quizzes/exams whose items are bank-locked and expose no stems
    # -- those are marked for format inference, not a content read.
    bloom_content_available = bool(content_excerpt or rubric_excerpt)

    return {
        "id": aid,
        "title": assignment.get("name") or "",
        "canvas_type": canvas_type,
        "engine": engine,
        "points": _points(assignment) or 0.0,
        "grade_weight_pct": round(weight, 4),
        "submission_types": sub_types,
        "has_rubric": rubric_present,
        "peer_reviews": bool(assignment.get("peer_reviews", False)),
        "omit_from_final_grade": bool(assignment.get("omit_from_final_grade", False)),
        "published": _is_published(assignment),
        "module": module_name,
        "group": assignment.get("_group_name"),
        "is_external_tool": bool(is_external_tool or is_new_quiz),
        "proctoring_signal": proc_signal,
        "content_excerpt": content_excerpt,
        "rubric_excerpt": rubric_excerpt,
        "bloom_content_available": bloom_content_available,
        # quiz_id stored so the Bloom-Unknown pass can fetch question stems.
        "_quiz_id": assignment.get("quiz_id"),
    }


# ---------------------------------------------------------------------------
# New Quizzes opaque-degrade path
# ---------------------------------------------------------------------------

def _try_enrich_new_quiz(
    assignment: dict,
    weight: float,
    module_name: str | None,
    config: dict,
    throughput: "_Throughput",
) -> dict:
    """Attempt to fetch New Quizzes detail; degrade gracefully if unavailable.

    New Quizzes does not surface item bank details via PAT.  We do a best-effort
    quiz fetch -- if it fails we set engine='new_quizzes_opaque' and carry on.
    """
    import requests as _req

    quiz_id = assignment.get("quiz_id") or assignment.get("id")
    quiz_detail = None
    if quiz_id:
        throughput.charge()
        try:
            resp = canvas_api._request_with_retry(
                _req.get,
                f"{config['course_url']}/quizzes/{quiz_id}",
                config["headers"],
            )
            if resp and resp.status_code == 200:
                quiz_detail = resp.json()
        except Exception as exc:
            logger.debug("New Quizzes detail fetch failed for %s: %s", quiz_id, exc)

    record = _build_record(
        assignment,
        weight,
        quiz_detail=quiz_detail,
        discussion_topic=None,
        module_name=module_name,
    )
    if quiz_detail is None:
        record["engine"] = "new_quizzes_opaque"
    return record


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------

def build_inventory(config: dict, throughput: "Any | None" = None) -> dict:
    """Build the full normalized assessment inventory for one course.

    Args:
        config: Canvas API config from canvas_api.get_config(course_id=...).
        throughput: Optional externally supplied rate-limiter exposing a
                    .charge() method, for shared call-budget tracking across
                    batch runs.  If None, a lightweight internal counter is
                    used.

    Returns:
        {
          "assessments": list[dict],
          "course_weighting_mode": "weighted_groups" | "points_only",
          "totals": {"counted_points": float, "grade_weight_pct_sum": float},
          "assumptions": list[str],
        }
    """
    canvas_api.require_course_id(config)

    # Use provided throughput or create a lightweight internal tracker
    if throughput is None:
        tp = _Throughput(PER_COURSE_CALL_BUDGET)
    else:
        tp = throughput

    assumptions: list[str] = []

    # -- 1. Course flags -------------------------------------------------
    course_obj = _fetch_course_flags(config, tp)
    weighted_mode = bool(course_obj.get("apply_assignment_group_weights", False))
    weighting_mode_label = "weighted_groups" if weighted_mode else "points_only"
    assumptions.append(f"course_weighting_mode={weighting_mode_label}")
    # Carried through to score_course() so it can skip a second, identical
    # /courses/:id GET for the syllabus body (see _fetch_course_flags above).
    syllabus_html = course_obj.get("syllabus_body") or ""

    # -- 2. Assignment groups (with embedded assignments) ----------------
    groups = _fetch_groups_with_assignments(config, tp)
    if not groups:
        assumptions.append("no_assignment_groups_returned")

    # -- 2b. Drop-rule application + choice-group detection (workstreams 1-2) --
    group_meta = _build_group_meta(groups)

    # -- 3. Classic quiz details (for proctoring + rubric) ---------------
    # Index by the quiz's *assignment* id as well as the quiz id because the
    # assignment object carries quiz_id; the list_quizzes endpoint uses quiz id.
    quizzes_by_quiz_id = _fetch_quizzes_map(config, tp)
    # Build a secondary map from assignment_id -> quiz_detail
    # The assignment carries ``quiz_id`` when it's a classic quiz.
    quizzes_by_assignment_id: dict[int, dict] = {}
    for a_grp in groups:
        for a in a_grp.get("assignments", []):
            qid = a.get("quiz_id")
            if qid and int(qid) in quizzes_by_quiz_id:
                quizzes_by_assignment_id[int(a["id"])] = quizzes_by_quiz_id[int(qid)]

    # -- 4. Discussion topics (for graded discussion join) ---------------
    discussions_by_assignment_id = _fetch_discussions_map(config, tp)

    # -- 5. Module membership map ----------------------------------------
    module_map = _build_module_map(config, tp)

    # -- 5b. Objective grounding (CLOs from syllabus, MLOs from overview pages) --
    # Used to fill the Bloom level of assessments whose title carries no Bloom verb
    # (fill-Unknown only; the title verb wins when present). Graceful degrade: an
    # empty result simply leaves those assessments at Bloom 'Unknown' as before.
    # Reuse the cached module tree _build_module_map already fetched, and charge
    # the extra Canvas calls to the per-course throughput budget.
    try:
        from course_navigator import get_tree as _get_tree
        _mlo_tree = _get_tree(config, force_refresh=False)
    except Exception:
        _mlo_tree = None
    try:
        course_clos = objective_extractor.extract_course_clos(config, throughput=tp)
    except Exception as exc:
        logger.warning("assurance_inventory: CLO extraction failed: %s", exc)
        course_clos = {"clos": [], "bloom_index": 0, "bloom_level": "Unknown", "source": "error"}
    try:
        module_mlos = objective_extractor.extract_module_mlos(config, tree=_mlo_tree, throughput=tp)
    except Exception as exc:
        logger.warning("assurance_inventory: MLO extraction failed: %s", exc)
        module_mlos = {}

    # -- 6. Grade-weight computation (the golden piece) ------------------
    weight_map, weighting_fallback = _compute_weights(groups, weighted_mode, group_meta)
    if weighting_fallback:
        # Visible, not silent: every group_weight was 0 despite weighting
        # being enabled, so weights were computed points-only instead of
        # zeroed. See _compute_weights() docstring.
        assumptions.append("weighting_fallback=points_only:group_weights_all_zero")

    # -- 7. Normalize all assignments to records -------------------------
    records: list[dict] = []
    for group in groups:
        gid = int(group.get("id", 0))
        group_dropped_ids = (group_meta.get(gid) or {}).get("dropped_ids") or set()
        for assignment in group.get("assignments", []):
            aid = int(assignment.get("id", 0))
            weight = weight_map.get(aid, 0.0)

            # Attach group name so _build_record can use it
            assignment.setdefault("_group_name", group.get("name", ""))
            assignment.setdefault("_group_weight", float(group.get("group_weight") or 0.0))

            # Module name: module-item content_id is the assignment id for
            # assignments but the QUIZ id for quizzes, so fall back to the
            # assignment's quiz_id when the assignment-id lookup misses
            # (otherwise every quiz maps to module=None). Keys are namespaced by
            # type so a quiz_id can't collide with an unrelated assignment id.
            mod_name = module_map.get(("Assignment", aid))
            if mod_name is None:
                qid = assignment.get("quiz_id")
                if qid:
                    mod_name = module_map.get(("Quiz", int(qid)))
            # Graded discussions appear as module items of type "Discussion"
            # whose content_id is the discussion topic id (NOT the backing
            # assignment id). Without this lookup every graded discussion that is
            # a module item resolves to module=None and loses its module MLO
            # Bloom level (verified on course 151110: 8 graded discussions).
            if mod_name is None:
                disc_topic = discussions_by_assignment_id.get(aid)
                if disc_topic:
                    topic_id = disc_topic.get("id")
                    if topic_id:
                        mod_name = module_map.get(("Discussion", int(topic_id)))

            if _detect_new_quizzes(assignment):
                record = _try_enrich_new_quiz(assignment, weight, mod_name, config, tp)
            else:
                quiz_detail = quizzes_by_assignment_id.get(aid)
                disc_topic = discussions_by_assignment_id.get(aid)
                record = _build_record(
                    assignment,
                    weight,
                    quiz_detail=quiz_detail,
                    discussion_topic=disc_topic,
                    module_name=mod_name,
                )

            # Workstream 1: mark items a group's drop_lowest/drop_highest rule
            # excluded from the counted-points denominator. dropped_by_rule
            # items always carry grade_weight_pct=0 (see _compute_weights()).
            record["dropped_by_rule"] = aid in group_dropped_ids

            _attach_bloom(record, module_mlos)
            records.append(record)

    # -- 7b. Quiz-question stem fetch (criterion B) ----------------------
    # For counted classic quizzes (weight > 0) whose Bloom is not grounded in a
    # title verb or module MLO -- i.e. still Unknown, or floored to Understand by
    # the objective floor -- fetch the first MAX_QUIZ_QUESTIONS_TO_FETCH question
    # stems and attach them as content_excerpt. This grounds the report's evidence
    # and lets Claude's inline judgment confirm the Bloom level. The objective
    # floor is a deterministic ceiling, not a content read; the excerpt is still
    # worth attaching for the same bounded one-call cost.
    # Bounded: skip if quiz has no _quiz_id (shouldn't happen for classic quizzes).
    for rec in records:
        if (
            rec.get("bloom_source") in ("unknown", "objective_floor")
            and rec.get("engine") == "classic_quiz"
            and rec.get("grade_weight_pct", 0.0) > 0.0
            and not rec.get("content_excerpt")
            and rec.get("_quiz_id")
        ):
            excerpt = _fetch_quiz_questions_excerpt(config, rec["_quiz_id"], tp)
            if excerpt:
                rec["content_excerpt"] = excerpt
                # Stems were exposed -- the task can be read, so a content classification
                # is possible. Bank-locked exams/quizzes return no stems and stay False.
                rec["bloom_content_available"] = True

    # -- 8. Totals and assertions ----------------------------------------
    counted_points = sum(
        r["points"]
        for r in records
        if not r["omit_from_final_grade"] and r["published"] and r["points"] > 0
    )
    weight_sum = sum(r["grade_weight_pct"] for r in records)

    if records and abs(weight_sum - 100.0) > WEIGHT_SUM_TOLERANCE:
        logger.warning(
            "Grade weight sum is %.2f%% (expected ~100%%). "
            "Possible course configuration issue (e.g. group weights do not sum to 100).",
            weight_sum,
        )
        assumptions.append(f"weight_sum_off_by={round(weight_sum - 100.0, 2)}")

    assumptions.append(f"per_course_api_calls={tp.calls}")
    assumptions.append(f"total_records={len(records)}")

    bloom_source_counts = {"title": 0, "mlo": 0, "unknown": 0}
    for r in records:
        bloom_source_counts[r.get("bloom_source", "unknown")] = (
            bloom_source_counts.get(r.get("bloom_source", "unknown"), 0) + 1
        )
    assumptions.append(
        f"bloom_grounding=title:{bloom_source_counts['title']},"
        f"mlo:{bloom_source_counts['mlo']},unknown:{bloom_source_counts['unknown']}"
    )

    # -- 9. Group meta output (workstreams 1-2: drops + choice-group flag) --
    # JSON-serializable: dropped_ids goes from a set to a sorted list. Sorted
    # by group id so the output is deterministic across runs.
    groups_output: list[dict] = []
    for gid in sorted(group_meta.keys()):
        gm = dict(group_meta[gid])
        gm["dropped_ids"] = sorted(gm.get("dropped_ids") or [])
        groups_output.append(gm)

    return {
        "assessments": records,
        "course_weighting_mode": weighting_mode_label,
        "groups": groups_output,
        "totals": {
            "counted_points": counted_points,
            "grade_weight_pct_sum": round(weight_sum, 4),
        },
        "course_clos": course_clos,
        "module_mlos": module_mlos,
        "syllabus_html": syllabus_html,
        "assumptions": assumptions,
    }


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import argparse
    import json

    parser = argparse.ArgumentParser(
        description="Build a normalized, weight-aware assessment inventory for a Canvas course."
    )
    parser.add_argument("--course-id", required=True, help="Canvas course ID")
    parser.add_argument(
        "--out",
        default="-",
        help="Output path for JSON (default: stdout)",
    )
    args = parser.parse_args()

    cfg = canvas_api.get_config(course_id=args.course_id)
    result = build_inventory(cfg)

    output = json.dumps(result, indent=2)
    if args.out == "-":
        print(output)
    else:
        with open(args.out, "w", encoding="utf-8") as fh:
            fh.write(output)
        print(f"Inventory written to {args.out}")
