"""Tests for Canvas write guards added in feat/canvas-write-gating-guard.

Guards:
  A. delete_page / delete_quiz / delete_assignment / delete_discussion
     - Raise PermissionError when confirm=False (default)
     - Back up then delete when confirm=True

  B. create_page / create_quiz / create_assignment / create_discussion
     - Emit logging.WARNING when from_staging=False (default)
     - Silent when from_staging=True

  C. update_page (#208 — parity with B: update_page had no from_staging
     guard at all)
     - Emit logging.WARNING when from_staging=False (default)
     - Silent when from_staging=True

All network calls and backup_manager are mocked.
"""
import importlib
import logging
import os
import sys
import types
import unittest
from unittest.mock import MagicMock, call, patch

# Ensure scripts/ is on the path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

import canvas_api


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

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


def _ok_response(status=200, body=None):
    r = MagicMock()
    r.status_code = status
    r.json.return_value = body or {}
    return r


def _backup_ok():
    return {"ok": True, "path": "/tmp/backup", "timestamp": "20260605T120000Z",
            "checksum": "abc123"}


def _backup_fail():
    return {"ok": False, "error": "disk full"}


# ---------------------------------------------------------------------------
# A. Delete guards — PermissionError without confirm
# ---------------------------------------------------------------------------

class TestDeleteGuardsRaiseWithoutConfirm(unittest.TestCase):
    """Each delete_* function must raise PermissionError when confirm is not set."""

    def test_delete_page_raises_without_confirm(self):
        with self.assertRaises(PermissionError) as ctx:
            canvas_api.delete_page(_mock_config(), "some-page-slug")
        self.assertIn("confirm=True", str(ctx.exception))

    def test_delete_quiz_raises_without_confirm(self):
        with self.assertRaises(PermissionError) as ctx:
            canvas_api.delete_quiz(_mock_config(), 123)
        self.assertIn("confirm=True", str(ctx.exception))

    def test_delete_assignment_raises_without_confirm(self):
        with self.assertRaises(PermissionError) as ctx:
            canvas_api.delete_assignment(_mock_config(), 456)
        self.assertIn("confirm=True", str(ctx.exception))

    def test_delete_discussion_raises_without_confirm(self):
        with self.assertRaises(PermissionError) as ctx:
            canvas_api.delete_discussion(_mock_config(), 789)
        self.assertIn("confirm=True", str(ctx.exception))


class TestDeleteGuardsDoNotCallHttpWithoutConfirm(unittest.TestCase):
    """HTTP delete must never fire when confirm is False."""

    @patch("canvas_api._request_with_retry")
    def test_delete_page_no_http_without_confirm(self, mock_req):
        try:
            canvas_api.delete_page(_mock_config(), "slug")
        except PermissionError:
            pass
        mock_req.assert_not_called()

    @patch("canvas_api._request_with_retry")
    def test_delete_quiz_no_http_without_confirm(self, mock_req):
        try:
            canvas_api.delete_quiz(_mock_config(), 1)
        except PermissionError:
            pass
        mock_req.assert_not_called()

    @patch("canvas_api._request_with_retry")
    def test_delete_assignment_no_http_without_confirm(self, mock_req):
        try:
            canvas_api.delete_assignment(_mock_config(), 2)
        except PermissionError:
            pass
        mock_req.assert_not_called()

    @patch("canvas_api._request_with_retry")
    def test_delete_discussion_no_http_without_confirm(self, mock_req):
        try:
            canvas_api.delete_discussion(_mock_config(), 3)
        except PermissionError:
            pass
        mock_req.assert_not_called()


# ---------------------------------------------------------------------------
# A. Delete guards — backup then delete when confirm=True
# ---------------------------------------------------------------------------

class TestDeleteGuardsBackupThenDeleteOnConfirm(unittest.TestCase):
    """With confirm=True the backup must be called before the HTTP delete."""

    def _fake_bm(self):
        bm = MagicMock()
        bm.save_backup.return_value = _backup_ok()
        return bm

    # --- delete_page ---
    @patch("canvas_api._request_with_retry")
    @patch("canvas_api.get_page")
    @patch("canvas_api._backup_manager")
    def test_delete_page_backs_up_before_delete(self, mock_bm_fn, mock_get, mock_req):
        bm = self._fake_bm()
        mock_bm_fn.return_value = bm
        mock_get.return_value = {"body": "<p>Hello</p>", "url": "slug"}
        mock_req.return_value = _ok_response(200)

        result = canvas_api.delete_page(_mock_config(), "slug", confirm=True)

        bm.save_backup.assert_called_once()
        args = bm.save_backup.call_args
        # Backup called with course_id, object_type='pages', object_id='slug'
        self.assertEqual(args[0][1], "pages")
        self.assertEqual(args[0][2], "slug")
        # HTTP delete fires after backup
        mock_req.assert_called_once()
        self.assertTrue(result)

    @patch("canvas_api._request_with_retry")
    @patch("canvas_api.get_page")
    @patch("canvas_api._backup_manager")
    def test_delete_page_aborts_if_backup_fails(self, mock_bm_fn, mock_get, mock_req):
        bm = self._fake_bm()
        bm.save_backup.return_value = _backup_fail()
        mock_bm_fn.return_value = bm
        mock_get.return_value = {"body": "<p>Hello</p>"}

        with self.assertRaises(RuntimeError) as ctx:
            canvas_api.delete_page(_mock_config(), "slug", confirm=True)
        self.assertIn("Delete aborted", str(ctx.exception))
        mock_req.assert_not_called()

    # --- delete_quiz ---
    @patch("canvas_api._request_with_retry")
    @patch("canvas_api.get_quiz")
    @patch("canvas_api._backup_manager")
    def test_delete_quiz_backs_up_before_delete(self, mock_bm_fn, mock_get, mock_req):
        bm = self._fake_bm()
        mock_bm_fn.return_value = bm
        mock_get.return_value = {"id": 77, "title": "Quiz"}
        mock_req.return_value = _ok_response(200)

        result = canvas_api.delete_quiz(_mock_config(), 77, confirm=True)

        bm.save_backup.assert_called_once()
        args = bm.save_backup.call_args
        self.assertEqual(args[0][1], "quizzes")
        self.assertEqual(args[0][2], "77")
        mock_req.assert_called_once()
        self.assertTrue(result)

    @patch("canvas_api._request_with_retry")
    @patch("canvas_api.get_quiz")
    @patch("canvas_api._backup_manager")
    def test_delete_quiz_aborts_if_backup_fails(self, mock_bm_fn, mock_get, mock_req):
        bm = self._fake_bm()
        bm.save_backup.return_value = _backup_fail()
        mock_bm_fn.return_value = bm
        mock_get.return_value = {"id": 77}

        with self.assertRaises(RuntimeError):
            canvas_api.delete_quiz(_mock_config(), 77, confirm=True)
        mock_req.assert_not_called()

    # --- delete_assignment ---
    @patch("canvas_api._request_with_retry")
    @patch("canvas_api.get_assignment")
    @patch("canvas_api._backup_manager")
    def test_delete_assignment_backs_up_before_delete(self, mock_bm_fn, mock_get, mock_req):
        bm = self._fake_bm()
        mock_bm_fn.return_value = bm
        mock_get.return_value = {"id": 55, "description": "<p>Asn</p>"}
        mock_req.return_value = _ok_response(200)

        result = canvas_api.delete_assignment(_mock_config(), 55, confirm=True)

        bm.save_backup.assert_called_once()
        args = bm.save_backup.call_args
        self.assertEqual(args[0][1], "assignments")
        self.assertEqual(args[0][2], "55")
        mock_req.assert_called_once()
        self.assertTrue(result)

    @patch("canvas_api._request_with_retry")
    @patch("canvas_api.get_assignment")
    @patch("canvas_api._backup_manager")
    def test_delete_assignment_aborts_if_backup_fails(self, mock_bm_fn, mock_get, mock_req):
        bm = self._fake_bm()
        bm.save_backup.return_value = _backup_fail()
        mock_bm_fn.return_value = bm
        mock_get.return_value = {"id": 55, "description": "x"}

        with self.assertRaises(RuntimeError):
            canvas_api.delete_assignment(_mock_config(), 55, confirm=True)
        mock_req.assert_not_called()

    # --- delete_discussion ---
    @patch("canvas_api._request_with_retry")
    @patch("canvas_api.get_discussion")
    @patch("canvas_api._backup_manager")
    def test_delete_discussion_backs_up_before_delete(self, mock_bm_fn, mock_get, mock_req):
        bm = self._fake_bm()
        mock_bm_fn.return_value = bm
        mock_get.return_value = {"id": 33, "message": "<p>Disc</p>"}
        mock_req.return_value = _ok_response(200)

        result = canvas_api.delete_discussion(_mock_config(), 33, confirm=True)

        bm.save_backup.assert_called_once()
        args = bm.save_backup.call_args
        self.assertEqual(args[0][1], "discussions")
        self.assertEqual(args[0][2], "33")
        mock_req.assert_called_once()
        self.assertTrue(result)

    @patch("canvas_api._request_with_retry")
    @patch("canvas_api.get_discussion")
    @patch("canvas_api._backup_manager")
    def test_delete_discussion_aborts_if_backup_fails(self, mock_bm_fn, mock_get, mock_req):
        bm = self._fake_bm()
        bm.save_backup.return_value = _backup_fail()
        mock_bm_fn.return_value = bm
        mock_get.return_value = {"id": 33, "message": "x"}

        with self.assertRaises(RuntimeError):
            canvas_api.delete_discussion(_mock_config(), 33, confirm=True)
        mock_req.assert_not_called()


# ---------------------------------------------------------------------------
# B. Create guards — warning when from_staging=False, silent when True
# ---------------------------------------------------------------------------

class TestCreateGuardsStagingWarning(unittest.TestCase):
    """create_* emits a WARNING when from_staging=False (default)."""

    def _ok_create_response(self, body, status=201):
        r = MagicMock()
        r.status_code = status
        r.reason = ""
        r.text = ""
        r.json.return_value = body
        return r

    @patch("canvas_api._request_with_retry")
    def test_create_page_warns_without_from_staging(self, mock_req):
        mock_req.return_value = self._ok_create_response(
            {"url": "test-page", "title": "Test"})
        with self.assertLogs("root", level="WARNING") as cm:
            canvas_api.create_page(_mock_config(), "T", "<p>body</p>")
        self.assertTrue(any("from_staging=False" in m for m in cm.output))

    @patch("canvas_api._request_with_retry")
    def test_create_page_silent_with_from_staging(self, mock_req):
        mock_req.return_value = self._ok_create_response(
            {"url": "test-page", "title": "Test"})
        # assertLogs raises AssertionError when no messages are logged — that's
        # exactly the "silent" proof we need.
        with self.assertRaises(AssertionError):
            with self.assertLogs("root", level="WARNING"):
                canvas_api.create_page(_mock_config(), "T", "<p>body</p>",
                                       from_staging=True)

    @patch("canvas_api._request_with_retry")
    def test_create_quiz_warns_without_from_staging(self, mock_req):
        mock_req.return_value = self._ok_create_response({"id": 10, "title": "Q"},
                                                         status=200)
        with self.assertLogs("root", level="WARNING") as cm:
            canvas_api.create_quiz(_mock_config(), "Quiz A")
        self.assertTrue(any("from_staging=False" in m for m in cm.output))

    @patch("canvas_api._request_with_retry")
    def test_create_quiz_silent_with_from_staging(self, mock_req):
        mock_req.return_value = self._ok_create_response({"id": 10, "title": "Q"},
                                                         status=200)
        with self.assertRaises(AssertionError):
            with self.assertLogs("root", level="WARNING"):
                canvas_api.create_quiz(_mock_config(), "Quiz A", from_staging=True)

    @patch("canvas_api._request_with_retry")
    def test_create_assignment_warns_without_from_staging(self, mock_req):
        mock_req.return_value = self._ok_create_response(
            {"id": 20, "name": "Asn"})
        with self.assertLogs("root", level="WARNING") as cm:
            canvas_api.create_assignment(_mock_config(), "Asn A")
        self.assertTrue(any("from_staging=False" in m for m in cm.output))

    @patch("canvas_api._request_with_retry")
    def test_create_assignment_silent_with_from_staging(self, mock_req):
        mock_req.return_value = self._ok_create_response(
            {"id": 20, "name": "Asn"})
        with self.assertRaises(AssertionError):
            with self.assertLogs("root", level="WARNING"):
                canvas_api.create_assignment(_mock_config(), "Asn A",
                                             from_staging=True)

    @patch("canvas_api._request_with_retry")
    def test_create_discussion_warns_without_from_staging(self, mock_req):
        mock_req.return_value = self._ok_create_response(
            {"id": 30, "title": "Disc"})
        with self.assertLogs("root", level="WARNING") as cm:
            canvas_api.create_discussion(_mock_config(), "Disc A")
        self.assertTrue(any("from_staging=False" in m for m in cm.output))

    @patch("canvas_api._request_with_retry")
    def test_create_discussion_silent_with_from_staging(self, mock_req):
        mock_req.return_value = self._ok_create_response(
            {"id": 30, "title": "Disc"})
        with self.assertRaises(AssertionError):
            with self.assertLogs("root", level="WARNING"):
                canvas_api.create_discussion(_mock_config(), "Disc A",
                                             from_staging=True)


# ---------------------------------------------------------------------------
# C. update_page guard — warning when from_staging=False, silent when True
# ---------------------------------------------------------------------------

class TestUpdatePageStagingWarning(unittest.TestCase):
    """update_page emits a WARNING when from_staging=False (default), mirroring
    the create_* guards above (#208)."""

    def _ok_update_response(self, status=200):
        r = MagicMock()
        r.status_code = status
        r.reason = ""
        r.text = ""
        r.json.return_value = {}
        return r

    @patch("canvas_api._request_with_retry")
    def test_update_page_warns_without_from_staging(self, mock_req):
        mock_req.return_value = self._ok_update_response()
        with self.assertLogs("root", level="WARNING") as cm:
            canvas_api.update_page(_mock_config(), "some-slug", "<p>body</p>")
        self.assertTrue(any("from_staging=False" in m for m in cm.output))

    @patch("canvas_api._request_with_retry")
    def test_update_page_silent_with_from_staging(self, mock_req):
        mock_req.return_value = self._ok_update_response()
        # assertLogs raises AssertionError when no messages are logged — that's
        # exactly the "silent" proof we need.
        with self.assertRaises(AssertionError):
            with self.assertLogs("root", level="WARNING"):
                canvas_api.update_page(_mock_config(), "some-slug",
                                       "<p>body</p>", from_staging=True)


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