#!/usr/bin/env python3
"""Phase A /alignment skill orchestrator.

Scrape phase writes a raw extraction manifest for Claude to fill with
verbatim CLO/MLO extractions. Report phase reads the filled manifest,
builds an alignment graph, and renders a local HTML report.
"""

from __future__ import annotations

import argparse
import html
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

SCRIPT_DIR = Path(__file__).resolve().parent
PLUGIN_ROOT = SCRIPT_DIR.parent
DEFAULT_STANDARDS = PLUGIN_ROOT / "standards" / "asu-design-standards.yaml"

sys.path.insert(0, str(SCRIPT_DIR))

import alignment_validator  # noqa: E402
import canvas_api  # noqa: E402
from blooms import BLOOMS_LEVELS, is_measurable  # noqa: E402
from html_utils import strip_html as _strip_html_shared  # noqa: E402
from mlo_extractor import parse_objectives  # noqa: E402


_HEADER_BLOCK_RE = re.compile(
    r"(?is)<(?P<tag>h[234])\b[^>]*>(?P<body>.*?)</(?P=tag)>"
    r"|<p\b[^>]*>\s*<strong\b[^>]*>(?P<strong>.*?)</strong>\s*</p>"
)

_CLO_HEADER_GROUPS = [
    (("Course Learning Outcomes", "Learning Outcomes", "Course Outcomes"), "high"),
    (("Course Objectives",), "low"),
    (("Student Learning Outcomes", "CLO"), "high"),
]
_MLO_HEADERS = ("Learning Objectives", "Module Objectives", "Module Outcomes")
_TOPIC_ONLY_HEADERS = ("Topics", "Topics Covered", "Key Cases", "Key Concepts", "Key Terms")


def _default_manifest_path(course_id: str) -> Path:
    return PLUGIN_ROOT / "courses" / str(course_id) / "staging" / "alignment" / "extract_manifest.json"


def _report_path(course_id: str) -> Path:
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    report_dir = PLUGIN_ROOT / "courses" / str(course_id) / "reports"
    report_dir.mkdir(parents=True, exist_ok=True)
    return report_dir / f"{stamp}-alignment.html"


def _remediation_brief_path(course_id: str) -> Path:
    return PLUGIN_ROOT / "courses" / str(course_id) / "staging" / "alignment" / "remediation_brief.json"


def _load_course_config(course_id: str) -> dict:
    """Load course config using the module-level ``PLUGIN_ROOT``.

    Uses PLUGIN_ROOT directly (rather than delegating to config_loader) so
    that tests that monkeypatch ``alignment_skill.PLUGIN_ROOT`` continue to
    resolve the config path correctly.  Falls back to cwd/course-config.json
    when the per-course path is absent.
    """
    config_path = PLUGIN_ROOT / "courses" / str(course_id) / "course-config.json"
    if not config_path.exists():
        cwd_fallback = Path.cwd() / "course-config.json"
        if cwd_fallback.exists():
            config_path = cwd_fallback
        else:
            return {}
    try:
        return json.loads(config_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return {}


def _declared_clos(course_config: dict) -> list[dict]:
    return [
        {"id": item.get("id", f"CLO-{idx}"), "text": item.get("text", "")}
        for idx, item in enumerate(course_config.get("clos", []), start=1)
        if item.get("text")
    ]


def _declared_mlos(course_config: dict) -> dict[str, list[dict]]:
    declared: dict[str, list[dict]] = {}
    for module in course_config.get("modules", []):
        module_number = str(module.get("number", ""))
        if not module_number:
            continue
        for idx, objective in enumerate(module.get("objectives", []), start=1):
            text = objective.get("text", "")
            if not text:
                continue
            clo_ref = objective.get("clo", [])
            if isinstance(clo_ref, str):
                clo_ref = [clo_ref]
            declared.setdefault(module_number, []).append({
                "id": objective.get("id", f"M{module_number}.{idx}"),
                "text": text,
                "clo_ids": [cid for cid in clo_ref if cid],
            })
    return declared


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


def _header_text_matches(header_text: str, allowed_headers: tuple[str, ...]) -> bool:
    normalized = re.sub(r"[:\s]+$", "", _strip_html_text(header_text)).lower()
    return normalized in {item.lower() for item in allowed_headers}


def _iter_matching_headers(source_html: str, allowed_headers: tuple[str, ...]):
    for match in _HEADER_BLOCK_RE.finditer(source_html or ""):
        header_html = match.group("body") if match.group("body") is not None else match.group("strong")
        header_text = _strip_html_text(header_html)
        if _header_text_matches(header_text, allowed_headers):
            yield match, re.sub(r"[:\s]+$", "", header_text).strip()


def _first_following_ul(source_html: str, start_pos: int) -> Optional[str]:
    tail = (source_html or "")[start_pos:]
    next_header = _HEADER_BLOCK_RE.search(tail)
    section_html = tail[:next_header.start()] if next_header else tail
    ul_match = re.search(r"(?is)<ul\b[^>]*>(.*?)</ul>", section_html)
    if not ul_match:
        return None
    return ul_match.group(1)


def _extract_li_texts(ul_inner_html: str) -> list[str]:
    items = []
    for li_match in re.finditer(r"(?is)<li\b[^>]*>(.*?)</li>", ul_inner_html or ""):
        text = _strip_html_text(li_match.group(1))
        if text:
            items.append(text)
    return items


def regex_extract_clos(syllabus_html: str, syllabus_url: str) -> tuple[list[dict], str]:
    """Multi-pattern regex CLO extraction. Returns (clos, header_used)."""
    for headers, confidence in _CLO_HEADER_GROUPS:
        for header_match, header_text in _iter_matching_headers(syllabus_html, headers):
            ul_html = _first_following_ul(syllabus_html, header_match.end())
            if ul_html is None:
                continue
            clos = [
                {
                    "text": text,
                    "page_url": syllabus_url,
                    "confidence": confidence,
                    "index": idx,
                    "extraction_method": "regex",
                }
                for idx, text in enumerate(_extract_li_texts(ul_html), start=1)
            ]
            if clos:
                return clos, header_text
    return [], "none"


def regex_extract_mlos(html: str, module_id: int, page_url: str) -> tuple[list[dict], str]:
    """Multi-pattern regex MLO extraction. Returns (mlos, classification)."""
    for header_match, _header_text in _iter_matching_headers(html, _MLO_HEADERS):
        ul_html = _first_following_ul(html, header_match.end())
        mlos = []
        if ul_html is not None:
            mlos = [
                {
                    "text": text,
                    "module_id": module_id,
                    "page_url": page_url,
                    "confidence": "high",
                    "index": idx,
                    "extraction_method": "regex",
                }
                for idx, text in enumerate(_extract_li_texts(ul_html), start=1)
            ]
        return mlos, "mlo_section"

    if any(True for _match in _iter_matching_headers(html, _TOPIC_ONLY_HEADERS)):
        return [], "topics_only"

    stripped_len = len(_strip_html_text(html))
    if stripped_len >= 200:
        return [], "prose_only"
    return [], "empty"


def _tree_assessments(tree: list[dict]) -> list[dict]:
    assessments = []
    for module in tree:
        module_id = module.get("id")
        for item in module.get("items", []):
            item_type = item.get("type")
            if item_type not in {"Quiz", "Discussion"}:
                continue
            assessments.append({
                "id": f"{item_type.lower()}-{item.get('content_id') or item.get('id')}",
                "title": item.get("title", ""),
                "type": "formative" if item_type == "Quiz" else "summative",
                "canvas_type": item_type,
                "canvas_id": item.get("content_id"),
                "module": module_id or 0,
                "module_id": module_id,
                "html_url": item.get("html_url", ""),
            })
    return assessments


def _assignment_module_lookup(tree: list[dict]) -> dict[int, int]:
    lookup = {}
    for module in tree:
        module_id = module.get("id") or 0
        for item in module.get("items", []):
            if item.get("type") == "Assignment" and item.get("content_id") is not None:
                lookup[item["content_id"]] = module_id
    return lookup


def _assignment_assessments(assignments: list[dict], module_lookup: Optional[dict[int, int]] = None) -> list[dict]:
    module_lookup = module_lookup or {}
    normalized = []
    for item in assignments:
        module_id = module_lookup.get(item.get("id"), item.get("module") or item.get("module_position") or 0)
        normalized.append({
            "id": f"assignment-{item.get('id')}",
            "title": item.get("name") or item.get("title") or "",
            "type": "summative",
            "canvas_type": "Assignment",
            "canvas_id": item.get("id"),
            "module": module_id,
            "module_id": module_id,
            "points": item.get("points_possible", 0),
            "html_url": item.get("html_url", ""),
            "description": item.get("description", ""),
        })
    return normalized


def _materials_from_tree(tree: list[dict]) -> list[dict]:
    materials = []
    for module in tree:
        module_id = module.get("id")
        for item in module.get("items", []):
            item_type = item.get("type", "")
            if item_type in {"Quiz", "Assignment", "Discussion", "SubHeader"}:
                continue
            materials.append({
                "id": f"{item_type.lower()}-{item.get('content_id') or item.get('id')}",
                "title": item.get("title", ""),
                "type": item_type.lower(),
                "canvas_type": item_type,
                "module": module_id or 0,
                "module_id": module_id,
                "page_url": item.get("page_url", ""),
                "html_url": item.get("html_url") or item.get("external_url", ""),
            })
    return materials


def run_scrape(course_id: str, manifest_path: Path, standards_path: Path) -> Path:
    canvas_api.validate_course_id(course_id)
    config = canvas_api.get_config(course_id=course_id)

    syllabus = canvas_api.get_syllabus(config)
    tree = _fetch_course_tree(config)

    module_overviews = {}
    modules = []
    regex_mlos_by_module = {}
    for module in tree:
        module_id = module.get("id")
        if module_id is None:
            continue
        overview = canvas_api.get_module_overview_page(config, module_id)
        overview_page_url = ""
        classification = "empty"
        if overview:
            overview["module_name"] = module.get("name", "")
            overview["module_position"] = module.get("position")
            module_overviews[str(module_id)] = overview
            overview_page_url = overview.get("page_url", "")
            regex_mlos, classification = regex_extract_mlos(
                overview.get("html", ""),
                module_id,
                overview_page_url,
            )
        else:
            regex_mlos, classification = regex_extract_mlos("", module_id, "")
        regex_mlos_by_module[str(module_id)] = regex_mlos
        modules.append({
            "id": module_id,
            "name": module.get("name", ""),
            "position": module.get("position"),
            "classification": classification,
            "overview_page_url": overview_page_url,
        })

    assignments = canvas_api.list_assignments(config)
    assessments = _assignment_assessments(assignments, _assignment_module_lookup(tree)) + _tree_assessments(tree)
    materials = _materials_from_tree(tree)
    course_config = _load_course_config(course_id)
    regex_clos, regex_clo_header = regex_extract_clos(syllabus.get("html", ""), syllabus.get("page_url", ""))

    manifest = {
        "course_id": str(course_id),
        "course_name": course_config.get("course", {}).get("title") or f"Course {course_id}",
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "standards_path": str(standards_path),
        "syllabus_html": syllabus.get("html", ""),
        "syllabus_url": syllabus.get("page_url", ""),
        "syllabus_updated_at": syllabus.get("updated_at"),
        "modules": modules,
        "module_overviews_html": module_overviews,
        "assessments": assessments,
        "materials": materials,
        "extracted_clos_regex_pass": regex_clos,
        "extracted_clos_regex_header_used": regex_clo_header,
        "extracted_mlos_by_module_regex_pass": regex_mlos_by_module,
        "extracted_clos": [],
        "extracted_mlos_by_module": {
            str(module["id"]): []
            for module in modules
            if module.get("classification") in {"topics_only", "prose_only", "empty"}
        },
    }

    declared_clos = _declared_clos(course_config)
    declared_mlos = _declared_mlos(course_config)
    if declared_clos:
        manifest["declared_clos"] = declared_clos
    if declared_mlos:
        manifest["declared_mlos"] = declared_mlos

    manifest_path.parent.mkdir(parents=True, exist_ok=True)
    manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
    print(f"Manifest written: {manifest_path.resolve()}")
    print("Claude next step: fill extracted_clos and extracted_mlos_by_module with verbatim quotes and confidence flags, then rerun with --phase report.")
    return manifest_path


def _fetch_course_tree(config: dict) -> list[dict]:
    from course_navigator import fetch_course_tree

    return fetch_course_tree(config)


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


def _dedupe_extractions(items: list[dict]) -> list[dict]:
    seen = set()
    deduped = []
    for item in items:
        key = (_norm(item.get("text", "")), item.get("page_url", ""))
        if not key[0] or key in seen:
            continue
        seen.add(key)
        deduped.append(item)
    return deduped


def _manifest_with_effective_extractions(manifest: dict) -> dict:
    effective = dict(manifest)
    effective["extracted_clos"] = _dedupe_extractions(
        list(manifest.get("extracted_clos_regex_pass", [])) + list(manifest.get("extracted_clos", []))
    )

    combined_mlos = {}
    regex_mlos = manifest.get("extracted_mlos_by_module_regex_pass", {})
    fallback_mlos = manifest.get("extracted_mlos_by_module", {})
    for module_id in sorted(set(regex_mlos.keys()) | set(fallback_mlos.keys()), key=lambda value: str(value)):
        combined_mlos[str(module_id)] = _dedupe_extractions(
            list(regex_mlos.get(module_id, [])) + list(fallback_mlos.get(module_id, []))
        )
    effective["extracted_mlos_by_module"] = combined_mlos
    return effective


def _node_from_extraction(prefix: str, index: int, item: dict, module: Optional[int] = None) -> dict:
    text = item.get("text", "")
    parsed = parse_objectives([text])[0]
    measurable, _ = is_measurable(text)
    node = {
        "id": f"{prefix}-{index}",
        "text": text,
        "measurable_verb": parsed["verb"],
        "is_measurable": measurable,
        "blooms_level": parsed["bloom_level"],
        "source": "extracted",
        "page_url": item.get("page_url", ""),
        "confidence": item.get("confidence", "low"),
    }
    if prefix == "CLO":
        node["mlo_ids"] = []
    else:
        node["module"] = module or 0
        node["clo_ids"] = []
    return node


def _build_graph_from_manifest(manifest: dict) -> dict:
    clos = [
        _node_from_extraction("CLO", idx, item)
        for idx, item in enumerate(manifest.get("extracted_clos", []), start=1)
        if item.get("text")
    ]

    mlos = []
    module_mlo_counts = {}
    for module_id, items in manifest.get("extracted_mlos_by_module", {}).items():
        try:
            module_number = int(module_id)
        except (TypeError, ValueError):
            module_number = 0
        for item in items:
            if not item.get("text"):
                continue
            module_mlo_counts[module_number] = module_mlo_counts.get(module_number, 0) + 1
            node = _node_from_extraction(
                f"M{module_number}",
                module_mlo_counts[module_number],
                item,
                module=module_number,
            )
            mlos.append(node)

    materials = []
    for item in manifest.get("materials", []):
        material = dict(item)
        material.setdefault("mlo_ids", [])
        material.setdefault("source", "scraped")
        materials.append(material)

    assessments = []
    for item in manifest.get("assessments", []):
        assessment = dict(item)
        assessment.setdefault("mlo_ids", [])
        assessment.setdefault("clo_ids", [])
        assessment.setdefault("source", "scraped")
        assessments.append(assessment)

    declared_mlos = [
        mlo
        for module_items in manifest.get("declared_mlos", {}).values()
        for mlo in module_items
    ]
    alignment_validator.wire_extracted_relationships(
        clos,
        mlos,
        materials,
        assessments,
        declared_mlos=declared_mlos,
    )

    graph = {
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "source": "alignment-skill-phase-a",
        "clos": clos,
        "mlos": mlos,
        "materials": materials,
        "assessments": assessments,
    }
    graph["gaps"] = alignment_validator.analyze_gaps(graph)
    graph["coverage"] = alignment_validator.calculate_coverage(graph)
    graph["validation_issues"] = alignment_validator.validate_graph(graph)
    return graph


def _compare_declared_extracted(manifest: dict) -> list[dict]:
    findings = []
    extracted_clos = {_norm(item.get("text")) for item in manifest.get("extracted_clos", []) if item.get("text")}
    declared_clos = manifest.get("declared_clos", [])
    for item in declared_clos:
        if _norm(item.get("text")) and _norm(item.get("text")) not in extracted_clos:
            findings.append({
                "severity": "warning",
                "category": "Declared/extracted divergence",
                "evidence": item.get("text", ""),
                "url": "",
                "confidence": "high",
                "recommendation": "Review placeholder: confirm whether this declared CLO appears in the live syllabus.",
            })

    declared_mlo_texts = {
        _norm(item.get("text"))
        for module_items in manifest.get("declared_mlos", {}).values()
        for item in module_items
        if item.get("text")
    }
    for module_id, items in manifest.get("extracted_mlos_by_module", {}).items():
        for item in items:
            if _norm(item.get("text")) and declared_mlo_texts and _norm(item.get("text")) not in declared_mlo_texts:
                findings.append({
                    "severity": "warning",
                    "category": "Declared/extracted divergence",
                    "evidence": item.get("text", ""),
                    "url": item.get("page_url", ""),
                    "confidence": item.get("confidence", "low"),
                    "recommendation": f"Review placeholder: compare extracted Module {module_id} objective against course-config.json.",
                })
    return findings


def _module_number(module: dict):
    value = module.get("position")
    if value in (None, ""):
        value = module.get("id", "")
    try:
        return int(value)
    except (TypeError, ValueError):
        return value


def _module_url(module: dict, manifest: dict) -> str:
    url = module.get("overview_page_url", "")
    if url:
        return url
    overview = manifest.get("module_overviews_html", {}).get(str(module.get("id")), {})
    return overview.get("page_url", "")


def _module_completeness_findings(manifest: dict) -> list[dict]:
    findings = []
    for module in manifest.get("modules", []):
        classification = module.get("classification", "")
        module_number = _module_number(module)
        module_label = f"Module {module_number}"
        url = _module_url(module, manifest)
        base = {
            "category": "Module completeness",
            "url": url,
            "confidence": "high",
            "module": module_number,
        }
        if classification == "mlo_section":
            findings.append({
                **base,
                "severity": "INFO",
                "criterion_id": f"alignment.M{module_number}.mlo-section",
                "finding": f"{module_label} has an explicit Learning Objectives section.",
                "evidence": f"{module_label} has an explicit Learning Objectives section.",
                "recommendation": "",
            })
        elif classification == "topics_only":
            findings.append({
                **base,
                "severity": "WARN",
                "criterion_id": f"alignment.M{module_number}.no-mlo-section",
                "finding": f"{module_label} has Topics list but no measurable Learning Objectives section.",
                "evidence": f"{module_label} has Topics list but no measurable Learning Objectives section.",
                "recommendation": "Add <h2>Learning Objectives</h2> with <ul> listing observable outcomes.",
            })
        elif classification == "prose_only":
            findings.append({
                **base,
                "severity": "WARN",
                "criterion_id": f"alignment.M{module_number}.no-mlo-section",
                "finding": f"{module_label} has narrative overview but no explicit Learning Objectives section.",
                "evidence": f"{module_label} has narrative overview but no explicit Learning Objectives section.",
                "recommendation": "Add one or run /course-build to scaffold.",
            })
        elif classification == "empty":
            findings.append({
                **base,
                "severity": "FAIL",
                "criterion_id": f"alignment.M{module_number}.empty-overview",
                "finding": f"{module_label} overview page has insufficient content. Build module overview before alignment can be assessed.",
                "evidence": f"{module_label} overview page has insufficient content. Build module overview before alignment can be assessed.",
                "recommendation": "Build module overview before alignment can be assessed.",
            })
    return findings


def _module_sort_key(value) -> tuple[int, str]:
    try:
        return (0, f"{int(value):06d}")
    except (TypeError, ValueError):
        return (1, str(value))


def _bloom_progression_rows_and_findings(graph: Optional[dict]) -> tuple[list[dict], list[dict]]:
    if not graph:
        return [], []

    module_levels: dict[object, set[str]] = {}
    for mlo in graph.get("mlos", []):
        module = mlo.get("module")
        level = mlo.get("blooms_level", "Unknown")
        if BLOOMS_LEVELS.get(level, 0) > 0:
            module_levels.setdefault(module, set()).add(level)

    coverage_modules = graph.get("coverage", {}).get("blooms_by_module", {}).keys()
    modules = sorted(set(module_levels.keys()) | set(coverage_modules), key=_module_sort_key)
    rows = []
    findings = []
    prior_max = 0
    for module in modules:
        levels = sorted(module_levels.get(module, set()), key=lambda item: BLOOMS_LEVELS.get(item, 0))
        max_level_value = max((BLOOMS_LEVELS.get(level, 0) for level in levels), default=0)
        highest = next((level for level in reversed(levels) if BLOOMS_LEVELS.get(level, 0) == max_level_value), "Unknown")
        flag = "OK"
        if max_level_value and prior_max and max_level_value < prior_max:
            flag = "WARN"
            findings.append({
                "severity": "WARN",
                "category": "Bloom progression",
                "criterion_id": f"alignment.M{module}.bloom-regression",
                "finding": f"Module {module} Bloom ceiling lower than expected at this course position.",
                "evidence": f"Module {module} Bloom ceiling lower than expected at this course position.",
                "url": "",
                "confidence": "high",
                "module": module,
                "recommendation": "Review module objectives so later modules reach Apply/Analyze/Evaluate/Create where appropriate.",
            })
        prior_max = max(prior_max, max_level_value)
        rows.append({
            "module": module,
            "levels_present": ", ".join(levels) if levels else "Unknown",
            "highest": highest,
            "progression_flag": flag,
        })
    return rows, findings


def _handoff_banners(manifest: dict) -> list[str]:
    banners = []
    no_regex_clos = not manifest.get("extracted_clos_regex_pass")
    no_declared_clos = not manifest.get("declared_clos")
    if no_regex_clos and no_declared_clos:
        banners.append(
            '<div class="handoff-banner critical">'
            "<strong>No course-level objectives found in syllabus.</strong> "
            "This course has no declared CLOs anywhere — staged or in Canvas. "
            'Run <a href="claude://invoke/course-build">/course-build</a> '
            "to scaffold a <code>course-config.json</code> from your syllabus PDF or planning doc. "
            "The alignment audit cannot map MLOs → CLOs without a CLO baseline."
            "</div>"
        )

    mlo_count = sum(len(items) for items in manifest.get("extracted_mlos_by_module", {}).values())
    if mlo_count == 0:
        banners.append(
            '<div class="handoff-banner critical">'
            "<strong>No module-level objectives found.</strong> "
            "This course has 0 extracted MLOs across all modules. "
            'Run <a href="claude://invoke/course-build">/course-build</a> or manually author measurable '
            "Learning Objectives sections before alignment can be assessed."
            "</div>"
        )
    return banners


def _brief_severity(value: str) -> str:
    normalized = str(value or "").strip().upper()
    if normalized in {"ERROR", "FAIL", "FAILED"}:
        return "FAIL"
    if normalized in {"WARNING", "WARN"}:
        return "WARN"
    return normalized


def _write_remediation_brief(course_id: str, findings: list[dict]) -> Path:
    criteria = []
    for idx, finding in enumerate(findings, start=1):
        severity = _brief_severity(finding.get("severity", ""))
        if severity not in {"WARN", "FAIL"}:
            continue
        criteria.append({
            "criterion_id": finding.get("criterion_id") or f"alignment.finding-{idx}",
            "module": finding.get("module"),
            "severity": severity,
            "finding": finding.get("finding") or finding.get("evidence", ""),
            "recommendation": finding.get("recommendation", ""),
            "evidence_url": finding.get("url", ""),
        })

    brief = {
        "source": "alignment",
        "course_id": str(course_id),
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "criteria_to_remediate": criteria,
    }
    out_path = _remediation_brief_path(course_id)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(json.dumps(brief, indent=2), encoding="utf-8")
    return out_path


def _findings_from_graph(manifest: dict, graph: dict, divergence_findings: list[dict]) -> list[dict]:
    findings = list(divergence_findings)

    for item in manifest.get("extracted_clos", []):
        if item.get("confidence") == "low":
            findings.append({
                "severity": "info",
                "category": "Low-confidence extraction",
                "evidence": item.get("text", ""),
                "url": item.get("page_url", ""),
                "confidence": "low",
                "recommendation": "Review placeholder: verify whether this is a true CLO.",
            })

    for module_id, items in manifest.get("extracted_mlos_by_module", {}).items():
        for item in items:
            if item.get("confidence") == "low":
                findings.append({
                    "severity": "info",
                    "category": "Low-confidence extraction",
                    "evidence": item.get("text", ""),
                    "url": item.get("page_url", ""),
                    "confidence": "low",
                    "recommendation": f"Review placeholder: verify whether this is a true MLO for module {module_id}.",
                })

    for issue in graph.get("validation_issues", []):
        findings.append({
            "severity": issue.get("level", "warning"),
            "category": "Graph validation",
            "evidence": issue.get("message", ""),
            "url": "",
            "confidence": "high",
            "recommendation": "Review placeholder: inspect the mapped relationship.",
        })

    gap_labels = {
        "unmapped_clos": "CLO has no MLO mapping",
        "unmapped_mlos": "MLO has no CLO mapping",
        "orphan_materials": "Material has no MLO mapping",
        "orphan_assessments": "Assessment has no MLO mapping",
        "clos_without_summative": "CLO lacks summative assessment coverage",
        "modules_without_formative": "Module lacks formative assessment",
    }
    for key, label in gap_labels.items():
        for value in graph.get("gaps", {}).get(key, []):
            findings.append({
                "severity": "warning",
                "category": label,
                "evidence": str(value),
                "url": "",
                "confidence": "high",
                "recommendation": "Review placeholder: add or document the missing alignment relationship.",
            })
    return findings


def _render_report(manifest: dict, graph: Optional[dict], findings: list[dict], out_path: Path, extraction_failed: bool = False) -> None:
    clos_count = len(manifest.get("extracted_clos", []))
    mlo_count = sum(len(items) for items in manifest.get("extracted_mlos_by_module", {}).values())
    assessment_count = len(manifest.get("assessments", []))
    material_count = len(manifest.get("materials", []))
    divergence_count = sum(1 for item in findings if item.get("category") == "Declared/extracted divergence")

    rows = []
    for finding in findings:
        confidence = finding.get("confidence", "")
        flag = "LOW CONFIDENCE" if str(confidence).lower() == "low" else str(confidence).upper()
        url = finding.get("url", "")
        evidence = html.escape(finding.get("evidence", ""))
        link = f'<a href="{html.escape(url)}">{html.escape(url)}</a>' if url else ""
        rows.append(
            "<tr>"
            f"<td>{html.escape(finding.get('severity', ''))}</td>"
            f"<td>{html.escape(finding.get('category', ''))}</td>"
            f"<td><blockquote>{evidence}</blockquote>{link}</td>"
            f"<td>{html.escape(flag)}</td>"
            f"<td>{html.escape(finding.get('recommendation', ''))}</td>"
            "</tr>"
        )
    if not rows:
        rows.append('<tr><td colspan="5">No findings generated.</td></tr>')

    matrix_rows = []
    if graph:
        assessments = graph.get("assessments", [])
        for clo in graph.get("clos", []):
            mapped_mlos = [m for m in graph.get("mlos", []) if m.get("id") in clo.get("mlo_ids", [])]
            if not mapped_mlos:
                matrix_rows.append(
                    f"<tr><td>{html.escape(clo['id'])}: {html.escape(clo.get('text', ''))}</td><td colspan=\"3\">No mapped MLOs</td></tr>"
                )
                continue
            for mlo in mapped_mlos:
                mapped_assessments = [
                    a.get("title", a.get("id", ""))
                    for a in assessments
                    if mlo.get("id") in a.get("mlo_ids", [])
                ]
                matrix_rows.append(
                    "<tr>"
                    f"<td>{html.escape(clo['id'])}: {html.escape(clo.get('text', ''))}</td>"
                    f"<td>{html.escape(mlo.get('id', ''))}: {html.escape(mlo.get('text', ''))}</td>"
                    f"<td>{html.escape(', '.join(mapped_assessments) or 'No mapped assessments')}</td>"
                    f"<td>{html.escape(str(clo.get('confidence', '')))}</td>"
                    "</tr>"
                )
    if not matrix_rows:
        matrix_rows.append('<tr><td colspan="4">No alignment matrix available.</td></tr>')

    bloom_rows = []
    for row in _bloom_progression_rows_and_findings(graph)[0]:
        bloom_rows.append(
            "<tr>"
            f"<td>{html.escape(str(row.get('module', '')))}</td>"
            f"<td>{html.escape(row.get('levels_present', 'Unknown'))}</td>"
            f"<td>{html.escape(row.get('highest', 'Unknown'))}</td>"
            f"<td>{html.escape(row.get('progression_flag', 'OK'))}</td>"
            "</tr>"
        )
    if not bloom_rows:
        bloom_rows.append('<tr><td colspan="4">No Bloom data available.</td></tr>')

    banner_html = "".join(_handoff_banners(manifest))
    status = "Extraction failed: no syllabus content and no module overview HTML were available." if extraction_failed else "Extraction completed."
    html_doc = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Alignment Report - {html.escape(manifest.get('course_name', 'Course'))}</title>
  <style>
    body {{ font-family: Arial, sans-serif; color: #191919; margin: 32px; line-height: 1.45; }}
    h1, h2 {{ color: #202020; }}
    table {{ border-collapse: collapse; width: 100%; margin: 16px 0 28px; }}
    th, td {{ border: 1px solid #c8c8c8; padding: 8px; vertical-align: top; }}
    th {{ background: #f2f2f2; text-align: left; }}
    blockquote {{ margin: 0 0 6px; padding-left: 10px; border-left: 3px solid #8c1d40; }}
    .summary {{ display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 8px; }}
    .summary div {{ border: 1px solid #c8c8c8; padding: 10px; background: #fafafa; }}
    .status {{ padding: 10px; background: #fff7e6; border: 1px solid #d6a100; }}
    .handoff-banner {{ margin: 12px 0; padding: 14px; border: 2px solid #8c1d40; background: #fff4f6; }}
    .handoff-banner a {{ color: #8c1d40; font-weight: bold; }}
  </style>
</head>
<body>
  <h1>Alignment Report</h1>
  {banner_html}
  <p><strong>Course:</strong> {html.escape(manifest.get('course_name', 'Course'))} ({html.escape(str(manifest.get('course_id', '')))}).</p>
  <p><strong>Generated:</strong> {html.escape(datetime.now().isoformat(timespec='seconds'))}</p>
  <p class="status">{html.escape(status)}</p>

  <h2>Extraction Summary</h2>
  <div class="summary">
    <div><strong>{clos_count}</strong><br>CLOs</div>
    <div><strong>{mlo_count}</strong><br>MLOs</div>
    <div><strong>{assessment_count}</strong><br>Assessments</div>
    <div><strong>{material_count}</strong><br>Materials</div>
    <div><strong>{divergence_count}</strong><br>Divergences</div>
  </div>

  <h2>Findings</h2>
  <table>
    <thead><tr><th>Severity</th><th>Category</th><th>Evidence</th><th>Confidence</th><th>Recommendation</th></tr></thead>
    <tbody>{''.join(rows)}</tbody>
  </table>

  <h2>CLO/MLO/Assessment Matrix</h2>
  <table>
    <thead><tr><th>CLO</th><th>MLO</th><th>Assessments</th><th>Confidence</th></tr></thead>
    <tbody>{''.join(matrix_rows)}</tbody>
  </table>

  <h2>Bloom Progression</h2>
  <table>
    <thead><tr><th>Module</th><th>Bloom levels present</th><th>Highest</th><th>Progression flag</th></tr></thead>
    <tbody>{''.join(bloom_rows)}</tbody>
  </table>
</body>
</html>
"""
    out_path.write_text(html_doc, encoding="utf-8")


def run_report(course_id: str, manifest_path: Path) -> Path:
    canvas_api.validate_course_id(course_id)
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    effective_manifest = _manifest_with_effective_extractions(manifest)
    extracted_clos = effective_manifest.get("extracted_clos", [])
    extracted_mlos = effective_manifest.get("extracted_mlos_by_module", {})
    has_raw_source = bool(manifest.get("syllabus_html")) or any(
        (value or {}).get("html") for value in manifest.get("module_overviews_html", {}).values()
    )
    out_path = _report_path(course_id)

    if not extracted_clos or not any(extracted_mlos.values()):
        if not has_raw_source:
            findings = _module_completeness_findings(manifest) + [{
                "severity": "error",
                "category": "Extraction failed",
                "evidence": "No syllabus content or module overview HTML was available for CLO/MLO extraction.",
                "url": "",
                "confidence": "high",
                "recommendation": "Review placeholder: verify Canvas syllabus and module overview pages are published and accessible.",
            }]
            _write_remediation_brief(course_id, findings)
            _render_report(effective_manifest, None, findings, out_path, extraction_failed=True)
            print(str(out_path.resolve()))
            return out_path

    graph = _build_graph_from_manifest(effective_manifest)
    divergence_findings = _compare_declared_extracted(effective_manifest)
    _bloom_rows, bloom_findings = _bloom_progression_rows_and_findings(graph)
    findings = (
        _module_completeness_findings(manifest)
        + _findings_from_graph(effective_manifest, graph, divergence_findings)
        + bloom_findings
    )
    _write_remediation_brief(course_id, findings)
    _render_report(effective_manifest, graph, findings, out_path)
    print(str(out_path.resolve()))
    return out_path


def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Run Phase A /alignment scrape or report workflow.")
    parser.add_argument("--course-id", required=True, help="Canvas course ID")
    parser.add_argument("--phase", required=True, choices=("scrape", "report"), help="Workflow phase")
    parser.add_argument("--manifest-path", help="Manifest path (default: courses/{id}/staging/alignment/extract_manifest.json)")
    parser.add_argument("--standards", default=str(DEFAULT_STANDARDS), help="ASU design standards YAML path")
    return parser.parse_args(argv)


def main(argv: Optional[list[str]] = None) -> int:
    args = parse_args(argv)
    manifest_path = Path(args.manifest_path) if args.manifest_path else _default_manifest_path(args.course_id)
    standards_path = Path(args.standards)
    try:
        if args.phase == "scrape":
            run_scrape(args.course_id, manifest_path, standards_path)
        else:
            run_report(args.course_id, manifest_path)
    except ValueError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    except FileNotFoundError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    except Exception as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
