#!/usr/bin/env python3
"""Audit MLO over-claim: flag assessments that declare MLO alignment without evidence.

An "over-claim" occurs when an assessment is linked to an MLO but no graded
items/tasks in that assessment provide measurable evidence for that MLO.

DATA MODEL LIMITATION:
    ASUCB's course-config.json and Canvas API do not expose per-question or
    per-task MLO tags. Assessment-to-MLO wiring in the alignment graph is
    performed exclusively by *module co-location inference*: every assessment in
    module N is linked to every MLO in module N.

    Given this constraint, this auditor checks two conditions that ARE
    detectable from the available data:

    1. EXPLICIT CROSS-MODULE OVER-CLAIM (Config-level):
       An assessment has an explicitly declared `mlo_ids` list in course-config
       that references an MLO ID whose owning module differs from the assessment's
       own module. This means the declared alignment is structurally impossible
       to evidence within the assessment's own module.

    2. EMPTY-MLO MODULE OVER-CLAIM (Structure-level):
       A module has assessments (graded items) but zero declared objectives
       (MLOs). The assessments implicitly claim to measure "something" but no
       MLO exists to be measured -- the assessment outcome is undeclared. This
       is effectively an over-claim at the course-config design level.

    Per-item evidence checks (question-to-MLO tagging) require a richer data
    model (e.g., Canvas New Quizzes Item Tagging or a custom rubric criterion
    taxonomy). That check is deferred -- see skills/alignment/SKILL.md note on
    item-level evidence limits.

Emits findings via the backlog/criterion-evaluator path, mirroring
audit_clo_orphans.py.

Severity:
    - Cross-module explicit over-claim  -> "warning" (structural defect, fixable)
    - Empty-MLO module with assessments -> "warning" (design gap, fixable)
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
PLUGIN_ROOT = SCRIPT_DIR.parent
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from config_loader import load_course_config  # noqa: E402


def _module_id(module):
    return module.get("id") or module.get("number") or module.get("module_id")


def _module_name(module):
    return module.get("title") or module.get("name") or f"Module {_module_id(module)}"


def _as_list(value):
    if value is None:
        return []
    if isinstance(value, list):
        return value
    return [value]


def _has_assessments(module):
    """Return True if the module has any graded assessment defined."""
    assessments = module.get("assessments", [])
    if isinstance(assessments, dict):
        for key in ("knowledge_check", "quiz", "artifact", "assignment", "discussion",
                    "guided_practice"):
            val = assessments.get(key)
            if val:
                if isinstance(val, dict):
                    # guided_practice is only "real" when it has points
                    if key == "guided_practice":
                        try:
                            if float(val.get("points") or val.get("points_possible") or 0) > 0:
                                return True
                        except (TypeError, ValueError):
                            pass
                    else:
                        return True
                elif val:
                    return True
    elif isinstance(assessments, list):
        for item in assessments:
            if not isinstance(item, dict):
                continue
            item_type = str(item.get("type") or "").lower()
            if item_type in ("quiz", "knowledge_check", "assignment", "artifact", "discussion"):
                return True
            if item_type == "guided_practice":
                try:
                    if float(item.get("points") or item.get("points_possible") or 0) > 0:
                        return True
                except (TypeError, ValueError):
                    pass
    # Also check module items list (sparse format)
    for item in module.get("items", []) or []:
        if not isinstance(item, dict):
            continue
        if str(item.get("type") or "").lower() in ("quiz", "assignment", "discussion"):
            return True
    return False


def audit_mlo_overclaim(course_id=None, course_config=None, config_path=None):
    """Return MLO over-claim findings from course-config.json.

    Returns a list of finding dicts, each with:
        issue_type  - "overclaim_cross_module" | "overclaim_empty_mlos"
        detail      - human-readable description
        module_id   - the module where the issue was detected
        module_name - human-readable module name
        assessment_id (overclaim_cross_module only) - the assessment slug
        mlo_id        (overclaim_cross_module only) - the orphaned MLO reference
    """
    if course_config is None:
        course_config = load_course_config(course_id=course_id, config_path=config_path)

    findings = []

    # ---------------------------------------------------------------------------
    # MLO field names supported across the codebase
    # (confirmed from alignment_graph_preview.py, alignment_mode_b.py,
    #  alignment_validator.py, audit_mlo_drift.py, and live course-configs):
    #   - "objectives"                 dict-items  {id, text, ...}
    #   - "mlos"                       string-items OR dict-items {id, text, ...}
    #   - "mlos_draft"                 dict-items  {id, text, ...}
    #   - "module_learning_objectives" dict-items  {id, text, ...}
    #   - "learning_objectives"        dict-items  {id, text, ...}
    # All fields are unioned; the same ID registered from multiple fields is
    # harmless (last-write wins for the module ownership lookup, which is the
    # same module either way).
    # ---------------------------------------------------------------------------

    _MLO_FIELDS = (
        "objectives",
        "mlos",
        "mlos_draft",
        "module_learning_objectives",
        "learning_objectives",
    )

    def _iter_module_mlos(module):
        """Yield (mlo_id_str, raw_entry) for every MLO in a module across all
        supported field names.  Plain strings (used by the ``mlos`` field in some
        configs) receive a generated ID so they participate in the ownership map."""
        mod_id_local = _module_id(module)
        global_index = 0
        for field in _MLO_FIELDS:
            for entry in _as_list(module.get(field) or []):
                global_index += 1
                if isinstance(entry, dict):
                    mlo_id = entry.get("id") or f"M{mod_id_local}.{global_index}"
                    yield str(mlo_id), entry
                elif entry:
                    # Plain string (common in "mlos" field)
                    yield f"M{mod_id_local}.{global_index}", entry

    def _module_has_any_mlos(module):
        """Return True if the module declares at least one non-empty MLO."""
        for _id, raw in _iter_module_mlos(module):
            if isinstance(raw, dict):
                if raw.get("text") or raw.get("id"):
                    return True
            elif raw:  # non-empty string
                return True
        return False

    # Build a lookup: MLO ID -> owning module ID
    mlo_to_module: dict[str, object] = {}
    for module in course_config.get("modules", []) or []:
        if not isinstance(module, dict):
            continue
        mod_id = _module_id(module)
        for mlo_id_str, _entry in _iter_module_mlos(module):
            mlo_to_module[mlo_id_str] = mod_id

    # Helper: iterate all assessment dicts from a module's ``assessments`` field,
    # regardless of whether it is a list-of-dicts or a dict-of-named-dicts.
    # Matches the dual-shape handling in alignment_validator.py (extract_assessments)
    # and alignment_graph_preview.py.
    def _iter_assessment_dicts(module):
        """Yield (asmt_key, asmt_dict) for every assessment entry in the module."""
        raw = module.get("assessments")
        if isinstance(raw, list):
            for idx, item in enumerate(raw):
                if isinstance(item, dict):
                    key = item.get("id") or item.get("title") or f"asmt-{_module_id(module)}-{idx}"
                    yield str(key), item
        elif isinstance(raw, dict):
            for named_key, item in raw.items():
                if isinstance(item, dict):
                    key = item.get("id") or item.get("title") or named_key
                    yield str(key), item

    # Check 1: explicit cross-module mlo_ids on assessments in course-config
    for module in course_config.get("modules", []) or []:
        if not isinstance(module, dict):
            continue
        mod_id = _module_id(module)
        mod_name = _module_name(module)

        # Both list-shaped and dict-shaped assessments are iterated
        for asmt_id, asmt in _iter_assessment_dicts(module):
            declared_mlo_ids = _as_list(
                asmt.get("mlo_ids") or asmt.get("objectives") or asmt.get("mlos")
            )
            for mlo_id in declared_mlo_ids:
                mlo_id_str = str(mlo_id)
                owning_mod = mlo_to_module.get(mlo_id_str)
                if owning_mod is not None and str(owning_mod) != str(mod_id):
                    findings.append({
                        "issue_type": "overclaim_cross_module",
                        "detail": (
                            f"Assessment '{asmt_id}' in module {mod_id} ({mod_name}) "
                            f"declares alignment to MLO '{mlo_id_str}' but that MLO "
                            f"belongs to module {owning_mod}. No graded items in this "
                            f"assessment can evidence an out-of-module objective."
                        ),
                        "module_id": mod_id,
                        "module_name": mod_name,
                        "assessment_id": str(asmt_id),
                        "mlo_id": mlo_id_str,
                    })

        # Check 2: module has assessments but no objectives (empty-MLO over-claim)
        if not _module_has_any_mlos(module) and _has_assessments(module):
            findings.append({
                "issue_type": "overclaim_empty_mlos",
                "detail": (
                    f"Module {mod_id} ({mod_name}) has assessments but no declared "
                    f"module learning objectives (MLOs). Assessments implicitly claim "
                    f"to measure learning outcomes that have not been defined. "
                    f"Add MLOs to this module or remove the assessments."
                ),
                "module_id": mod_id,
                "module_name": mod_name,
            })

    return findings


def main():
    parser = argparse.ArgumentParser(
        description="Audit MLO over-claim: assessments declaring unevidenced MLO alignment"
    )
    parser.add_argument("--course-id", required=True, help="Canvas course ID")
    parser.add_argument("--output", choices=["text", "json"], default="text")
    parser.add_argument("--config", help="Optional course-config.json path")
    args = parser.parse_args()

    findings = audit_mlo_overclaim(course_id=args.course_id, config_path=args.config)
    if args.output == "json":
        print(json.dumps(findings, indent=2))
        return
    if not findings:
        print("No MLO over-claim findings.")
        return
    for finding in findings:
        mod = finding.get("module_id", "?")
        print(f"Module {mod}: {finding['issue_type']} - {finding['detail']}")


if __name__ == "__main__":
    main()
