"""Tests for push_to_canvas.py — verify atomic push contracts.

All Canvas API calls are mocked. Tests verify:
- staging cleared only after successful verification
- staging preserved on verification failure
- quiz push creates/updates/deletes questions correctly
- quiz push with empty questions list deletes all existing
- quiz push with missing questions key skips question reconciliation
"""

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

# Add scripts to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

import staging_manager
import push_to_canvas
import canvas_api

TEST_COURSE_ID = "99999"
PLUGIN_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def _mock_config():
    return {
        "token": "fake",
        "domain": "canvas.test.edu",
        "course_id": TEST_COURSE_ID,
        "base_url": "https://canvas.test.edu/api/v1",
        "course_url": f"https://canvas.test.edu/api/v1/courses/{TEST_COURSE_ID}",
        "headers": {"Authorization": "Bearer fake"},
    }


class TestPushPageAtomicity(unittest.TestCase):
    """Page push clears staging only after successful verification."""

    def setUp(self):
        staging_manager.stage(TEST_COURSE_ID, "pages", "test-page",
                              "<h2>Test</h2>")

    def tearDown(self):
        staging_manager.clear_staged(TEST_COURSE_ID)
        course_dir = os.path.join(PLUGIN_ROOT, "courses", TEST_COURSE_ID)
        if os.path.exists(course_dir):
            shutil.rmtree(course_dir)

    @patch("push_to_canvas.post_write_verify.verify_page")
    @patch("push_to_canvas.canvas_api.update_page", return_value=True)
    @patch("push_to_canvas.canvas_api.get_page", return_value={"body": "<p>old</p>"})
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_success_clears_staging(self, mock_backup, mock_get, mock_update,
                                     mock_verify):
        mock_verify.return_value = {"ok": True, "canvas_url": "http://x",
                                     "content_length": 10, "warnings": []}
        result = push_to_canvas.push_page(_mock_config(), "test-page",
                                           TEST_COURSE_ID)
        self.assertTrue(result["ok"])
        # Staging should be cleared
        self.assertIsNone(
            staging_manager.get_staged(TEST_COURSE_ID, "pages", "test-page"))

    @patch("push_to_canvas.post_write_verify.verify_page")
    @patch("push_to_canvas.canvas_api.update_page", return_value=True)
    @patch("push_to_canvas.canvas_api.get_page", return_value={"body": "<p>old</p>"})
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_verify_failure_preserves_staging(self, mock_backup, mock_get,
                                               mock_update, mock_verify):
        mock_verify.return_value = {"ok": False, "error": "Page not found"}
        result = push_to_canvas.push_page(_mock_config(), "test-page",
                                           TEST_COURSE_ID)
        self.assertFalse(result["ok"])
        self.assertTrue(result.get("staging_preserved"))
        # Staging should still exist
        self.assertIsNotNone(
            staging_manager.get_staged(TEST_COURSE_ID, "pages", "test-page"))


class TestPushAssignmentAtomicity(unittest.TestCase):
    """Assignment push clears staging only after successful verification."""

    def setUp(self):
        staging_manager.stage(TEST_COURSE_ID, "assignments", "12345",
                              "<p>Instructions</p>")

    def tearDown(self):
        staging_manager.clear_staged(TEST_COURSE_ID)
        course_dir = os.path.join(PLUGIN_ROOT, "courses", TEST_COURSE_ID)
        if os.path.exists(course_dir):
            shutil.rmtree(course_dir)

    @patch("push_to_canvas.post_write_verify.verify_assignment")
    @patch("push_to_canvas.canvas_api.update_assignment",
           return_value={"id": 12345})
    @patch("push_to_canvas.canvas_api.get_assignment",
           return_value={"description": "<p>old</p>"})
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_success_clears_staging(self, mock_backup, mock_get, mock_update,
                                     mock_verify):
        mock_verify.return_value = {"ok": True, "canvas_url": "http://x",
                                     "content_length": 10, "warnings": []}
        result = push_to_canvas.push_assignment(_mock_config(), "12345",
                                                 TEST_COURSE_ID)
        self.assertTrue(result["ok"])
        self.assertIsNone(
            staging_manager.get_staged(TEST_COURSE_ID, "assignments", "12345"))

    @patch("push_to_canvas.post_write_verify.verify_assignment")
    @patch("push_to_canvas.canvas_api.update_assignment",
           return_value={"id": 12345})
    @patch("push_to_canvas.canvas_api.get_assignment",
           return_value={"description": "<p>old</p>"})
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_verify_failure_preserves_staging(self, mock_backup, mock_get,
                                               mock_update, mock_verify):
        mock_verify.return_value = {"ok": False, "error": "Verify failed"}
        result = push_to_canvas.push_assignment(_mock_config(), "12345",
                                                 TEST_COURSE_ID)
        self.assertFalse(result["ok"])
        self.assertIsNotNone(
            staging_manager.get_staged(TEST_COURSE_ID, "assignments", "12345"))


class TestPushDiscussionAtomicity(unittest.TestCase):
    """Discussion push clears staging only after successful verification."""

    def setUp(self):
        staging_manager.stage(TEST_COURSE_ID, "discussions", "67890",
                              "<p>Discuss this</p>")

    def tearDown(self):
        staging_manager.clear_staged(TEST_COURSE_ID)
        course_dir = os.path.join(PLUGIN_ROOT, "courses", TEST_COURSE_ID)
        if os.path.exists(course_dir):
            shutil.rmtree(course_dir)

    @patch("push_to_canvas.post_write_verify.verify_discussion")
    @patch("push_to_canvas.canvas_api.update_discussion",
           return_value={"id": 67890})
    @patch("push_to_canvas.canvas_api.get_discussion",
           return_value={"message": "<p>old</p>"})
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_success_clears_staging(self, mock_backup, mock_get, mock_update,
                                     mock_verify):
        mock_verify.return_value = {"ok": True, "canvas_url": "http://x",
                                     "content_length": 10, "warnings": []}
        result = push_to_canvas.push_discussion(_mock_config(), "67890",
                                                 TEST_COURSE_ID)
        self.assertTrue(result["ok"])
        self.assertIsNone(
            staging_manager.get_staged(TEST_COURSE_ID, "discussions", "67890"))

    @patch("push_to_canvas.post_write_verify.verify_discussion")
    @patch("push_to_canvas.canvas_api.update_discussion",
           return_value={"id": 67890})
    @patch("push_to_canvas.canvas_api.get_discussion",
           return_value={"message": "<p>old</p>"})
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_verify_failure_preserves_staging(self, mock_backup, mock_get,
                                               mock_update, mock_verify):
        mock_verify.return_value = {"ok": False, "error": "Verify failed"}
        result = push_to_canvas.push_discussion(_mock_config(), "67890",
                                                 TEST_COURSE_ID)
        self.assertFalse(result["ok"])
        self.assertIsNotNone(
            staging_manager.get_staged(TEST_COURSE_ID, "discussions", "67890"))


class TestPushQuizWithQuestions(unittest.TestCase):
    """Quiz push creates questions, deletes stale, syncs points."""

    def setUp(self):
        self.quiz_content = {
            "quiz": {"title": "Test Quiz"},
            "questions": [
                {
                    "question_name": "Q1",
                    "question_text": "<p>What is 2+2?</p>",
                    "question_type": "multiple_choice_question",
                    "points_possible": 3,
                    "answers": [
                        {"answer_text": "4", "answer_weight": 100},
                        {"answer_text": "5", "answer_weight": 0},
                    ],
                }
            ],
        }
        staging_manager.stage(TEST_COURSE_ID, "quizzes", "555",
                              self.quiz_content)

    def tearDown(self):
        staging_manager.clear_staged(TEST_COURSE_ID)
        course_dir = os.path.join(PLUGIN_ROOT, "courses", TEST_COURSE_ID)
        if os.path.exists(course_dir):
            shutil.rmtree(course_dir)

    @patch("push_to_canvas.post_write_verify.verify_quiz")
    @patch("push_to_canvas.canvas_api.update_assignment")
    @patch("push_to_canvas.canvas_api.get_quiz")
    @patch("push_to_canvas.canvas_api.update_quiz", return_value={"id": 555})
    @patch("push_to_canvas.canvas_api.add_quiz_question",
           return_value={"id": 1001})
    @patch("push_to_canvas.canvas_api.get_quiz_questions", return_value=[])
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_creates_questions_and_clears_on_success(
            self, mock_backup, mock_get_q, mock_add_q, mock_update_quiz,
            mock_get_quiz, mock_update_asn, mock_verify):
        mock_get_quiz.return_value = {"id": 555, "quiz_type": "practice_quiz"}
        mock_verify.return_value = {
            "ok": True, "canvas_url": "http://x",
            "question_count": 1, "points_possible": 3, "warnings": []}

        result = push_to_canvas.push_quiz(_mock_config(), "555",
                                           TEST_COURSE_ID)
        self.assertTrue(result["ok"])
        self.assertEqual(result["question_count"], 1)
        mock_add_q.assert_called_once()
        # Staging should be cleared
        self.assertIsNone(
            staging_manager.get_staged(TEST_COURSE_ID, "quizzes", "555"))

    @patch("push_to_canvas.post_write_verify.verify_quiz")
    @patch("push_to_canvas.canvas_api.update_assignment")
    @patch("push_to_canvas.canvas_api.get_quiz")
    @patch("push_to_canvas.canvas_api.update_quiz", return_value={"id": 555})
    @patch("push_to_canvas.canvas_api.add_quiz_question",
           return_value={"id": 1001})
    @patch("push_to_canvas.canvas_api.get_quiz_questions")
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_backup_omits_questions_when_fetch_returns_none(
            self, mock_backup, mock_get_q, mock_add_q, mock_update_quiz,
            mock_get_quiz, mock_update_asn, mock_verify):
        """Regression test for #212: a transient get_quiz_questions()
        failure (returns None) at backup time must not produce an
        unrestorable {"questions": None} backup payload — that crashes
        _restore_quiz on rollback."""
        mock_get_quiz.return_value = {"id": 555, "quiz_type": "practice_quiz"}
        # 1st call = pre-push backup fetch (simulated transient failure);
        # 2nd call = reconciliation fetch (succeeds normally).
        mock_get_q.side_effect = [None, []]
        mock_verify.return_value = {
            "ok": True, "canvas_url": "http://x",
            "question_count": 1, "points_possible": 3, "warnings": []}

        result = push_to_canvas.push_quiz(_mock_config(), "555",
                                           TEST_COURSE_ID)

        self.assertTrue(result["ok"])
        mock_backup.assert_called_once()
        backup_data = mock_backup.call_args[0][3]
        self.assertNotIn(
            "questions", backup_data,
            "backup payload must omit 'questions' rather than storing None "
            "when the pre-push fetch failed")

    def test_canvas_answer_feedback_normalizes_to_write_keys(self):
        answers = [
            {
                "answer_text": "A",
                "answer_weight": 100,
                "comments": "Plain feedback",
                "comments_html": "<p>Plain feedback</p>",
            },
            {
                "answer_text": "B",
                "answer_weight": 0,
                "answer_comment": "Legacy feedback",
                "answer_comments_html": "<p>Legacy feedback</p>",
            },
        ]

        normalized = canvas_api._normalize_quiz_answers_for_write(answers)

        self.assertEqual(normalized[0]["answer_comments"], "Plain feedback")
        self.assertEqual(
            normalized[0]["answer_comment_html"],
            "<p>Plain feedback</p>",
        )
        self.assertEqual(normalized[1]["answer_comments"], "Legacy feedback")
        self.assertEqual(
            normalized[1]["answer_comment_html"],
            "<p>Legacy feedback</p>",
        )

    @patch("push_to_canvas.post_write_verify.verify_quiz")
    @patch("push_to_canvas.canvas_api.update_assignment")
    @patch("push_to_canvas.canvas_api.get_quiz")
    @patch("push_to_canvas.canvas_api.update_quiz", return_value={"id": 556})
    @patch("push_to_canvas.canvas_api.add_quiz_question",
           return_value={"id": 1002})
    @patch("push_to_canvas.canvas_api.get_quiz_questions", return_value=[])
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_push_quiz_passes_question_and_answer_feedback(
            self, mock_backup, mock_get_q, mock_add_q, mock_update_quiz,
            mock_get_quiz, mock_update_asn, mock_verify):
        staged_quiz = {
            "quiz": {"title": "Feedback Quiz"},
            "questions": [
                {
                    "question_name": "Feedback Q",
                    "question_text": "<p>Choose one.</p>",
                    "question_type": "multiple_choice_question",
                    "points_possible": 1,
                    "correct_comments": "Question correct feedback",
                    "incorrect_comments": "Question incorrect feedback",
                    "neutral_comments": "Question neutral feedback",
                    "answers": [
                        {
                            "answer_text": "Correct",
                            "answer_weight": 100,
                            "answer_comments": "Answer correct feedback",
                            "answer_comment_html": (
                                "<p>Answer correct feedback</p>"
                            ),
                        },
                        {
                            "answer_text": "Incorrect",
                            "answer_weight": 0,
                            "comments": "Answer incorrect feedback",
                            "comments_html": (
                                "<p>Answer incorrect feedback</p>"
                            ),
                        },
                    ],
                }
            ],
        }
        staging_manager.stage(TEST_COURSE_ID, "quizzes", "556",
                              staged_quiz, validate=False)
        mock_get_quiz.return_value = {"id": 556, "quiz_type": "practice_quiz"}
        mock_verify.return_value = {
            "ok": True, "canvas_url": "http://x",
            "question_count": 1, "points_possible": 1, "warnings": []}

        result = push_to_canvas.push_quiz(_mock_config(), "556",
                                           TEST_COURSE_ID)

        self.assertTrue(result["ok"])
        mock_add_q.assert_called_once()
        _, kwargs = mock_add_q.call_args
        self.assertEqual(kwargs["correct_comments"],
                         "Question correct feedback")
        self.assertEqual(kwargs["incorrect_comments"],
                         "Question incorrect feedback")
        self.assertEqual(kwargs["neutral_comments"],
                         "Question neutral feedback")
        self.assertEqual(
            kwargs["answers"][0]["answer_comments"],
            "Answer correct feedback",
        )
        self.assertEqual(
            kwargs["answers"][0]["answer_comment_html"],
            "<p>Answer correct feedback</p>",
        )
        self.assertEqual(
            kwargs["answers"][1]["comments"],
            "Answer incorrect feedback",
        )
        self.assertEqual(
            kwargs["answers"][1]["comments_html"],
            "<p>Answer incorrect feedback</p>",
        )

    def test_quiz_push_rejects_non_numeric_staged_id(self):
        staged_id = "m1-quiz-revised-questions-mockup"
        staging_manager.stage(TEST_COURSE_ID, "quizzes", staged_id,
                              self.quiz_content, validate=False)

        result = push_to_canvas.push_quiz(_mock_config(), staged_id,
                                           TEST_COURSE_ID)

        self.assertFalse(result["ok"])
        self.assertTrue(result["staging_preserved"])
        self.assertIn("numeric Canvas quiz ID", result["error"])
        self.assertIn("--rename", result["error"])
        self.assertIsNotNone(
            staging_manager.get_staged(TEST_COURSE_ID, "quizzes", staged_id))

    @patch("push_to_canvas.post_write_verify.verify_quiz")
    @patch("push_to_canvas.canvas_api.update_assignment")
    @patch("push_to_canvas.canvas_api.get_quiz")
    @patch("push_to_canvas.canvas_api.update_quiz", return_value={"id": 777})
    @patch("push_to_canvas.canvas_api.add_quiz_question",
           side_effect=[{"id": 2001}, {"id": 2002}, {"id": 2003}])
    @patch("push_to_canvas.canvas_api.get_quiz_questions", return_value=[])
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_three_question_quiz_push_backs_up_verifies_and_clears(
            self, mock_backup, mock_get_q, mock_add_q, mock_update_quiz,
            mock_get_quiz, mock_update_asn, mock_verify):
        staged_quiz = json.loads("""
        {
          "quiz": {"title": "Three Question Quiz"},
          "questions": [
            {
              "question_name": "Q1",
              "question_text": "<p>First?</p>",
              "question_type": "multiple_choice_question",
              "points_possible": 1,
              "answers": [{"answer_text": "A", "answer_weight": 100}]
            },
            {
              "question_name": "Q2",
              "question_text": "<p>Second?</p>",
              "question_type": "true_false_question",
              "points_possible": 2,
              "answers": [{"answer_text": "True", "answer_weight": 100}]
            },
            {
              "question_name": "Q3",
              "question_text": "<p>Third?</p>",
              "question_type": "short_answer_question",
              "points_possible": 3,
              "answers": [{"answer_text": "Done", "answer_weight": 100}]
            }
          ]
        }
        """)
        staging_manager.stage(TEST_COURSE_ID, "quizzes", "777",
                              staged_quiz, validate=False)
        mock_get_quiz.side_effect = [
            {"id": 777, "title": "Old Quiz", "quiz_type": "practice_quiz"},
            {"id": 777, "quiz_type": "practice_quiz"},
        ]
        mock_verify.return_value = {
            "ok": True, "canvas_url": "http://x",
            "question_count": 3, "points_possible": 6, "warnings": []}

        result = push_to_canvas.push_quiz(_mock_config(), "777",
                                           TEST_COURSE_ID)

        self.assertTrue(result["ok"])
        mock_backup.assert_called_once()
        self.assertEqual(mock_add_q.call_count, 3)
        expected_calls = [
            call(_mock_config(), "777", question_name="Q1",
                 question_text="<p>First?</p>",
                 question_type="multiple_choice_question",
                 points_possible=1, answers=[{"answer_text": "A",
                                               "answer_weight": 100}],
                 position=None,
                 correct_comments=None, incorrect_comments=None,
                 neutral_comments=None, text_after_answers=None),
            call(_mock_config(), "777", question_name="Q2",
                 question_text="<p>Second?</p>",
                 question_type="true_false_question",
                 points_possible=2, answers=[{"answer_text": "True",
                                               "answer_weight": 100}],
                 position=None,
                 correct_comments=None, incorrect_comments=None,
                 neutral_comments=None, text_after_answers=None),
            call(_mock_config(), "777", question_name="Q3",
                 question_text="<p>Third?</p>",
                 question_type="short_answer_question",
                 points_possible=3, answers=[{"answer_text": "Done",
                                               "answer_weight": 100}],
                 position=None,
                 correct_comments=None, incorrect_comments=None,
                 neutral_comments=None, text_after_answers=None),
        ]
        mock_add_q.assert_has_calls(expected_calls)
        mock_verify.assert_called_once_with(
            _mock_config(), "777", expected_points=6, expected_questions=3)
        self.assertIsNone(
            staging_manager.get_staged(TEST_COURSE_ID, "quizzes", "777"))

    @patch("push_to_canvas.post_write_verify.verify_quiz")
    @patch("push_to_canvas.canvas_api.update_quiz", return_value={"id": 555})
    @patch("push_to_canvas.canvas_api.add_quiz_question", return_value=None)
    @patch("push_to_canvas.canvas_api.get_quiz_questions", return_value=[])
    @patch("push_to_canvas.canvas_api.get_quiz",
           return_value={"id": 555, "quiz_type": "practice_quiz"})
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_question_failure_preserves_staging(
            self, mock_backup, mock_get_quiz, mock_get_q, mock_add_q,
            mock_update_quiz, mock_verify):
        result = push_to_canvas.push_quiz(_mock_config(), "555",
                                           TEST_COURSE_ID)
        self.assertFalse(result["ok"])
        self.assertTrue(result.get("staging_preserved"))
        # Staging should still exist
        self.assertIsNotNone(
            staging_manager.get_staged(TEST_COURSE_ID, "quizzes", "555"))

    @patch("push_to_canvas.post_write_verify.verify_quiz")
    @patch("push_to_canvas.canvas_api.update_quiz", return_value={"id": 555})
    @patch("push_to_canvas.canvas_api.delete_quiz_question", return_value=True)
    @patch("push_to_canvas.canvas_api.get_quiz_questions",
           return_value=[{"id": 9001}, {"id": 9002}])
    @patch("push_to_canvas.canvas_api.get_quiz",
           return_value={"id": 555, "quiz_type": "practice_quiz"})
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_empty_questions_deletes_all_existing(
            self, mock_backup, mock_get_quiz, mock_get_q, mock_delete_q,
            mock_update_quiz, mock_verify):
        # Stage with explicit empty questions list
        staging_manager.stage(TEST_COURSE_ID, "quizzes", "555",
                              {"quiz": {"title": "Empty"}, "questions": []})
        mock_verify.return_value = {
            "ok": True, "canvas_url": "http://x",
            "question_count": 0, "points_possible": 0, "warnings": []}

        result = push_to_canvas.push_quiz(_mock_config(), "555",
                                           TEST_COURSE_ID)
        self.assertTrue(result["ok"])
        # Should have deleted both existing questions
        self.assertEqual(mock_delete_q.call_count, 2)

    @patch("push_to_canvas.post_write_verify.verify_quiz")
    @patch("push_to_canvas.canvas_api.update_quiz", return_value={"id": 555})
    @patch("push_to_canvas.canvas_api.get_quiz_questions", return_value=[])
    @patch("push_to_canvas.canvas_api.get_quiz",
           return_value={"id": 555, "quiz_type": "practice_quiz"})
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_no_questions_key_skips_reconciliation(
            self, mock_backup, mock_get_quiz, mock_get_q, mock_update_quiz,
            mock_verify):
        # Stage with NO questions key — settings-only push
        staging_manager.stage(TEST_COURSE_ID, "quizzes", "555",
                              {"quiz": {"title": "Settings Only"}})
        mock_verify.return_value = {
            "ok": True, "canvas_url": "http://x",
            "question_count": 5, "points_possible": 15, "warnings": []}

        result = push_to_canvas.push_quiz(_mock_config(), "555",
                                           TEST_COURSE_ID)
        self.assertTrue(result["ok"])
        # verify was called without expected_questions/expected_points (None)
        mock_verify.assert_called_once()
        _, kwargs = mock_verify.call_args
        self.assertIsNone(kwargs.get("expected_questions"))
        self.assertIsNone(kwargs.get("expected_points"))

    @patch("push_to_canvas.post_write_verify.verify_quiz")
    @patch("push_to_canvas.canvas_api.update_assignment")
    @patch("push_to_canvas.canvas_api.get_quiz")
    @patch("push_to_canvas.canvas_api.update_quiz", return_value={"id": 555})
    @patch("push_to_canvas.canvas_api.add_quiz_question",
           return_value={"id": 1001})
    @patch("push_to_canvas.canvas_api.delete_quiz_question", return_value=True)
    @patch("push_to_canvas.canvas_api.get_quiz_questions",
           return_value=[{"id": 8001}])
    @patch("push_to_canvas.backup_manager.save_backup")
    def test_deletes_stale_questions_not_in_staged(
            self, mock_backup, mock_get_q, mock_delete_q, mock_add_q,
            mock_update_quiz, mock_get_quiz, mock_update_asn, mock_verify):
        mock_get_quiz.return_value = {"id": 555, "quiz_type": "practice_quiz"}
        mock_verify.return_value = {
            "ok": True, "canvas_url": "http://x",
            "question_count": 1, "points_possible": 3, "warnings": []}

        result = push_to_canvas.push_quiz(_mock_config(), "555",
                                           TEST_COURSE_ID)
        self.assertTrue(result["ok"])
        # Should have created 1 new question
        mock_add_q.assert_called_once()
        # Should have deleted the stale question 8001
        mock_delete_q.assert_called_once_with(
            _mock_config(), "555", 8001)


class TestConfirmVerbGate(unittest.TestCase):
    """CLI refuses non-dry-run push without an explicit --confirm-verb.

    Per CLAUDE.md §Write Safety Guard: the gate is script-enforced, not
    behavior-only. Ambiguous affirmatives ('ok', 'yes', 'confirm') are
    rejected. Only 'push', 'approved', 'deploy' are accepted.
    """

    SCRIPT = os.path.join(PLUGIN_ROOT, "scripts", "push_to_canvas.py")

    def _run(self, args):
        import subprocess
        return subprocess.run(
            [sys.executable, self.SCRIPT, *args],
            capture_output=True, text=True, cwd=PLUGIN_ROOT,
        )

    def test_refuses_without_verb(self):
        r = self._run(["--type", "page", "--slug", "x", "--course-id", "1"])
        self.assertNotEqual(r.returncode, 0)
        self.assertIn("confirm-verb", r.stderr)

    def test_refuses_ambiguous_verb(self):
        for bad in ("yes", "ok", "confirm"):
            r = self._run(["--type", "page", "--slug", "x",
                           "--course-id", "1", "--confirm-verb", bad])
            self.assertNotEqual(r.returncode, 0, f"verb={bad}")
            self.assertIn("confirm-verb", r.stderr.lower() + r.stdout.lower())

    def test_accepts_valid_verbs(self):
        # Valid verbs pass the gate. Use --dry-run so no Canvas state needed;
        # we set verb anyway to prove argparse accepts it alongside dry-run.
        for verb in ("push", "approved", "deploy"):
            r = self._run(["--type", "page", "--slug", "no-such-slug",
                           "--course-id", "1", "--dry-run",
                           "--confirm-verb", verb])
            # Past argparse: stderr should NOT carry the gate error.
            self.assertNotIn(
                "non-dry-run push requires --confirm-verb",
                r.stderr,
                f"verb={verb} stderr={r.stderr!r}",
            )

    def test_dry_run_exempt(self):
        # Dry-run without verb should pass argparse (downstream may still
        # exit non-zero for missing config/staged file — that's fine).
        r = self._run(["--type", "page", "--slug", "no-such-slug",
                       "--course-id", "1", "--dry-run"])
        self.assertNotIn(
            "non-dry-run push requires --confirm-verb",
            r.stderr,
        )


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