"""Contract checks for the alignment skill wiring (Mode A + Mode B).

These tests guard against silent SKILL.md edits that would break the
plain-English routing into the Mode B graph preview pipeline shipped in
v7.13.0 + v7.14.0. They assert stable command strings, artifact filenames,
and decision labels rather than prose snippets, so future wording polish
does not break the suite.
"""

from pathlib import Path
import os
import sys
import unittest


ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))

ALIGNMENT_SKILL = (ROOT / "skills" / "alignment" / "SKILL.md").read_text(encoding="utf-8")
COURSE_MOCKUP_SKILL = (ROOT / "skills" / "course-mockup" / "SKILL.md").read_text(encoding="utf-8")

# Import runtime constants so the SKILL.md contract stays in sync with code.
# Drift between the docs and the actual scripts must surface as a test failure,
# not silent breakage in a real session.
from alignment_preview_html import CONFIDENCE_LABELS  # noqa: E402
from alignment_mockup_decisions import (  # noqa: E402
    DECISION_LABELS,
    SCHEMA_VERSION as SCHEMA_VERSION_DECISIONS,
)
from alignment_graph_preview import (  # noqa: E402
    EDGE_TYPES,
    NODE_TYPES,
    SCHEMA_VERSION as SCHEMA_VERSION_GRAPH,
    default_graph_path,
)
from alignment_preview_html import default_output_path as default_preview_path  # noqa: E402
from alignment_mockup_decisions import default_output_path as default_decisions_path  # noqa: E402


class AlignmentSkillContractTest(unittest.TestCase):
    def test_alignment_skill_mentions_classic_report_mode(self):
        self.assertIn("Mode A", ALIGNMENT_SKILL)
        self.assertIn("Classic Report", ALIGNMENT_SKILL)
        self.assertIn("alignment_skill.py --course-id {id} --phase scrape", ALIGNMENT_SKILL)
        self.assertIn("alignment_skill.py --course-id {id} --phase report", ALIGNMENT_SKILL)
        self.assertIn("remediation_brief.json", ALIGNMENT_SKILL)

    def test_alignment_skill_mentions_graph_preview_mode(self):
        self.assertIn("Mode B", ALIGNMENT_SKILL)
        self.assertIn("Graph Preview", ALIGNMENT_SKILL)
        self.assertIn("alignment_graph_preview.py", ALIGNMENT_SKILL)
        self.assertIn("alignment_preview_html.py", ALIGNMENT_SKILL)
        self.assertIn("alignment_mockup_decisions.py", ALIGNMENT_SKILL)
        self.assertIn("graph.json", ALIGNMENT_SKILL)
        self.assertIn("alignment-preview.html", ALIGNMENT_SKILL)
        self.assertIn("mockup_decisions.json", ALIGNMENT_SKILL)

    def test_alignment_skill_documents_include_canvas_flag(self):
        self.assertIn("--include-canvas", ALIGNMENT_SKILL)
        self.assertIn("read-only and opt-in", ALIGNMENT_SKILL.lower().replace("**", ""))

    def test_alignment_skill_documents_mockup_decision_labels(self):
        for label in ("KEEP", "ADD", "REPLACE", "OPTIONAL"):
            self.assertIn(label, ALIGNMENT_SKILL)
        for confidence in ("EXTRACTED", "INFERRED", "AMBIGUOUS"):
            self.assertIn(confidence, ALIGNMENT_SKILL)

    def test_alignment_skill_preserves_invocation_gate(self):
        # Regression guard: the no-unsolicited-reports rule from the earlier
        # standing-instruction commit must stay verbatim-recoverable.
        self.assertIn("Invocation gate", ALIGNMENT_SKILL)
        self.assertIn("no unsolicited reports", ALIGNMENT_SKILL)
        self.assertIn("user explicitly asks", ALIGNMENT_SKILL)

    def test_alignment_skill_documents_mode_ambiguity_resolution(self):
        # When the user's prompt is ambiguous, the skill must ask once and
        # default-on-no-answer to Mode A, never run both modes silently.
        self.assertIn("Ambiguity resolution", ALIGNMENT_SKILL)
        self.assertIn("ask once", ALIGNMENT_SKILL)
        self.assertIn("Default-on-no-answer is Mode A", ALIGNMENT_SKILL)

    def test_alignment_skill_documents_boundary_phrases(self):
        # Phrases that fall between Mode A and Mode B trigger lists must
        # route to the ask-once branch. The Boundary phrases subsection
        # documents the catch-all rule so future maintainers know which
        # prompts trigger the question. Every example phrase listed in the
        # subsection must remain present — deleting any of them silently
        # would weaken the documented catch-all and let real prompts slip
        # past the ask-once branch.
        self.assertIn("Boundary phrases", ALIGNMENT_SKILL)
        for phrase in (
            "visualize alignment",
            "alignment evidence",
            "show me how aligned things are",
            "alignment overview",
            "alignment status",
            "alignment view",
        ):
            self.assertIn(phrase, ALIGNMENT_SKILL, (
                f"boundary phrase '{phrase}' missing from alignment SKILL.md"
            ))

    def test_skill_documents_runtime_confidence_label_mapping(self):
        # Every raw confidence key and every faculty-safe display value
        # from scripts/alignment_preview_html.py:CONFIDENCE_LABELS must
        # be referenced in skills/alignment/SKILL.md. If a script renames
        # a key or label, the docs must update too.
        for key, value in CONFIDENCE_LABELS.items():
            self.assertIn(key, ALIGNMENT_SKILL, f"confidence key '{key}' missing from alignment SKILL.md")
            self.assertIn(value, ALIGNMENT_SKILL, f"confidence label '{value}' missing from alignment SKILL.md")

    def test_skill_documents_runtime_decision_labels(self):
        # Every DECISION_LABELS member from scripts/alignment_mockup_decisions.py
        # must appear in alignment SKILL.md so the docs stay synchronised with
        # the runtime label set.
        for label in DECISION_LABELS:
            self.assertIn(label, ALIGNMENT_SKILL, f"decision label '{label}' missing from alignment SKILL.md")

    def test_skill_documents_runtime_schema_versions(self):
        # The graph-preview and decisions schema strings are the public
        # contract between the scripts and the SKILL.md guidance.
        self.assertIn(SCHEMA_VERSION_GRAPH, ALIGNMENT_SKILL)
        self.assertIn(SCHEMA_VERSION_DECISIONS, ALIGNMENT_SKILL)

    def test_skill_documents_runtime_default_output_path_suffixes(self):
        # SKILL.md should reference the default artifact directory + filenames
        # so the docs match where the scripts actually write. Use Path.parts
        # comparison (not raw endswith) so the assertion is portable across
        # POSIX and Windows path separators, and so a future relocation of
        # the parent directory still trips the test.
        for path_fn, expected_tail in (
            (default_graph_path, ("alignment", "graph.json")),
            (default_preview_path, ("alignment", "alignment-preview.html")),
            (default_decisions_path, ("alignment", "mockup_decisions.json")),
        ):
            rendered = path_fn("EXAMPLE-COURSE")
            tail = rendered.parts[-len(expected_tail):]
            self.assertEqual(tail, expected_tail, (
                f"default path function returned unexpected tail: {rendered}"
            ))
            posix_suffix = "/".join(expected_tail)
            self.assertIn(posix_suffix, ALIGNMENT_SKILL, (
                f"default output path suffix '{posix_suffix}' missing from alignment SKILL.md"
            ))

    def test_alignment_skill_documents_thought_partner_workflow(self):
        # v7.17.0: alignment review is a chat-collaboration workflow,
        # not a one-shot script run. SKILL.md must document the 7-step
        # thought-partner pattern + the inferred_mappings.json sidecar +
        # the exact approval verb set.
        self.assertIn("Thought-partner workflow", ALIGNMENT_SKILL)
        self.assertIn("inferred_mappings.json", ALIGNMENT_SKILL)
        self.assertIn("alignment-inferred-mappings/v1", ALIGNMENT_SKILL)
        # Minimum schema example present so the chat-to-file handoff is reliable.
        self.assertIn('"mlo_to_clo":', ALIGNMENT_SKILL)
        self.assertIn('"assessment_to_mlo":', ALIGNMENT_SKILL)
        self.assertIn('"proposed_clo_ids":', ALIGNMENT_SKILL)
        self.assertIn('"rationale":', ALIGNMENT_SKILL)

    def test_alignment_skill_documents_approval_verb_gate(self):
        # Approval set is exact-match. Bare `approved` is rejected to avoid
        # collision with the push-gate verb. Ambiguous affirmatives reject.
        self.assertIn("alignment approved", ALIGNMENT_SKILL)
        self.assertIn("faculty approved", ALIGNMENT_SKILL)
        self.assertIn("align approved", ALIGNMENT_SKILL)
        # The contract must state that bare `approved` is NOT accepted.
        self.assertIn("Never accept bare `approved`", ALIGNMENT_SKILL)
        # And ambiguous affirmatives.
        self.assertIn("Ambiguous affirmatives", ALIGNMENT_SKILL)

    def test_alignment_skill_documents_inferred_mappings_boundary_from_mockup_decisions(self):
        # Clarify that mockup_decisions.json is a SEPARATE workflow
        # (quiz/assignment mockup generation), not the alignment-review path.
        self.assertIn("separate concern", ALIGNMENT_SKILL)
        self.assertIn("NOT part of the alignment-review", ALIGNMENT_SKILL)

    def test_timeline_logger_includes_alignment_mapping_approved_event(self):
        # The approval write to course-config.json must be auditable via
        # `courses/{id}/timeline.jsonl`. The new event type lives in
        # scripts/timeline_logger.py VALID_EVENT_TYPES.
        from timeline_logger import VALID_EVENT_TYPES

        assert "alignment_mapping_approved" in VALID_EVENT_TYPES, (
            "alignment_mapping_approved missing from VALID_EVENT_TYPES "
            "— approval audit trail breaks"
        )

    def test_alignment_skill_documents_report_generation_policy(self):
        # v7.17.2: report generation is ALWAYS optional. The orchestrator
        # runs only after an explicit ID ask. SKILL.md must document the
        # policy with concrete trigger / non-trigger examples and the
        # ambiguous-prompt handling rule.
        self.assertIn("Report generation policy", ALIGNMENT_SKILL)
        self.assertIn("never auto-generates", ALIGNMENT_SKILL)
        # Examples that DO trigger:
        self.assertIn("generate report", ALIGNMENT_SKILL)
        self.assertIn("render the alignment graph", ALIGNMENT_SKILL)
        # Examples that DO NOT trigger (thought-partner only):
        self.assertIn("review alignment", ALIGNMENT_SKILL)
        # Ambiguous-prompt handling rule:
        self.assertIn("ambiguous", ALIGNMENT_SKILL.lower())
        self.assertIn("Want me to render the HTML preview report?", ALIGNMENT_SKILL)

    def test_alignment_skill_documents_one_command_orchestrator(self):
        # PR B: SKILL.md must document the single alignment_mode_b.py command
        # so plain-English Mode B prompts route to one orchestrator call,
        # not a multi-step bash chain.
        self.assertIn("alignment_mode_b.py", ALIGNMENT_SKILL)
        self.assertIn("python3 scripts/alignment_mode_b.py --course-id", ALIGNMENT_SKILL)
        self.assertIn("--with-decisions", ALIGNMENT_SKILL)
        self.assertIn("--no-open", ALIGNMENT_SKILL)

    def test_alignment_skill_documents_canvas_as_source_of_truth(self):
        # Phase 2 acceptance: SKILL.md states explicitly that Canvas is the
        # source of truth and course-config.json is an optional planning-map
        # override. Future SKILL.md edits cannot silently flip the priority.
        self.assertIn("Canvas is the source of truth", ALIGNMENT_SKILL)
        self.assertIn("OPTIONAL planning-map override", ALIGNMENT_SKILL)

    def test_alignment_skill_documents_auto_open_and_summary_only_chat(self):
        # Phase 2: SKILL.md must document the macOS auto-open step + the
        # summary-only chat reporting rule so the orchestrator's behavior
        # is contract-bound, not implicit.
        self.assertIn("Auto-open", ALIGNMENT_SKILL)
        self.assertIn("default browser", ALIGNMENT_SKILL)
        # Summary-only reporting: SKILL.md must instruct Claude to paste the
        # JSON summary verbatim + a one-line interpretation, NOT the full
        # graph / decisions JSON content.
        self.assertIn("JSON summary that Claude pastes into chat verbatim", ALIGNMENT_SKILL)
        self.assertIn("Do not paste graph / decisions JSON content", ALIGNMENT_SKILL)

    def test_skill_documents_runtime_node_and_edge_type_sets(self):
        # NODE_TYPES and EDGE_TYPES drive the graph-validation gate in
        # alignment_graph_preview.py. SKILL.md should reference every
        # currently-supported node and edge type so the docs stay in lockstep
        # with the validator. Future renames or additions will fail this
        # test until SKILL.md catches up.
        for node_type in NODE_TYPES:
            self.assertIn(node_type, ALIGNMENT_SKILL, (
                f"node type '{node_type}' missing from alignment SKILL.md"
            ))
        for edge_type in EDGE_TYPES:
            self.assertIn(edge_type, ALIGNMENT_SKILL, (
                f"edge type '{edge_type}' missing from alignment SKILL.md"
            ))


class CourseMockupAlignmentIntegrationTest(unittest.TestCase):
    def test_course_mockup_skill_documents_alignment_evidence_integration(self):
        self.assertIn("Alignment Evidence Integration", COURSE_MOCKUP_SKILL)
        self.assertIn("mockup_decisions.json", COURSE_MOCKUP_SKILL)
        self.assertIn("Read-before-generate", COURSE_MOCKUP_SKILL)
        self.assertIn("Offer-then-generate", COURSE_MOCKUP_SKILL)
        # All four decision labels must reach the mockup skill consumer.
        for label in ("KEEP", "ADD", "REPLACE", "OPTIONAL"):
            self.assertIn(label, COURSE_MOCKUP_SKILL)
        # Faculty-safe confidence label mapping must match alignment_preview_html.py.
        for facade in ("Explicit evidence", "Likely alignment", "Needs review"):
            self.assertIn(facade, COURSE_MOCKUP_SKILL)

    def test_course_mockup_skill_uses_runtime_confidence_label_values(self):
        # Every faculty-safe display value from CONFIDENCE_LABELS must reach
        # the mockup consumer so badges stay synchronised with the renderer.
        for value in CONFIDENCE_LABELS.values():
            self.assertIn(value, COURSE_MOCKUP_SKILL, (
                f"confidence label '{value}' missing from course-mockup SKILL.md"
            ))

    def test_course_mockup_skill_uses_runtime_decision_labels(self):
        for label in DECISION_LABELS:
            self.assertIn(label, COURSE_MOCKUP_SKILL, (
                f"decision label '{label}' missing from course-mockup SKILL.md"
            ))


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