#!/usr/bin/env python3
"""tests/test_push_pipeline.py — Mocked Canvas integration tests.

Covers push_to_canvas, post_write_verify, backup_manager, push_syllabus,
and staging_manager. All HTTP calls mocked via unittest.mock.
Filesystem isolated via tempfile.TemporaryDirectory.

Run: python3 -m unittest tests.test_push_pipeline -v
"""

import hashlib
import json
import os
import pathlib
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, patch, PropertyMock

# Ensure scripts/ is importable
_HERE = pathlib.Path(__file__).parent.parent / "scripts"
sys.path.insert(0, str(_HERE))


def _make_config(domain="canvas.example.edu", course_id="99999", token="fake-token"):
    return {
        "domain": domain,
        "course_id": course_id,
        "token": token,
        "headers": {"Authorization": f"Bearer {token}"},
    }


def _mock_resp(status=200, json_body=None, text=""):
    resp = MagicMock()
    resp.status_code = status
    resp.ok = (status < 400)
    resp.json.return_value = json_body or {}
    resp.text = text
    resp.raise_for_status = MagicMock()
    if status >= 400:
        from requests.exceptions import HTTPError
        resp.raise_for_status.side_effect = HTTPError(f"HTTP {status}")
    return resp


# ---------------------------------------------------------------------------
# backup_manager tests
# ---------------------------------------------------------------------------

class TestBackupManager(unittest.TestCase):

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.tmp_root = pathlib.Path(self.tmp.name)
        import backup_manager
        self._bm = backup_manager
        self._orig_root = backup_manager.get_plugin_root
        backup_manager.get_plugin_root = lambda: self.tmp_root

    def tearDown(self):
        self._bm.get_plugin_root = self._orig_root
        self.tmp.cleanup()

    def test_save_backup_writes_file_and_meta(self):
        result = self._bm.save_backup("99999", "pages", "test-slug", "<h1>Hello</h1>")
        self.assertTrue(result["ok"])
        self.assertTrue(os.path.exists(result["path"]))
        meta_path = result["path"] + ".meta.json"
        self.assertTrue(os.path.exists(meta_path))
        with open(meta_path) as f:
            meta = json.load(f)
        self.assertEqual(meta["object_id"], "test-slug")
        self.assertEqual(meta["object_type"], "pages")

    def test_save_backup_checksum_correct(self):
        content = "<p>Test content</p>"
        result = self._bm.save_backup("99999", "pages", "chk-slug", content)
        expected = hashlib.sha256(content.encode("utf-8")).hexdigest()
        self.assertEqual(result["checksum"], expected)

    def test_save_backup_invalid_type(self):
        result = self._bm.save_backup("99999", "invalid_type", "x", "content")
        self.assertFalse(result["ok"])

    def test_list_backups_finds_saved(self):
        import time
        self._bm.save_backup("77001", "pages", "list-slug-a", "<p>A</p>")
        self._bm.save_backup("77001", "pages", "list-slug-b", "<p>B</p>")
        backups = self._bm.list_backups("77001", object_type="pages")
        self.assertGreaterEqual(len(backups), 2)

    def test_get_backup_content_round_trip(self):
        original = "<div>Round trip content</div>"
        result = self._bm.save_backup("77002", "pages", "rt-slug", original)
        ts = result["timestamp"]
        recovered = self._bm.get_backup_content("77002", "pages", "rt-slug", ts)
        self.assertEqual(recovered, original)

    # -- Issue #212: checksum-verified restore ---------------------------

    def test_get_backup_content_raises_on_corrupted_backup(self):
        """A backup file corrupted on disk after being written (bit rot, a
        truncated write, a bad copy) must never be silently handed back —
        the stored checksum has to catch it."""
        original = "<div>Original, trustworthy content</div>"
        result = self._bm.save_backup("77021", "pages", "corrupt-slug",
                                      original)
        ts = result["timestamp"]
        # Corrupt the backup file's bytes directly, leaving the .meta.json
        # sidecar (and its recorded checksum) untouched — simulates disk
        # corruption / a torn write independent of the atomic-write fix.
        with open(result["path"], "w", encoding="utf-8") as f:
            f.write("<div>TAMPERED — must never be restored</div>")

        with self.assertRaises(ValueError):
            self._bm.get_backup_content("77021", "pages", "corrupt-slug", ts)

    def test_restore_backup_fails_closed_on_corrupted_backup(self):
        """restore_backup must refuse to push corrupted content to Canvas
        rather than restoring it."""
        original = "<div>Good content</div>"
        result = self._bm.save_backup("77022", "pages", "corrupt-restore",
                                      original)
        ts = result["timestamp"]
        with open(result["path"], "w", encoding="utf-8") as f:
            f.write("<div>TAMPERED</div>")

        config = _make_config(course_id="77022")
        with patch("backup_manager.canvas_api.update_page",
                   return_value=True) as mock_update:
            res = self._bm.restore_backup(config, "77022", "pages",
                                          "corrupt-restore", ts)

        self.assertFalse(res["ok"])
        mock_update.assert_not_called()

    def test_get_backup_content_skips_verification_for_legacy_backup(self):
        """Backups saved before checksums existed (no "checksum" key in
        their .meta.json sidecar) must keep working rather than failing
        closed — there is nothing recorded to verify against."""
        original = "<div>Legacy backup, no checksum recorded</div>"
        result = self._bm.save_backup("77023", "pages", "legacy-slug",
                                      original)
        ts = result["timestamp"]
        meta_path = result["path"] + ".meta.json"
        with open(meta_path) as f:
            meta = json.load(f)
        del meta["checksum"]
        with open(meta_path, "w") as f:
            json.dump(meta, f)
        # Content differs from what was originally saved too — verification
        # must be skipped entirely for a legacy sidecar, not just tolerant.
        with open(result["path"], "w", encoding="utf-8") as f:
            f.write("<div>Different content, unverifiable</div>")

        recovered = self._bm.get_backup_content("77023", "pages",
                                                 "legacy-slug", ts)
        self.assertEqual(recovered, "<div>Different content, unverifiable</div>")

    # -- Issue #212: atomic backup write ----------------------------------

    def test_save_backup_uses_atomic_replace(self):
        """save_backup must write via temp-file + os.replace, not a direct
        truncating write, so a crash or full disk mid-write can never leave
        a half-written backup sitting at the final path."""
        with patch("backup_manager.os.replace", wraps=os.replace) as mock_replace:
            result = self._bm.save_backup("77020", "pages", "atomic-slug",
                                          "<p>Atomic content</p>")
        self.assertTrue(result["ok"])
        # One replace for the backup file, one for the .meta.json sidecar.
        self.assertGreaterEqual(mock_replace.call_count, 1)
        with open(result["path"], "r", encoding="utf-8") as f:
            self.assertEqual(f.read(), "<p>Atomic content</p>")
        # No leftover temp file in the backup directory.
        dirpath = os.path.dirname(result["path"])
        leftovers = [fn for fn in os.listdir(dirpath) if fn.endswith(".tmp")]
        self.assertEqual(leftovers, [])

    # -- Issue #212: _restore_quiz defensive against None questions ------

    def test_restore_quiz_handles_none_questions_without_typeerror(self):
        """_restore_quiz must not crash when a backup payload has an
        explicit "questions": null (e.g. saved during a transient
        get_quiz_questions() failure at backup time)."""
        config = _make_config(course_id="77024")
        content = {"quiz": {"title": "Restored"}, "questions": None}
        with patch("backup_manager.canvas_api.update_quiz",
                   return_value=True) as mock_update_quiz, \
             patch("backup_manager.canvas_api.get_quiz_questions") as mock_get_q:
            ok = self._bm._restore_quiz(config, "900", content)
        self.assertTrue(ok)
        mock_update_quiz.assert_called_once()
        # Must not attempt to reconcile questions against a None list.
        mock_get_q.assert_not_called()

    def test_restore_quiz_handles_missing_questions_key(self):
        """A settings-only backup (no "questions" key at all) must also
        restore cleanly without touching questions."""
        config = _make_config(course_id="77025")
        content = {"quiz": {"title": "Settings Only"}}
        with patch("backup_manager.canvas_api.update_quiz",
                   return_value=True) as mock_update_quiz, \
             patch("backup_manager.canvas_api.get_quiz_questions") as mock_get_q:
            ok = self._bm._restore_quiz(config, "900", content)
        self.assertTrue(ok)
        mock_update_quiz.assert_called_once()
        mock_get_q.assert_not_called()


# ---------------------------------------------------------------------------
# staging_manager tests
# ---------------------------------------------------------------------------

class TestStagingManager(unittest.TestCase):

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.tmp_root = pathlib.Path(self.tmp.name)
        import staging_manager
        self._sm = staging_manager
        self._orig_root = staging_manager.get_plugin_root
        staging_manager.get_plugin_root = lambda: self.tmp_root

    def tearDown(self):
        self._sm.get_plugin_root = self._orig_root
        self.tmp.cleanup()

    def test_stage_and_get(self):
        html = "<h1>Staged Page</h1><p>Content here</p>"
        result = self._sm.stage("88001", "pages", "module1-overview", html, validate=False)
        self.assertTrue(result.get("ok") or result.get("staged"))
        recovered = self._sm.get_staged("88001", "pages", "module1-overview")
        self.assertIsNotNone(recovered)
        self.assertIn("Staged Page", recovered)

    def test_get_staged_missing_returns_none(self):
        result = self._sm.get_staged("88002", "pages", "nonexistent-slug")
        self.assertIsNone(result)

    def test_list_staged(self):
        self._sm.stage("88003", "pages", "page-a", "<p>A</p>", validate=False)
        self._sm.stage("88003", "pages", "page-b", "<p>B</p>", validate=False)
        listed = self._sm.list_staged("88003", object_type="pages")
        slugs = [item.get("object_id") or item.get("id") for item in listed]
        self.assertIn("page-a", slugs)
        self.assertIn("page-b", slugs)

    def test_clear_staged_removes_item(self):
        self._sm.stage("88004", "pages", "temp-page", "<p>Temp</p>", validate=False)
        self._sm.clear_staged("88004", "pages", "temp-page")
        recovered = self._sm.get_staged("88004", "pages", "temp-page")
        self.assertIsNone(recovered)


# ---------------------------------------------------------------------------
# post_write_verify tests
# ---------------------------------------------------------------------------

class TestPostWriteVerify(unittest.TestCase):

    def setUp(self):
        self.config = _make_config()
        import post_write_verify
        self._pwv = post_write_verify

    def test_verify_page_passing(self):
        page_data = {
            "body": "<h1>Module Overview</h1><p>Content here.</p>",
            "title": "Module 1 Overview",
            "published": True,
        }
        with patch("post_write_verify.get_page", return_value=page_data):
            result = self._pwv.verify_page(self.config, "module1-overview")
        self.assertTrue(result["ok"])
        self.assertGreater(result["content_length"], 0)

    def test_verify_page_not_found(self):
        with patch("post_write_verify.get_page", return_value=None):
            result = self._pwv.verify_page(self.config, "missing-slug")
        self.assertFalse(result["ok"])
        self.assertIn("not found", result.get("error", "").lower())

    def test_verify_page_empty_body_warns(self):
        page_data = {"body": "  ", "title": "Empty", "published": True}
        with patch("post_write_verify.get_page", return_value=page_data):
            result = self._pwv.verify_page(self.config, "empty-slug")
        # ok may still be True but should have a warning
        self.assertTrue(len(result.get("warnings", [])) > 0)

    def test_verify_page_unpublished_warns(self):
        page_data = {
            "body": "<p>Some decent content here.</p>",
            "title": "Draft",
            "published": False,
        }
        with patch("post_write_verify.get_page", return_value=page_data):
            result = self._pwv.verify_page(self.config, "draft-slug")
        warnings = result.get("warnings", [])
        self.assertTrue(any("publish" in w.lower() for w in warnings))


# ---------------------------------------------------------------------------
# push_to_canvas tests (push_page)
# ---------------------------------------------------------------------------

class TestPushToCanvas(unittest.TestCase):

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.tmp_root = pathlib.Path(self.tmp.name)
        self.config = _make_config()
        import staging_manager
        import backup_manager
        import push_to_canvas
        self._ptc = push_to_canvas
        self._sm = staging_manager
        self._bm = backup_manager

        # Patch plugin root for staging and backup
        self._orig_sm_root = staging_manager.get_plugin_root
        self._orig_bm_root = backup_manager.get_plugin_root
        staging_manager.get_plugin_root = lambda: self.tmp_root
        backup_manager.get_plugin_root = lambda: self.tmp_root

        # Stage a test page
        self._sm.stage("99999", "pages", "test-page", "<h1>Test Page</h1><p>Content.</p>", validate=False)

    def tearDown(self):
        import staging_manager, backup_manager
        staging_manager.get_plugin_root = self._orig_sm_root
        backup_manager.get_plugin_root = self._orig_bm_root
        self.tmp.cleanup()

    def test_push_page_success_flow(self):
        current_page = {"body": "<h1>Old content</h1>", "title": "Old", "published": True}
        verify_resp = {
            "ok": True,
            "canvas_url": "https://canvas.example.edu/courses/99999/pages/test-page",
            "content_length": 30,
            "warnings": [],
        }
        with patch("canvas_api.get_page", return_value=current_page), \
             patch("canvas_api.update_page", return_value=True), \
             patch("post_write_verify.verify_page", return_value=verify_resp):
            result = self._ptc.push_page(self.config, "test-page", "99999")
        self.assertTrue(result["ok"])
        # Staging should be cleared after success
        recovered = self._sm.get_staged("99999", "pages", "test-page")
        self.assertIsNone(recovered)

    def test_push_page_canvas_rejects(self):
        # Re-stage since previous test may have cleared
        self._sm.stage("99999", "pages", "fail-page", "<h1>Fail</h1><p>Content.</p>", validate=False)
        current_page = {"body": "<h1>Existing</h1>", "title": "Existing", "published": True}
        with patch("canvas_api.get_page", return_value=current_page), \
             patch("canvas_api.update_page", return_value=False):
            result = self._ptc.push_page(self.config, "fail-page", "99999")
        self.assertFalse(result["ok"])
        self.assertIn("error", result)

    def test_push_page_verify_mismatch_preserves_staging(self):
        self._sm.stage("99999", "pages", "verify-fail-page", "<h1>V</h1><p>Content.</p>", validate=False)
        current_page = {"body": "<h1>Existing</h1>", "title": "Existing", "published": True}
        verify_resp = {"ok": False, "error": "Content drift detected."}
        with patch("canvas_api.get_page", return_value=current_page), \
             patch("canvas_api.update_page", return_value=True), \
             patch("post_write_verify.verify_page", return_value=verify_resp):
            result = self._ptc.push_page(self.config, "verify-fail-page", "99999")
        self.assertFalse(result["ok"])
        # Staging must be preserved on verify failure
        still_staged = self._sm.get_staged("99999", "pages", "verify-fail-page")
        self.assertIsNotNone(still_staged)

    def test_push_page_no_staged_returns_error(self):
        result = self._ptc.push_page(self.config, "nonexistent-slug", "99999")
        self.assertFalse(result["ok"])
        self.assertIn("staged", result.get("error", "").lower())


# ---------------------------------------------------------------------------
# push_syllabus tests
# ---------------------------------------------------------------------------

class TestPushSyllabus(unittest.TestCase):

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.tmp_root = pathlib.Path(self.tmp.name)
        import push_syllabus as ps
        self._ps = ps
        # Patch COURSES_DIR in push_syllabus
        self._orig_courses_dir = ps.COURSES_DIR
        ps.COURSES_DIR = self.tmp_root

    def tearDown(self):
        self._ps.COURSES_DIR = self._orig_courses_dir
        self.tmp.cleanup()

    def _write_staged_syllabus(self, course_id, html):
        staged_dir = self.tmp_root / str(course_id) / "staging" / "syllabus"
        staged_dir.mkdir(parents=True, exist_ok=True)
        (staged_dir / "syllabus_body.html").write_text(html, encoding="utf-8")

    def test_push_syllabus_success_flow(self):
        self._write_staged_syllabus("77001", "<h1>Syllabus</h1><p>Course info here.</p>")
        current_body = "<h1>Old Syllabus</h1>"
        verified_body = "<h1>Syllabus</h1><p>Course info here.</p>"

        mock_get_current = _mock_resp(200, {"syllabus_body": current_body})
        mock_put = _mock_resp(200, {})
        mock_get_verify = _mock_resp(200, {"syllabus_body": verified_body})

        config = _make_config(course_id="77001")
        with patch("canvas_api.get_config", return_value=config), \
             patch("requests.get", side_effect=[mock_get_current, mock_get_verify]), \
             patch("requests.put", return_value=mock_put):
            result = self._ps.push_syllabus("77001")
        self.assertTrue(result)
        # Staged file should be removed
        staged = self.tmp_root / "77001" / "staging" / "syllabus" / "syllabus_body.html"
        self.assertFalse(staged.exists())

    def test_push_syllabus_missing_staged_returns_false(self):
        config = _make_config(course_id="77002")
        with patch("canvas_api.get_config", return_value=config):
            result = self._ps.push_syllabus("77002")
        self.assertFalse(result)

    def test_push_syllabus_put_failure_returns_false(self):
        self._write_staged_syllabus("77003", "<h1>New</h1><p>Content.</p>")
        current_body = "<h1>Old</h1>"
        mock_get_current = _mock_resp(200, {"syllabus_body": current_body})
        mock_put_fail = _mock_resp(500, {}, "Internal Server Error")

        config = _make_config(course_id="77003")
        with patch("canvas_api.get_config", return_value=config), \
             patch("requests.get", return_value=mock_get_current), \
             patch("requests.put", return_value=mock_put_fail):
            result = self._ps.push_syllabus("77003")
        self.assertFalse(result)

    def test_push_syllabus_dry_run(self):
        self._write_staged_syllabus("77004", "<h1>Dry run</h1><p>Content.</p>")
        current_body = "<h1>Old</h1>"
        mock_get_current = _mock_resp(200, {"syllabus_body": current_body})

        config = _make_config(course_id="77004")
        with patch("canvas_api.get_config", return_value=config), \
             patch("requests.get", return_value=mock_get_current), \
             patch("requests.put") as mock_put:
            result = self._ps.push_syllabus("77004", dry_run=True)
        self.assertTrue(result)
        mock_put.assert_not_called()
        # Staged file should still exist after dry run
        staged = self.tmp_root / "77004" / "staging" / "syllabus" / "syllabus_body.html"
        self.assertTrue(staged.exists())


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