#!/usr/bin/env python3
"""Audit module learning objectives against assessment evidence."""

from __future__ import annotations

import argparse
import json
import re
import sys
from datetime import datetime, timezone
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))

import backlog_manager  # noqa: E402
import canvas_api  # noqa: E402
from blooms import BLOOMS_LEVELS, BLOOMS_VERBS  # noqa: E402
from html_utils import strip_html as _strip_html_shared  # noqa: E402


STOPWORDS = {
    "about", "after", "also", "and", "are", "can", "for", "from", "how",
    "into", "that", "the", "their", "these", "this", "through", "using",
    "with", "within", "your",
}
VERB_TO_LEVEL = {
    verb: level
    for level, verbs in BLOOMS_VERBS.items()
    for verb in verbs
}


def _now_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def _timestamp() -> str:
    return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")


def _strip_html(value: str) -> str:
    """Strip HTML tags and decode entities.  Thin alias — delegates to :func:`html_utils.strip_html`."""
    return _strip_html_shared(value)


def _normalize(value: str) -> str:
    return re.sub(r"\s+", " ", str(value or "").lower()).strip()


def _tokens(value: str) -> set[str]:
    return {
        token
        for token in re.findall(r"[a-z0-9]+", _normalize(value))
        if len(token) >= 4 and token not in STOPWORDS
    }


def _level_value(level: str) -> int:
    return BLOOMS_LEVELS.get(str(level or ""), 0)


def _normalize_verb(word: str) -> str:
    word = re.sub(r"[^a-z]", "", str(word or "").lower())
    candidates = [word]
    if word.endswith("ies") and len(word) > 4:
        candidates.append(word[:-3] + "y")
    if word.endswith("es") and len(word) > 3:
        candidates.extend([word[:-1], word[:-2], word[:-2] + "e"])
    if word.endswith("s") and len(word) > 3:
        candidates.append(word[:-1])
    if word.endswith("ing") and len(word) > 5:
        candidates.extend([word[:-3], word[:-3] + "e"])
    for candidate in candidates:
        if candidate in VERB_TO_LEVEL:
            return candidate
    return word


def extract_verb_objects(text: str) -> list[dict]:
    """Extract a lightweight Bloom verb and object phrase from text."""
    cleaned = _strip_html(text)
    cleaned = re.sub(
        r"^(?:CLO-?\d+|MLO-?\d+|M\d+[\.\d]*|[\d]+[\.\d]*)\s*[:\-\s]\s*",
        "",
        cleaned,
        flags=re.IGNORECASE,
    )
    words = re.findall(r"[A-Za-z][A-Za-z'-]*", cleaned)
    if not words:
        return []

    verb_index = 0
    verb = _normalize_verb(words[0])
    for idx, word in enumerate(words):
        candidate = _normalize_verb(word)
        if candidate in VERB_TO_LEVEL:
            verb_index = idx
            verb = candidate
            break
    obj = " ".join(words[verb_index + 1:])
    return [{
        "verb": verb,
        "object": obj,
        "level": VERB_TO_LEVEL.get(verb, "Unknown"),
    }]


def _first_bloom_level(text: str) -> str:
    for pair in extract_verb_objects(text):
        if pair.get("level") in BLOOMS_LEVELS:
            return pair["level"]
    return "Unknown"


def course_config_path(course_id: str) -> Path:
    """Resolve the config path for *course_id*, raising FileNotFoundError on miss.

    Uses the module-level ``PLUGIN_ROOT`` (patchable in tests via monkeypatch)
    so that test fixtures that monkeypatch ``audit_mlo_drift.PLUGIN_ROOT``
    continue to work correctly.

    Search order:
        1. ``PLUGIN_ROOT/courses/{course_id}/course-config.json``
        2. ``<cwd>/course-config.json``
    """
    course_path = PLUGIN_ROOT / "courses" / str(course_id) / "course-config.json"
    if course_path.exists():
        return course_path
    fallback = Path.cwd() / "course-config.json"
    if fallback.exists():
        return fallback
    raise FileNotFoundError(
        f"No course-config.json found for course {course_id} "
        f"at {course_path} or {fallback}"
    )


def load_course_config(course_id: str, config_path: str | Path | None = None) -> tuple[dict, Path]:
    """Load config and return ``(dict, Path)``.

    Returns the tuple because callers in this module need the resolved Path
    for reporting.  Uses :func:`course_config_path` for resolution so that
    ``PLUGIN_ROOT`` monkeypatching in tests is respected.
    """
    if config_path is not None:
        path = Path(config_path)
    else:
        path = course_config_path(course_id)
    return json.loads(path.read_text(encoding="utf-8")), path


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


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


def extract_mlos(course_config: dict) -> list[dict]:
    mlos = []
    for module in course_config.get("modules", []):
        if not isinstance(module, dict):
            continue
        module_number = module.get("number") or module.get("module_number") or module.get("id") or 0
        for idx, objective in enumerate(module.get("objectives", []), start=1):
            if isinstance(objective, dict):
                text = objective.get("text", "")
                mlo_id = objective.get("id") or f"M{module_number}.{idx}"
            else:
                text = str(objective)
                mlo_id = f"M{module_number}.{idx}"
            text = _strip_html(text)
            if not text:
                continue
            pair = (extract_verb_objects(text) or [{"verb": "", "object": "", "level": "Unknown"}])[0]
            mlos.append({
                "id": mlo_id,
                "module": module_number,
                "module_id": _module_id(module),
                "module_name": _module_name(module),
                "text": text,
                "verb": pair.get("verb", ""),
                "object_phrase": pair.get("object", ""),
                "bloom_level": pair.get("level", "Unknown"),
            })
    return mlos


def _assessment_text(assessment: dict) -> str:
    return " ".join(
        _strip_html(assessment.get(key, ""))
        for key in ("title", "description", "prompt", "text", "instructions", "question_text")
        if assessment.get(key)
    ).strip()


def _assessment_bloom_level(assessment: dict) -> str:
    explicit = assessment.get("bloom_level") or assessment.get("blooms_level")
    if explicit in BLOOMS_LEVELS:
        return explicit
    return _first_bloom_level(_assessment_text(assessment))


def _normalize_assessment(item: dict, canvas_type: str) -> dict:
    if canvas_type == "assignment":
        title = item.get("name") or item.get("title") or ""
        description = item.get("description") or ""
    elif canvas_type == "quiz":
        title = item.get("title") or item.get("name") or ""
        description = item.get("description") or item.get("body") or ""
    else:
        title = item.get("title") or item.get("name") or ""
        description = item.get("message") or item.get("description") or ""
    assessment = {
        "id": f"{canvas_type}-{item.get('id', '')}",
        "canvas_id": item.get("id"),
        "canvas_type": canvas_type,
        "title": title,
        "description": _strip_html(description),
        "html_url": item.get("html_url", ""),
    }
    assessment["bloom_level"] = _assessment_bloom_level({**item, **assessment})
    return assessment


def fetch_canvas_assessments(course_id: str) -> list[dict]:
    config = canvas_api.get_config(course_id=course_id)
    assessments = []
    for item in canvas_api.list_assignments(config):
        assessments.append(_normalize_assessment(item, "assignment"))
    for item in canvas_api.list_quizzes(config):
        assessments.append(_normalize_assessment(item, "quiz"))
    for item in canvas_api.list_discussions(config):
        assessments.append(_normalize_assessment(item, "discussion"))
    return assessments


def _matches_mlo(mlo: dict, assessment: dict) -> bool:
    haystack = _normalize(_assessment_text(assessment))
    mlo_id = _normalize(mlo.get("id", ""))
    if mlo_id and mlo_id in haystack:
        return True

    object_phrase = _normalize(mlo.get("object_phrase", ""))
    if object_phrase and len(object_phrase) >= 12 and object_phrase in haystack:
        return True

    objective_tokens = _tokens(mlo.get("object_phrase") or mlo.get("text", ""))
    if not objective_tokens:
        return False
    assessment_tokens = _tokens(haystack)
    overlap = objective_tokens & assessment_tokens
    required = min(3, len(objective_tokens))
    return len(overlap) >= required


def detect_drift(mlos: list[dict], assessments: list[dict]) -> list[dict]:
    """Legacy drift detector used by report/backlog tests."""
    drift = []
    for mlo in mlos:
        matches = [assessment for assessment in assessments if _matches_mlo(mlo, assessment)]
        if not matches:
            drift.append({
                "type": "missing_assessment",
                "finding_type": "mlo_rewrite",
                "module": mlo.get("module_name") or mlo.get("module"),
                "module_id": mlo.get("module_id") or mlo.get("module"),
                "mlo_id": mlo["id"],
                "mlo_text": mlo["text"],
                "mlo_bloom_level": mlo["bloom_level"],
                "description": f"{mlo['id']} has no corresponding Canvas assessment.",
            })
            continue

        mlo_level = _level_value(mlo.get("bloom_level"))
        strongest = max(matches, key=lambda item: _level_value(item.get("bloom_level")))
        if mlo_level > 0 and _level_value(strongest.get("bloom_level")) < mlo_level:
            drift.append({
                "type": "lower_bloom_assessment",
                "finding_type": "mlo_rewrite",
                "module": mlo.get("module_name") or mlo.get("module"),
                "module_id": mlo.get("module_id") or mlo.get("module"),
                "mlo_id": mlo["id"],
                "mlo_text": mlo["text"],
                "mlo_bloom_level": mlo["bloom_level"],
                "assessment_id": strongest.get("id"),
                "assessment_title": strongest.get("title", ""),
                "assessment_bloom_level": strongest.get("bloom_level", "Unknown"),
                "description": (
                    f"{mlo['id']} expects {mlo['bloom_level']} but matched "
                    f"assessment '{strongest.get('title', '')}' is "
                    f"{strongest.get('bloom_level', 'Unknown')}."
                ),
            })
    return drift


def _config_assessment_items(module: dict) -> list[dict]:
    items = []

    def add_item(item_type, name, text):
        text = _strip_html(text)
        if text:
            items.append({"type": item_type, "name": name or item_type, "text": text})

    assessments = module.get("assessments", [])
    if isinstance(assessments, dict):
        kc = assessments.get("knowledge_check") or assessments.get("quiz")
        if isinstance(kc, dict):
            for idx, question in enumerate(kc.get("questions", []) or [], start=1):
                if isinstance(question, dict):
                    add_item("quiz", question.get("name") or f"Question {idx}",
                             question.get("question_text") or question.get("text") or question.get("prompt"))
                else:
                    add_item("quiz", f"Question {idx}", question)
            add_item("quiz", kc.get("title") or "Knowledge Check",
                     kc.get("focus") or kc.get("prompt") or kc.get("description"))
        for key in ("artifact", "assignment", "guided_practice", "discussion"):
            value = assessments.get(key)
            if isinstance(value, dict):
                add_item(
                    "discussion" if key == "discussion" else "assignment",
                    value.get("title") or key.replace("_", " ").title(),
                    value.get("prompt")
                    or value.get("description")
                    or value.get("instructions")
                    or value.get("focus")
                    or value.get("title"),
                )
    elif isinstance(assessments, list):
        for idx, value in enumerate(assessments, start=1):
            if not isinstance(value, dict):
                add_item("assessment", f"Assessment {idx}", value)
                continue
            item_type = str(value.get("type") or "assessment").lower()
            if item_type in ("quiz", "knowledge_check"):
                for q_idx, question in enumerate(value.get("questions", []) or [], start=1):
                    if isinstance(question, dict):
                        add_item("quiz", question.get("name") or f"Question {q_idx}",
                                 question.get("question_text") or question.get("text") or question.get("prompt"))
                    else:
                        add_item("quiz", f"Question {q_idx}", question)
            add_item(
                "quiz" if item_type in ("quiz", "knowledge_check") else "assignment",
                value.get("title") or f"Assessment {idx}",
                value.get("prompt")
                or value.get("description")
                or value.get("instructions")
                or value.get("focus")
                or value.get("title"),
            )
    return items


def audit_mlo_drift(course_id=None, course_config=None, config_path=None,
                    assessments=None) -> list[dict]:
    """Return Phase 2 MLO drift findings as list-of-dicts."""
    if course_config is None:
        try:
            course_config, _path = load_course_config(course_id, config_path=config_path)
        except FileNotFoundError:
            return []

    findings = []
    mlos_by_module = {}
    for mlo in extract_mlos(course_config):
        mlos_by_module.setdefault(mlo["module"], []).append(mlo)

    for module in course_config.get("modules", []) or []:
        if not isinstance(module, dict):
            continue
        module_key = module.get("number") or module.get("module_number") or module.get("id") or 0
        mlos = mlos_by_module.get(module_key, [])
        if not mlos:
            continue
        mlo_verbs = sorted({mlo["verb"] for mlo in mlos if mlo.get("verb")})
        mlo_levels = {mlo["bloom_level"] for mlo in mlos if mlo.get("bloom_level") in BLOOMS_LEVELS}
        mlo_level_nums = {_level_value(level) for level in mlo_levels}
        mlo_terms = set()
        mlo_text = " ".join(mlo.get("text", "") for mlo in mlos if mlo.get("text"))
        for mlo in mlos:
            mlo_terms.update(_tokens(mlo.get("object_phrase") or mlo.get("text", "")))

        items = _config_assessment_items(module)
        if assessments is not None:
            items = [
                {
                    "type": assessment.get("canvas_type") or assessment.get("type") or "assessment",
                    "name": assessment.get("title") or assessment.get("name") or "Assessment",
                    "text": _assessment_text(assessment),
                }
                for assessment in assessments
                if isinstance(assessment, dict) and _assessment_text(assessment)
            ]
        if not items:
            findings.append({
                "finding_type": "mlo_rewrite",
                "module": _module_name(module),
                "module_id": _module_id(module),
                "module_name": _module_name(module),
                "mlo_text": mlo_text,
                "mlo_verbs": mlo_verbs,
                "assessment_verbs": [],
                "drift_type": "missing_assessment",
                "issue_type": "missing_assessment",
                "detail": "Module has MLOs but no quiz, assignment, or discussion assessment item.",
            })
            continue

        for item in items:
            pairs = extract_verb_objects(item["text"])
            if not pairs:
                continue
            assessment_verbs = sorted({pair["verb"] for pair in pairs if pair.get("verb")})
            assessment_levels = {pair["level"] for pair in pairs if pair.get("level") in BLOOMS_LEVELS}
            assessment_level_nums = {_level_value(level) for level in assessment_levels}
            if mlo_level_nums and assessment_level_nums:
                uncovered = sorted(level for level in assessment_levels if level not in mlo_levels)
                if uncovered and max(assessment_level_nums) > max(mlo_level_nums):
                    findings.append({
                        "finding_type": "mlo_rewrite",
                        "module": _module_name(module),
                        "module_id": _module_id(module),
                        "module_name": _module_name(module),
                        "mlo_text": mlo_text,
                        "mlo_verbs": mlo_verbs,
                        "assessment_verbs": assessment_verbs,
                        "drift_type": "bloom_level_drift",
                        "issue_type": "bloom_level_drift",
                        "detail": (
                            f"{item['name']} uses Bloom level(s) {', '.join(uncovered)} "
                            f"above the MLO coverage ({', '.join(sorted(mlo_levels))})."
                        ),
                    })

            assessment_terms = set()
            for pair in pairs:
                assessment_terms.update(_tokens(pair.get("object", "")))
            if mlo_terms and assessment_terms and not (mlo_terms & assessment_terms):
                findings.append({
                    "finding_type": "mlo_rewrite",
                    "module": _module_name(module),
                    "module_id": _module_id(module),
                    "module_name": _module_name(module),
                    "mlo_text": mlo_text,
                    "mlo_verbs": mlo_verbs,
                    "assessment_verbs": assessment_verbs,
                    "drift_type": "topic_drift",
                    "issue_type": "topic_drift",
                    "detail": (
                        f"{item['name']} addresses topic(s) not found in MLO objects: "
                        f"{', '.join(sorted(assessment_terms - mlo_terms)[:8])}."
                    ),
                })
    return findings


def report_path(course_id: str) -> Path:
    path = PLUGIN_ROOT / "courses" / str(course_id) / "reports"
    path.mkdir(parents=True, exist_ok=True)
    return path / f"mlo-drift-{_timestamp()}.json"


def append_backlog_items(course_id: str, drift_items: list[dict], report_file: Path) -> list[dict]:
    records = []
    for item in drift_items:
        record = backlog_manager.add(course_id, {
            "type": "drift",
            "finding_type": "mlo_rewrite",
            "source": "audit_mlo_drift.py",
            "issue_type": item["type"],
            "detail": item["description"],
            "description": item["description"],
            "module": item.get("module") or item.get("module_name") or "",
            "module_id": item.get("module_id", ""),
            "mlo_id": item.get("mlo_id", ""),
            "mlo_text": item.get("mlo_text", ""),
            "priority": "medium",
            "severity": "warning",
            "report_path": str(report_file),
        })
        records.append(record)
    return records


def run_audit(course_id: str) -> dict:
    course_id = canvas_api.validate_course_id(course_id)
    course_config, config_path = load_course_config(course_id)
    mlos = extract_mlos(course_config)
    assessments = fetch_canvas_assessments(course_id)
    drift_items = detect_drift(mlos, assessments)

    output_path = report_path(course_id)
    report = {
        "course_id": course_id,
        "generated_at": _now_iso(),
        "course_config_path": str(config_path),
        "mlo_count": len(mlos),
        "assessment_count": len(assessments),
        "drift_count": len(drift_items),
        "mlos": mlos,
        "assessments": assessments,
        "drift_items": drift_items,
    }
    output_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    backlog_records = append_backlog_items(course_id, drift_items, output_path)
    report["report_path"] = str(output_path)
    report["backlog_item_ids"] = [item["id"] for item in backlog_records]
    output_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    return report


def main() -> None:
    parser = argparse.ArgumentParser(description="Audit MLO drift against assessment items.")
    parser.add_argument("--course-id", required=True)
    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_drift(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 drift findings.")
        return
    for finding in findings:
        print(f"{finding['module_name']}: {finding['drift_type']} - {finding['detail']}")


if __name__ == "__main__":
    main()
