"""Tests for the Mode B orchestrator (scripts/alignment_mode_b.py).

PR B of `docs/plan-alignment-mode-b-canvas-first-graphify.md`. The
orchestrator chains the 3 alignment scripts into one command so the
`/alignment` skill can respond to plain-English Mode B prompts without
multi-step bash invocations.
"""

import json
import os
import sys
import unittest
from unittest.mock import MagicMock, patch

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


def _fake_graph():
    return {
        "schema_version": "alignment-graph-preview/v1",
        "generated_at": "2026-05-23T00:00:00Z",
        "course_id": "777",
        "course_title": "Test Course",
        "source_mode": "canvas-readonly",
        "nodes": [
            {"id": "clo:c1:aaa", "type": "clo", "label": "CLO1", "text": "CLO text", "metadata": {}},
            {"id": "mlo:m1:bbb", "type": "mlo", "label": "MLO1.1", "text": "MLO text", "metadata": {"module_number": 1}},
        ],
        "edges": [
            {"id": "edge:1", "type": "aligns_to", "source": "mlo:m1:bbb", "target": "clo:c1:aaa", "confidence": "EXTRACTED", "evidence": "fixture"},
        ],
        "gaps": {"mlo_without_assessment": ["mlo:m1:bbb"]},
        "validation_issues": [],
    }


class ModeBOrchestratorTest(unittest.TestCase):
    def setUp(self):
        import alignment_mode_b

        self.amb = alignment_mode_b

    def _patch_pipeline(self, monkeypatch_ctx, graph=None, raise_step=None):
        """Patch the 3 imported modules + canvas_api.get_config."""
        graph = graph or _fake_graph()
        import canvas_api
        import alignment_graph_preview as agp
        import alignment_preview_html as aph
        import alignment_mockup_decisions as amd

        monkeypatch_ctx.enter_context(patch.object(canvas_api, "get_config", lambda course_id=None: {"course_id": course_id}))

        if raise_step == "graph":
            monkeypatch_ctx.enter_context(patch.object(agp, "build_graph_with_canvas_data", side_effect=RuntimeError("graph boom")))
        else:
            monkeypatch_ctx.enter_context(patch.object(agp, "build_graph_with_canvas_data", return_value=graph))
        monkeypatch_ctx.enter_context(patch.object(agp, "write_graph", lambda g, path: path))
        monkeypatch_ctx.enter_context(patch.object(agp, "default_config_path", lambda cid: __import__("pathlib").Path(f"/tmp/nonexistent-{cid}-config.json")))
        monkeypatch_ctx.enter_context(patch.object(agp, "default_graph_path", lambda cid: __import__("pathlib").Path(f"/tmp/{cid}-graph.json")))

        if raise_step == "preview":
            monkeypatch_ctx.enter_context(patch.object(aph, "write_preview", side_effect=RuntimeError("preview boom")))
        else:
            monkeypatch_ctx.enter_context(patch.object(aph, "write_preview", lambda g, p: p))
        monkeypatch_ctx.enter_context(patch.object(aph, "default_output_path", lambda cid: __import__("pathlib").Path(f"/tmp/{cid}-preview.html")))

        if raise_step == "decisions":
            monkeypatch_ctx.enter_context(patch.object(amd, "build_decisions", side_effect=RuntimeError("decisions boom")))
        else:
            monkeypatch_ctx.enter_context(patch.object(amd, "build_decisions", return_value={
                "decisions_by_mlo": [{"mlo_id": "mlo:m1:bbb", "overall_label": "ADD"}],
                "decisions_by_item": [],
                "summary": {"keep": 0, "add": 1, "replace": 0, "optional": 0},
            }))
        monkeypatch_ctx.enter_context(patch.object(amd, "write_decisions", lambda d, p: p))
        monkeypatch_ctx.enter_context(patch.object(amd, "default_output_path", lambda cid: __import__("pathlib").Path(f"/tmp/{cid}-decisions.json")))

    def test_full_chain_runs_with_decisions_flag(self):
        import contextlib

        with contextlib.ExitStack() as stack:
            self._patch_pipeline(stack)
            stack.enter_context(patch.object(self.amb, "_maybe_open_in_browser", lambda p: {"opened": False, "reason": "stub"}))
            summary = self.amb.run_mode_b("777", with_decisions=True, open_browser=False)

        self.assertTrue(summary["ok"])
        step_names = [s.get("step") for s in summary["steps"]]
        self.assertEqual(step_names, ["graph", "preview", "decisions"])
        self.assertTrue(all(s.get("ok") for s in summary["steps"]))
        self.assertIn("preview_path", summary)

    def test_decisions_step_skipped_without_flag(self):
        import contextlib

        with contextlib.ExitStack() as stack:
            self._patch_pipeline(stack)
            stack.enter_context(patch.object(self.amb, "_maybe_open_in_browser", lambda p: {"opened": False}))
            summary = self.amb.run_mode_b("777", with_decisions=False, open_browser=False)

        decisions_step = next(s for s in summary["steps"] if s.get("step") == "decisions")
        self.assertTrue(decisions_step.get("skipped"))
        self.assertTrue(summary["ok"])

    def test_no_open_suppresses_browser_call(self):
        import contextlib

        with contextlib.ExitStack() as stack:
            self._patch_pipeline(stack)
            open_mock = MagicMock()
            stack.enter_context(patch.object(self.amb, "_maybe_open_in_browser", open_mock))
            summary = self.amb.run_mode_b("777", open_browser=False)

        open_mock.assert_not_called()
        self.assertNotIn("open", summary)

    def test_chain_handles_no_local_config(self):
        """Phase 1 dependency: orchestrator works on a course with no
        course-config.json at the default path. The graph step receives
        course_config=None and Canvas-first extraction takes over."""
        import contextlib

        with contextlib.ExitStack() as stack:
            self._patch_pipeline(stack)
            stack.enter_context(patch.object(self.amb, "_maybe_open_in_browser", lambda p: {"opened": False}))
            summary = self.amb.run_mode_b("ghost-course", open_browser=False)

        self.assertTrue(summary["ok"])
        graph_step = next(s for s in summary["steps"] if s.get("step") == "graph")
        self.assertEqual(graph_step["source_mode"], "canvas-readonly")

    def test_orchestrator_loads_inferred_mappings_sidecar_when_present(self, monkeypatch=None):
        import contextlib
        from pathlib import Path
        import json as _json

        with contextlib.ExitStack() as stack:
            self._patch_pipeline(stack)
            stack.enter_context(patch.object(self.amb, "_maybe_open_in_browser", lambda p: {"opened": False}))

            # Force the agp.default_inferred_mappings_path to a tmp file that exists.
            import alignment_graph_preview as agp

            tmp = Path("/tmp/test-mode-b-inferred.json")
            tmp.write_text(_json.dumps({
                "schema_version": agp.INFERRED_MAPPINGS_SCHEMA_VERSION,
                "course_id": "777",
                "mlo_to_clo": [{"mlo_id": "MLO1.1", "proposed_clo_ids": ["CLO1"], "rationale": "test"}],
            }), encoding="utf-8")
            stack.enter_context(patch.object(agp, "default_inferred_mappings_path", lambda cid: tmp))

            summary = self.amb.run_mode_b("777", with_decisions=False, open_browser=False)

        graph_step = next(s for s in summary["steps"] if s.get("step") == "graph")
        assert graph_step.get("inferred_mappings_layered") is True, (
            "orchestrator should report inferred_mappings_layered=True when sidecar exists"
        )
        try:
            tmp.unlink()
        except FileNotFoundError:
            pass

    def test_orchestrator_fails_fast_on_malformed_inferred_sidecar(self, monkeypatch=None):
        import contextlib
        from pathlib import Path

        with contextlib.ExitStack() as stack:
            self._patch_pipeline(stack)
            stack.enter_context(patch.object(self.amb, "_maybe_open_in_browser", lambda p: {"opened": False}))

            import alignment_graph_preview as agp

            tmp = Path("/tmp/test-mode-b-inferred-bad.json")
            tmp.write_text("{ not json", encoding="utf-8")
            stack.enter_context(patch.object(agp, "default_inferred_mappings_path", lambda cid: tmp))

            summary = self.amb.run_mode_b("777", with_decisions=False, open_browser=False)

        assert summary["ok"] is False, "malformed sidecar should fail the build"
        assert summary["failed_at"] == "graph"
        try:
            tmp.unlink()
        except FileNotFoundError:
            pass

    def test_exit_code_nonzero_on_graph_step_failure(self):
        import contextlib

        with contextlib.ExitStack() as stack:
            self._patch_pipeline(stack, raise_step="graph")
            stack.enter_context(patch.object(self.amb, "_maybe_open_in_browser", lambda p: {"opened": False}))
            rc = self.amb.main(["--course-id", "777"])

        self.assertEqual(rc, 1)


if __name__ == "__main__":
    unittest.main()
