"""Tests for the local alignment preview graph skeleton."""

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "scripts"))


def _fixture_config():
    return {
        "course": {"id": "999", "name": "Preview Course"},
        "clos": [
            {"id": "CLO1", "label": "Interpretation", "text": "Interpret fitted statistical models."},
            {"id": "CLO2", "label": "Communication", "text": "Explain statistical findings to audiences."},
        ],
        "modules": [
            {
                "number": 1,
                "title": "Module 1",
                "mlos_draft": [
                    {
                        "id": "MLO1.1",
                        "text": "Interpret a fitted regression coefficient in context.",
                        "ladders_to": "CLO1",
                    }
                ],
                "learning_materials": [
                    {
                        "title": "Regression reading",
                        "type": "reading",
                        "text": "Textbook section on regression coefficients.",
                        "mlo_ids": ["MLO1.1"],
                    }
                ],
                "assessments": [
                    {
                        "type": "quiz",
                        "title": "M1 Quiz",
                        "mlo_ids": ["MLO1.1"],
                        "questions": [
                            {
                                "id": "Q1",
                                "text": "Which interpretation of the slope is best?",
                                "mlo_ids": ["MLO1.1"],
                            },
                            {
                                "id": "Q2",
                                "text": "Which answer is numerically largest?",
                            },
                        ],
                    },
                    {
                        "type": "assignment",
                        "title": "M1 Homework",
                        "mlo_ids": ["MLO1.1"],
                        "rubric": [
                            {
                                "id": "criterion-interpretation",
                                "text": "Interprets the coefficient with units and context.",
                                "mlo_ids": ["MLO1.1"],
                            }
                        ],
                    },
                ],
            },
            {
                "number": 2,
                "title": "Module 2",
                "mlos_draft": [
                    {
                        "id": "MLO2.1",
                        "text": "Explain statistical findings in plain language.",
                        "ladders_to": "CLO2",
                    }
                ],
            },
        ],
    }


def test_build_graph_from_course_config_has_typed_nodes_edges_and_no_schema_errors():
    import alignment_graph_preview as agp

    graph = agp.build_graph_from_course_config(_fixture_config())

    assert graph["schema_version"] == agp.SCHEMA_VERSION
    assert graph["course_id"] == "999"
    assert "not silently overwritten" in graph["conflict_policy"]
    assert graph["validation_issues"] == []
    node_types = {node["type"] for node in graph["nodes"]}
    assert {"clo", "mlo", "reading", "quiz", "quiz_question", "assignment", "rubric_criterion"} <= node_types
    edge_types = {edge["type"] for edge in graph["edges"]}
    assert {"aligns_to", "supports", "assesses", "contains", "needs_review"} <= edge_types
    assert all(edge["confidence"] in agp.CONFIDENCE_VALUES for edge in graph["edges"])
    assert all(edge["evidence"] for edge in graph["edges"])


def test_stable_ids_are_repeatable_across_runs():
    import alignment_graph_preview as agp

    first = agp.build_graph_from_course_config(_fixture_config())
    second = agp.build_graph_from_course_config(_fixture_config())

    assert [node["id"] for node in first["nodes"]] == [node["id"] for node in second["nodes"]]
    assert [edge["id"] for edge in first["edges"]] == [edge["id"] for edge in second["edges"]]


def test_gap_detection_flags_unassessed_module_and_question_needing_review():
    import alignment_graph_preview as agp

    graph = agp.build_graph_from_course_config(_fixture_config())

    assert graph["gaps"]["mlo_without_assessment"]
    assert any("mlo2-1" in item for item in graph["gaps"]["mlo_without_assessment"])
    assert graph["gaps"]["quiz_question_without_mlo"]
    assert graph["gaps"]["ambiguous_edges"]


def test_cli_writes_default_alignment_graph_path(tmp_path, monkeypatch):
    import alignment_graph_preview as agp

    course_dir = tmp_path / "courses" / "999"
    course_dir.mkdir(parents=True)
    (course_dir / "course-config.json").write_text(json.dumps(_fixture_config()), encoding="utf-8")
    monkeypatch.setattr(agp, "PLUGIN_ROOT", tmp_path)

    assert agp.main(["--course-id", "999"]) == 0
    graph_path = tmp_path / "courses" / "999" / "alignment" / "graph.json"
    assert graph_path.exists()
    graph = json.loads(graph_path.read_text(encoding="utf-8"))
    assert graph["course_id"] == "999"
    assert graph["source_mode"] == "local-course-config"


def _canvas_tree_fixture():
    return [
        {
            "id": 10,
            "name": "Module 1",
            "position": 1,
            "items": [
                {
                    "id": 100,
                    "title": "Regression reading",
                    "type": "Page",
                    "page_url": "regression-reading",
                    "html_url": "https://canvas.test/courses/999/pages/regression-reading",
                },
                {
                    "id": 101,
                    "title": "M1 Quiz",
                    "type": "Quiz",
                    "content_id": 201,
                    "html_url": "https://canvas.test/courses/999/quizzes/201",
                },
                {
                    "id": 102,
                    "title": "M1 Homework",
                    "type": "Assignment",
                    "content_id": 301,
                    "html_url": "https://canvas.test/courses/999/assignments/301",
                },
                {
                    "id": 103,
                    "title": "Module discussion",
                    "type": "Discussion",
                    "content_id": 401,
                    "html_url": "https://canvas.test/courses/999/discussion_topics/401",
                },
            ],
        }
    ]


def test_canvas_readonly_extractor_adds_canvas_nodes_questions_and_rubrics(monkeypatch):
    import alignment_graph_preview as agp
    import canvas_api
    import course_navigator

    config = {
        "course_id": "999",
        "domain": "canvas.test",
        "course_url": "https://canvas.test/api/v1/courses/999",
        "headers": {},
    }
    monkeypatch.setattr(course_navigator, "fetch_course_tree", lambda _config: _canvas_tree_fixture())
    monkeypatch.setattr(canvas_api, "list_assignments", lambda _config: [
        {
            "id": 301,
            "name": "M1 Homework",
            "description": "Canvas homework wording differs from config.",
            "html_url": "https://canvas.test/courses/999/assignments/301",
            "points_possible": 10,
            "rubric": [
                {
                    "id": "crit-1",
                    "description": "Interpretation",
                    "long_description": "Explains the slope with context.",
                }
            ],
        }
    ])
    monkeypatch.setattr(canvas_api, "list_quizzes", lambda _config: [
        {
            "id": 201,
            "title": "M1 Quiz",
            "description": "Quiz description",
            "html_url": "https://canvas.test/courses/999/quizzes/201",
            "points_possible": 5,
        }
    ])
    monkeypatch.setattr(canvas_api, "list_discussions", lambda _config: [
        {
            "id": 401,
            "title": "Module discussion",
            "message": "Discuss the model interpretation.",
            "html_url": "https://canvas.test/courses/999/discussion_topics/401",
        }
    ])
    monkeypatch.setattr(canvas_api, "get_quiz_questions", lambda _config, quiz_id: [
        {
            "id": 501,
            "question_name": "Question 1",
            "question_text": "Which interpretation of the slope is best?",
            "question_type": "multiple_choice_question",
        }
    ])

    graph = agp.build_graph_with_canvas_data(config, _fixture_config())

    assert graph["source_mode"] == "local-course-config+canvas-readonly"
    canvas_nodes = [node for node in graph["nodes"] if node["source"] == "canvas"]
    assert {node["type"] for node in canvas_nodes} >= {"page", "quiz", "quiz_question", "assignment", "discussion", "rubric_criterion"}
    assert all(node.get("canvas_url", "").startswith("https://canvas.test/courses/999") for node in canvas_nodes)
    assert any(edge["type"] == "contains" and "Canvas quiz" in edge["evidence"] for edge in graph["edges"])
    assert any(edge["type"] == "needs_review" and edge["confidence"] == "AMBIGUOUS" for edge in graph["edges"])
    assert graph["validation_issues"] == []


def test_canvas_cli_include_canvas_uses_read_helpers_and_writes_graph(tmp_path, monkeypatch):
    import alignment_graph_preview as agp
    import canvas_api
    import course_navigator

    course_dir = tmp_path / "courses" / "999"
    course_dir.mkdir(parents=True)
    (course_dir / "course-config.json").write_text(json.dumps(_fixture_config()), encoding="utf-8")
    config = {
        "course_id": "999",
        "domain": "canvas.test",
        "course_url": "https://canvas.test/api/v1/courses/999",
        "headers": {},
    }
    monkeypatch.setattr(agp, "PLUGIN_ROOT", tmp_path)
    monkeypatch.setattr(canvas_api, "get_config", lambda course_id=None: config)
    monkeypatch.setattr(course_navigator, "fetch_course_tree", lambda _config: _canvas_tree_fixture())
    monkeypatch.setattr(canvas_api, "list_assignments", lambda _config: [])
    monkeypatch.setattr(canvas_api, "list_quizzes", lambda _config: [])
    monkeypatch.setattr(canvas_api, "list_discussions", lambda _config: [])
    monkeypatch.setattr(canvas_api, "get_quiz_questions", lambda _config, _quiz_id: [])

    for forbidden in (
        "create_page",
        "update_page",
        "create_quiz",
        "add_quiz_question",
        "update_quiz_question",
        "create_assignment",
        "update_assignment",
        "create_discussion",
        "update_discussion",
    ):
        if hasattr(canvas_api, forbidden):
            monkeypatch.setattr(canvas_api, forbidden, lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("write helper called")))

    assert agp.main(["--course-id", "999", "--include-canvas"]) == 0
    graph_path = tmp_path / "courses" / "999" / "alignment" / "graph.json"
    graph = json.loads(graph_path.read_text(encoding="utf-8"))
    assert graph["source_mode"] == "local-course-config+canvas-readonly"
    assert any(node["source"] == "canvas" for node in graph["nodes"])


def test_module_number_prefers_name_over_position():
    """Canvas positions count Module 0, Spring Break, etc. and do not match
    the instructor's "Module N" numbering. The number must be parsed from
    the module title so config M10 matches Canvas "Module 10".

    Regression for LAW 519 smoke (2026-05-23): position-based lookup misrouted
    M10 evidence because its visual position was 12, not 10.
    """
    import alignment_graph_preview as agp

    assert agp._module_number({"name": "Module 10: Legal Writing", "position": 12}, fallback=1) == 10
    assert agp._module_number({"name": "(03/23 03/29) Module 10: Edit", "position": 12}, fallback=1) == 10
    # Explicit number wins over name.
    assert agp._module_number({"number": 7, "name": "Module 10", "position": 12}, fallback=1) == 7
    # No "Module N" in name -> fall back to position.
    assert agp._module_number({"name": "Spring Break", "position": 10}, fallback=1) == 10
    # No name and no position -> fallback index.
    assert agp._module_number({}, fallback=3) == 3


def test_canvas_module_titles_drive_alignment_match(monkeypatch):
    """Live Canvas extractor must route Module 10 items to config M10 even
    when the module's visual position is 12 (Module 0 + Spring Break shifts
    everything later in the tree)."""
    import alignment_graph_preview as agp
    import canvas_api
    import course_navigator

    config = {
        "course_id": "555",
        "domain": "canvas.test",
        "course_url": "https://canvas.test/api/v1/courses/555",
        "headers": {},
    }
    course_config = {
        "course": {"id": "555", "name": "Position Drift Test"},
        "clos": [{"id": "CLO1", "label": "CLO1", "text": "Test CLO"}],
        "modules": [
            {
                "number": 10,
                "title": "Module 10",
                "mlos_draft": [
                    {"id": "MLO10.1", "text": "Test MLO ten point one.", "ladders_to": "CLO1"},
                ],
            },
        ],
    }
    tree = [
        {"id": 1, "name": "Module 0: Welcome", "position": 1, "items": []},
        {"id": 2, "name": "Spring Break", "position": 11, "items": []},
        {
            "id": 12,
            "name": "(date) Module 10: Edit",
            "position": 12,
            "items": [
                {
                    "id": 1000,
                    "title": "M10 Homework",
                    "type": "Assignment",
                    "content_id": 9001,
                    "html_url": "https://canvas.test/courses/555/assignments/9001",
                },
            ],
        },
    ]
    monkeypatch.setattr(course_navigator, "fetch_course_tree", lambda _c: tree)
    monkeypatch.setattr(canvas_api, "list_assignments", lambda _c: [
        {"id": 9001, "name": "M10 Homework", "html_url": "https://canvas.test/courses/555/assignments/9001"},
    ])
    monkeypatch.setattr(canvas_api, "list_quizzes", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_discussions", lambda _c: [])
    monkeypatch.setattr(canvas_api, "get_quiz_questions", lambda _c, _q: [])

    graph = agp.build_graph_with_canvas_data(config, course_config)
    asn = next(n for n in graph["nodes"] if n["source"] == "canvas" and n["type"] == "assignment")
    assert (asn["metadata"] or {}).get("module_number") == 10, (
        f"Expected module_number=10, got {(asn['metadata'] or {}).get('module_number')}"
    )
    mlo = next(n for n in graph["nodes"] if n["type"] == "mlo" and n["label"] == "MLO10.1")
    linked = [
        e for e in graph["edges"]
        if e["type"] == "assesses" and e["source"] == asn["id"] and e["target"] == mlo["id"]
    ]
    assert linked, "Canvas Module 10 assignment did not link to config MLO10.1"


# ---------------------------------------------------------------------------
# Phase 1 — Canvas-first CLO/MLO extraction tests
# ---------------------------------------------------------------------------

_SYLLABUS_FIXTURE = """
<h2>Course Objectives</h2>
<p>Some preamble text.</p>
<h3>Learning Outcomes</h3>
<p>Students will be able to:</p>
<ul>
  <li>Identify the structure of the U.S. legal system.</li>
  <li>Conduct effective legal research.</li>
  <li>Apply Bluebook citation conventions.</li>
</ul>
<h2>Required Textbooks</h2>
"""

_OVERVIEW_FIXTURE = """
<p>In this module, students will learn ...</p>
<h3>Learning Objectives</h3>
<p>By the end of this module, students will be able to:</p>
<ul>
  <li>Identify components of the legal system.</li>
  <li>Distinguish between mandatory and persuasive authority.</li>
</ul>
<h3>Topics</h3>
<ul><li>Stare decisis</li></ul>
"""


def test_extract_clos_from_syllabus_pulls_well_formed_list():
    import alignment_graph_preview as agp

    clos = agp.extract_clos_from_syllabus(_SYLLABUS_FIXTURE)
    assert len(clos) == 3
    assert clos[0]["label"] == "CLO1"
    assert "structure of the U.S. legal system" in clos[0]["text"]
    assert all(c["confidence"] == "high" for c in clos)
    # Empty / None input returns empty list, not crash.
    assert agp.extract_clos_from_syllabus("") == []
    assert agp.extract_clos_from_syllabus(None) == []
    # Missing anchor returns empty list.
    assert agp.extract_clos_from_syllabus("<p>No outcomes here</p>") == []


def test_extract_mlos_from_overview_pulls_module_objectives():
    import alignment_graph_preview as agp

    mlos = agp.extract_mlos_from_overview(_OVERVIEW_FIXTURE, module_number=1)
    assert len(mlos) == 2
    assert mlos[0]["label"] == "MLO1.1"
    assert mlos[1]["label"] == "MLO1.2"
    assert "components of the legal system" in mlos[0]["text"]
    # Malformed page with no list does not crash — returns [].
    assert agp.extract_mlos_from_overview("<p>just prose</p>", 1) == []
    # Anchor present but list empty / placeholder-only does not crash.
    placeholder_html = (
        "<h3>Learning Objectives</h3><p>...</p><ul><li>&lt;topic&gt;</li></ul>"
    )
    assert agp.extract_mlos_from_overview(placeholder_html, 5) == []


def _canvas_first_tree_fixture():
    return [
        {
            "id": 100,
            "name": "Module 1: Test",
            "position": 1,
            "items": [
                {
                    "id": 1000,
                    "title": "Module 1 Overview",
                    "type": "Page",
                    "page_url": "m1-overview",
                    "html_url": "https://canvas.test/courses/777/pages/m1-overview",
                },
            ],
        },
    ]


def test_canvas_first_extractor_works_with_no_local_config(monkeypatch):
    """Bare-Canvas course (no course-config.json) still yields CLOs + MLOs."""
    import alignment_graph_preview as agp
    import canvas_api
    import course_navigator

    config = {
        "course_id": "777",
        "domain": "canvas.test",
        "course_url": "https://canvas.test/api/v1/courses/777",
        "headers": {},
    }
    monkeypatch.setattr(course_navigator, "fetch_course_tree", lambda _c: _canvas_first_tree_fixture())
    monkeypatch.setattr(canvas_api, "list_assignments", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_quizzes", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_discussions", lambda _c: [])
    monkeypatch.setattr(canvas_api, "get_quiz_questions", lambda _c, _q: [])

    graph = agp.build_graph_with_canvas_data(
        config,
        course_config=None,
        syllabus_body=_SYLLABUS_FIXTURE,
        page_body_loader=lambda slug: _OVERVIEW_FIXTURE if slug == "m1-overview" else "",
    )

    canvas_clos = [n for n in graph["nodes"] if n["type"] == "clo" and n.get("source") == "canvas"]
    canvas_mlos = [n for n in graph["nodes"] if n["type"] == "mlo" and n.get("source") == "canvas"]
    assert len(canvas_clos) == 3, f"expected 3 canvas CLOs, got {len(canvas_clos)}"
    assert len(canvas_mlos) == 2, f"expected 2 canvas MLOs, got {len(canvas_mlos)}"
    assert graph["source_mode"] == "canvas-readonly"
    # CLO nodes carry canvas_url + confidence_extraction.
    assert all(n.get("canvas_url", "").startswith("https://canvas.test/courses/777") for n in canvas_clos + canvas_mlos)
    assert canvas_clos[0]["metadata"]["confidence_extraction"] == "high"


def test_canvas_first_extractor_config_overrides_canvas_on_label_match(monkeypatch):
    """Config CLO with same label as Canvas CLO wins. Text divergence emits
    AMBIGUOUS needs_review edge so the conflict surfaces."""
    import alignment_graph_preview as agp
    import canvas_api
    import course_navigator

    config = {
        "course_id": "777",
        "domain": "canvas.test",
        "course_url": "https://canvas.test/api/v1/courses/777",
        "headers": {},
    }
    course_config = {
        "course": {"id": "777", "name": "Override Test"},
        "clos": [
            {"id": "CLO1", "label": "CLO1", "text": "Intended CLO text from planning map."},
        ],
        "modules": [],
    }
    monkeypatch.setattr(course_navigator, "fetch_course_tree", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_assignments", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_quizzes", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_discussions", lambda _c: [])
    monkeypatch.setattr(canvas_api, "get_quiz_questions", lambda _c, _q: [])

    graph = agp.build_graph_with_canvas_data(
        config,
        course_config=course_config,
        syllabus_body=_SYLLABUS_FIXTURE,
        page_body_loader=lambda slug: "",
    )

    clos = [n for n in graph["nodes"] if n["type"] == "clo"]
    # 1 config CLO + 2 unique canvas CLOs (CLO2, CLO3) + 1 conflict-shadow CLO node for CLO1.
    # CLO1 from canvas keeps its text different from config CLO1 -> needs_review edge fires.
    labels = sorted({c["label"] for c in clos})
    assert "CLO1" in labels and "CLO2" in labels and "CLO3" in labels
    # Find the config CLO1 (source != canvas) and confirm it survived intact.
    config_clo1 = next(c for c in clos if c["label"] == "CLO1" and c.get("source") != "canvas")
    assert "planning map" in config_clo1["text"]
    # Conflict edge should exist.
    conflict_edges = [
        e for e in graph["edges"]
        if e["type"] == "needs_review" and e["target"] == config_clo1["id"]
    ]
    assert conflict_edges, "Expected AMBIGUOUS needs_review edge for CLO1 text divergence"
    assert conflict_edges[0]["confidence"] == "AMBIGUOUS"


def test_cli_include_canvas_works_without_config_file(tmp_path, monkeypatch):
    """`--course-id N --include-canvas` succeeds with no course-config.json."""
    import alignment_graph_preview as agp
    import canvas_api
    import course_navigator

    config = {
        "course_id": "888",
        "domain": "canvas.test",
        "course_url": "https://canvas.test/api/v1/courses/888",
        "headers": {},
    }
    # PLUGIN_ROOT redirected -> default_config_path resolves to a non-existent file.
    monkeypatch.setattr(agp, "PLUGIN_ROOT", tmp_path)
    monkeypatch.setattr(canvas_api, "get_config", lambda course_id=None: config)
    monkeypatch.setattr(course_navigator, "fetch_course_tree", lambda _c: _canvas_first_tree_fixture())
    monkeypatch.setattr(canvas_api, "list_assignments", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_quizzes", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_discussions", lambda _c: [])
    monkeypatch.setattr(canvas_api, "get_quiz_questions", lambda _c, _q: [])
    monkeypatch.setattr(canvas_api, "get_page", lambda _c, slug: {"body": _OVERVIEW_FIXTURE if slug == "m1-overview" else ""})

    # Stub the requests.get path used to fetch syllabus_body.
    import requests

    class _StubResponse:
        def __init__(self, payload):
            self._payload = payload
        def raise_for_status(self):
            return None
        def json(self):
            return self._payload

    monkeypatch.setattr(requests, "get", lambda *a, **kw: _StubResponse({"syllabus_body": _SYLLABUS_FIXTURE}))

    assert agp.main(["--course-id", "888", "--include-canvas"]) == 0
    graph_path = tmp_path / "courses" / "888" / "alignment" / "graph.json"
    assert graph_path.exists()
    graph = json.loads(graph_path.read_text(encoding="utf-8"))
    assert graph["source_mode"] == "canvas-readonly"
    # Canvas-extracted CLOs + MLOs are present despite no config file.
    canvas_clos = [n for n in graph["nodes"] if n["type"] == "clo" and n.get("source") == "canvas"]
    canvas_mlos = [n for n in graph["nodes"] if n["type"] == "mlo" and n.get("source") == "canvas"]
    assert len(canvas_clos) == 3
    assert len(canvas_mlos) == 2


# ---------------------------------------------------------------------------
# Phase 1 — Negative-path coverage (Codex 5.5 diff review fix-now items)
# ---------------------------------------------------------------------------


def test_canvas_first_handles_get_page_exception_gracefully(monkeypatch, capsys):
    """When canvas_api.get_page raises, the run completes with zero MLOs
    for that module and emits a WARNING to stderr — never crashes."""
    import alignment_graph_preview as agp
    import canvas_api
    import course_navigator

    config = {
        "course_id": "777",
        "domain": "canvas.test",
        "course_url": "https://canvas.test/api/v1/courses/777",
        "headers": {},
    }

    def _raise(*_a, **_kw):
        raise RuntimeError("Canvas page fetch boom")

    monkeypatch.setattr(course_navigator, "fetch_course_tree", lambda _c: _canvas_first_tree_fixture())
    monkeypatch.setattr(canvas_api, "list_assignments", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_quizzes", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_discussions", lambda _c: [])
    monkeypatch.setattr(canvas_api, "get_quiz_questions", lambda _c, _q: [])
    monkeypatch.setattr(canvas_api, "get_page", _raise)

    graph = agp.build_graph_with_canvas_data(
        config,
        course_config=None,
        syllabus_body=_SYLLABUS_FIXTURE,
    )
    canvas_mlos = [n for n in graph["nodes"] if n["type"] == "mlo" and n.get("source") == "canvas"]
    assert canvas_mlos == [], "overview fetch failure should yield zero MLOs, not crash"
    err = capsys.readouterr().err
    assert "failed to fetch overview page" in err


def test_canvas_first_handles_syllabus_fetch_exception_gracefully(monkeypatch, capsys):
    """When the syllabus fetch raises, the run completes with zero CLOs
    and emits a WARNING — never crashes."""
    import alignment_graph_preview as agp
    import canvas_api
    import course_navigator
    import requests

    config = {
        "course_id": "777",
        "domain": "canvas.test",
        "course_url": "https://canvas.test/api/v1/courses/777",
        "headers": {},
    }

    def _raise(*_a, **_kw):
        raise requests.exceptions.ConnectionError("syllabus fetch boom")

    monkeypatch.setattr(requests, "get", _raise)
    monkeypatch.setattr(course_navigator, "fetch_course_tree", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_assignments", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_quizzes", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_discussions", lambda _c: [])
    monkeypatch.setattr(canvas_api, "get_quiz_questions", lambda _c, _q: [])

    # syllabus_body=None forces the fetch path; on raise we fall back to empty body.
    graph = agp.build_graph_with_canvas_data(config, course_config=None, syllabus_body=None)
    canvas_clos = [n for n in graph["nodes"] if n["type"] == "clo" and n.get("source") == "canvas"]
    assert canvas_clos == [], "syllabus fetch failure should yield zero CLOs, not crash"
    err = capsys.readouterr().err
    assert "failed to fetch syllabus_body" in err


def test_canvas_first_no_false_needs_review_on_whitespace_only_divergence(monkeypatch):
    """Config CLO text differing from Canvas CLO text only in whitespace must
    NOT emit a needs_review edge — `_strip_html` collapses whitespace."""
    import alignment_graph_preview as agp
    import canvas_api
    import course_navigator

    config = {
        "course_id": "777",
        "domain": "canvas.test",
        "course_url": "https://canvas.test/api/v1/courses/777",
        "headers": {},
    }
    # Same content, different whitespace.
    canvas_syllabus = (
        "<h3>Learning Outcomes</h3>"
        "<p>Students will be able to:</p>"
        "<ul>"
        "<li>Identify  the   structure of the legal system.</li>"
        "<li>Conduct legal research.</li>"
        "</ul>"
    )
    course_config = {
        "course": {"id": "777", "name": "Whitespace Test"},
        "clos": [
            {"id": "CLO1", "label": "CLO1", "text": "Identify the structure of the legal system."},
            {"id": "CLO2", "label": "CLO2", "text": "Conduct legal research."},
        ],
        "modules": [],
    }
    monkeypatch.setattr(course_navigator, "fetch_course_tree", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_assignments", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_quizzes", lambda _c: [])
    monkeypatch.setattr(canvas_api, "list_discussions", lambda _c: [])
    monkeypatch.setattr(canvas_api, "get_quiz_questions", lambda _c, _q: [])

    graph = agp.build_graph_with_canvas_data(
        config,
        course_config=course_config,
        syllabus_body=canvas_syllabus,
        page_body_loader=lambda slug: "",
    )
    needs_review_edges = [e for e in graph["edges"] if e["type"] == "needs_review"]
    assert needs_review_edges == [], (
        f"Whitespace-only divergence should not emit needs_review, got: "
        f"{[e['evidence'] for e in needs_review_edges]}"
    )


def test_inferred_mappings_sidecar_loader_returns_none_when_missing(tmp_path):
    import alignment_graph_preview as agp

    missing = tmp_path / "nope.json"
    assert agp.load_inferred_mappings(missing) is None


def test_inferred_mappings_sidecar_loader_rejects_malformed_json(tmp_path):
    import alignment_graph_preview as agp

    p = tmp_path / "inferred.json"
    p.write_text("{ not json", encoding="utf-8")
    try:
        agp.load_inferred_mappings(p)
        assert False, "loader must raise on malformed JSON"
    except ValueError as exc:
        assert "malformed" in str(exc)


def test_inferred_mappings_sidecar_loader_rejects_schema_mismatch(tmp_path):
    import alignment_graph_preview as agp
    import json as _json

    p = tmp_path / "inferred.json"
    p.write_text(_json.dumps({"schema_version": "alignment-inferred-mappings/v999", "mlo_to_clo": []}), encoding="utf-8")
    try:
        agp.load_inferred_mappings(p)
        assert False, "loader must raise on schema mismatch"
    except ValueError as exc:
        assert "schema_version" in str(exc)


def test_merge_inferred_fills_missing_ladders_only():
    """Inferences only fill gaps. Canonical declared `clo` / `ladders_to`
    is never overwritten."""
    import alignment_graph_preview as agp

    course_config = {
        "course": {"id": "999"},
        "modules": [
            {
                "number": 1,
                "objectives": [
                    {"id": "MLO1.1", "text": "Already declared", "clo": "CLO1"},
                    {"id": "MLO1.2", "text": "No ladder declared"},
                ],
            }
        ],
    }
    inferred = {
        "schema_version": agp.INFERRED_MAPPINGS_SCHEMA_VERSION,
        "course_id": "999",
        "mlo_to_clo": [
            {"mlo_id": "MLO1.1", "proposed_clo_ids": ["CLO_BAD"], "rationale": "should be ignored"},
            {"mlo_id": "MLO1.2", "proposed_clo_ids": ["CLO2", "CLO3"], "rationale": "ok"},
        ],
    }
    merged = agp.merge_inferred_into_course_config(course_config, inferred)
    mlos = merged["modules"][0]["objectives"]
    # Declared MLO1.1 keeps original `clo`, no `ladders_to` injected.
    assert mlos[0]["clo"] == "CLO1"
    assert mlos[0].get("ladders_to") is None or mlos[0].get("ladders_to") == []
    # Missing-ladder MLO1.2 gets the inferred list.
    assert mlos[1]["ladders_to"] == ["CLO2", "CLO3"]


def test_merge_inferred_returns_deepcopy_not_mutation():
    import alignment_graph_preview as agp

    course_config = {
        "course": {"id": "999"},
        "modules": [{"number": 1, "objectives": [{"id": "MLO1.1", "text": "X"}]}],
    }
    inferred = {
        "mlo_to_clo": [{"mlo_id": "MLO1.1", "proposed_clo_ids": ["CLO1"]}],
    }
    merged = agp.merge_inferred_into_course_config(course_config, inferred)
    # Original course_config untouched.
    assert course_config["modules"][0]["objectives"][0].get("ladders_to") is None
    # Merged copy has the inference.
    assert merged["modules"][0]["objectives"][0]["ladders_to"] == ["CLO1"]


def test_canvas_first_handles_anchor_without_list_items_gracefully():
    """Syllabus has the 'Students will be able to:' anchor but the bullet
    list is empty / missing — extractor returns [] cleanly, no crash."""
    import alignment_graph_preview as agp

    # Anchor + opening list tag + immediate close.
    html_empty_list = "<p>Students will be able to:</p><ul></ul>"
    assert agp.extract_clos_from_syllabus(html_empty_list) == []

    # Anchor + list with only too-short bullets (< 10 chars each).
    html_short_bullets = (
        "<p>Students will be able to:</p>"
        "<ul><li>X.</li><li>Y.</li></ul>"
    )
    assert agp.extract_clos_from_syllabus(html_short_bullets) == []

    # Anchor without any list tag at all.
    html_no_list = "<p>Students will be able to: do many things.</p>"
    assert agp.extract_clos_from_syllabus(html_no_list) == []
