"""Tests for PR4 canvas_api efficiency changes.

Covers:
  (a) Successful GET does NOT sleep with INTER_REQUEST_DELAY.
  (b) Successful POST/PUT DO incur INTER_REQUEST_DELAY.
  (c) X-Rate-Limit-Remaining < 10 still sleeps 1.0 (proactive throttle unchanged).
  (d) 429-then-200 sequence retries correctly (backoff path intact).
  (e) Session singleton is reused across calls (same object).
  (f) MagicMock method (compat shim) still routes through the original callable.
"""

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

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

import canvas_api

URL = "https://canvas.test.edu/api/v1/courses/99999"
HEADERS = {"Authorization": "Bearer fake"}


def _mock_resp(status=200, headers=None):
    """Build a minimal mock response."""
    resp = MagicMock()
    resp.status_code = status
    resp.headers = headers or {}
    resp.json.return_value = {}
    return resp


class TestGetNoSleep(unittest.TestCase):
    """GET success must NOT call time.sleep with INTER_REQUEST_DELAY."""

    def test_get_success_no_inter_request_sleep(self):
        resp = _mock_resp(200)
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep") as mock_sleep:
            sess = MagicMock()
            sess.get.return_value = resp
            mock_session_fn.return_value = sess

            result = canvas_api._request_with_retry(
                canvas_api.requests.get, URL, HEADERS
            )

        self.assertIs(result, resp)
        # INTER_REQUEST_DELAY is 0.25 — must not appear in any sleep call
        for c in mock_sleep.call_args_list:
            self.assertNotEqual(
                c.args[0], canvas_api.INTER_REQUEST_DELAY,
                f"time.sleep({canvas_api.INTER_REQUEST_DELAY}) must not fire on GET; "
                f"got sleep calls: {mock_sleep.call_args_list}"
            )


class TestWriteVerbsDoSleep(unittest.TestCase):
    """POST and PUT successes MUST call time.sleep(INTER_REQUEST_DELAY)."""

    def _check_verb(self, verb):
        resp = _mock_resp(201)
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep") as mock_sleep:
            sess = MagicMock()
            getattr(sess, verb).return_value = resp
            mock_session_fn.return_value = sess

            method = getattr(canvas_api.requests, verb)
            canvas_api._request_with_retry(method, URL, HEADERS)

        sleep_values = [c.args[0] for c in mock_sleep.call_args_list]
        self.assertIn(
            canvas_api.INTER_REQUEST_DELAY,
            sleep_values,
            f"Expected time.sleep({canvas_api.INTER_REQUEST_DELAY}) on {verb.upper()}; "
            f"got: {sleep_values}"
        )

    def test_post_incurs_write_delay(self):
        self._check_verb("post")

    def test_put_incurs_write_delay(self):
        self._check_verb("put")

    def test_delete_incurs_write_delay(self):
        resp = _mock_resp(200)
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep") as mock_sleep:
            sess = MagicMock()
            sess.delete.return_value = resp
            mock_session_fn.return_value = sess

            canvas_api._request_with_retry(canvas_api.requests.delete, URL, HEADERS)

        sleep_values = [c.args[0] for c in mock_sleep.call_args_list]
        self.assertIn(canvas_api.INTER_REQUEST_DELAY, sleep_values)

    def test_patch_incurs_write_delay(self):
        resp = _mock_resp(200)
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep") as mock_sleep:
            sess = MagicMock()
            sess.patch.return_value = resp
            mock_session_fn.return_value = sess

            canvas_api._request_with_retry(canvas_api.requests.patch, URL, HEADERS)

        sleep_values = [c.args[0] for c in mock_sleep.call_args_list]
        self.assertIn(canvas_api.INTER_REQUEST_DELAY, sleep_values)


class TestProactiveThrottleUnchanged(unittest.TestCase):
    """X-Rate-Limit-Remaining < 10 must still sleep 1.0 on every verb."""

    def test_low_remaining_sleeps_on_get(self):
        resp = _mock_resp(200, headers={"X-Rate-Limit-Remaining": "5"})
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep") as mock_sleep:
            sess = MagicMock()
            sess.get.return_value = resp
            mock_session_fn.return_value = sess

            canvas_api._request_with_retry(canvas_api.requests.get, URL, HEADERS)

        # 1.0 sleep (proactive throttle) must fire even though it's a GET
        sleep_values = [c.args[0] for c in mock_sleep.call_args_list]
        self.assertIn(1.0, sleep_values,
                      f"Expected 1.0-s proactive sleep; got: {sleep_values}")
        # INTER_REQUEST_DELAY must NOT appear (still a GET)
        self.assertNotIn(canvas_api.INTER_REQUEST_DELAY, sleep_values)

    def test_low_remaining_sleeps_on_post(self):
        resp = _mock_resp(201, headers={"X-Rate-Limit-Remaining": "3"})
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep") as mock_sleep:
            sess = MagicMock()
            sess.post.return_value = resp
            mock_session_fn.return_value = sess

            canvas_api._request_with_retry(canvas_api.requests.post, URL, HEADERS)

        sleep_values = [c.args[0] for c in mock_sleep.call_args_list]
        # Both the proactive 1.0 AND the write-delay 0.25 must fire
        self.assertIn(1.0, sleep_values)
        self.assertIn(canvas_api.INTER_REQUEST_DELAY, sleep_values)


class TestRetryOn429(unittest.TestCase):
    """429 then 200 must retry and succeed (backoff path intact)."""

    def test_429_then_200_retries(self):
        resp_429 = _mock_resp(429, headers={"Retry-After": "0.01"})
        resp_200 = _mock_resp(200)
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep"):
            sess = MagicMock()
            sess.get.side_effect = [resp_429, resp_200]
            mock_session_fn.return_value = sess

            result = canvas_api._request_with_retry(
                canvas_api.requests.get, URL, HEADERS, max_retries=3
            )

        self.assertIs(result, resp_200)
        self.assertEqual(sess.get.call_count, 2,
                         "Expected exactly 2 GET calls: 429 attempt + retry")

    def test_persistent_429_raises_after_retries(self):
        resp_429 = _mock_resp(429)
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep"):
            sess = MagicMock()
            sess.get.return_value = resp_429
            mock_session_fn.return_value = sess

            result = canvas_api._request_with_retry(
                canvas_api.requests.get, URL, HEADERS, max_retries=2
            )

        # After exhausting retries, the last response is returned (not raised)
        self.assertIs(result, resp_429)
        self.assertEqual(sess.get.call_count, 2)


class TestSessionReuse(unittest.TestCase):
    """The same Session object must be returned on every call to _session()."""

    def setUp(self):
        # Reset the module-level singleton so tests start clean
        canvas_api._SESSION = None

    def tearDown(self):
        canvas_api._SESSION = None

    def test_same_session_returned_twice(self):
        s1 = canvas_api._session()
        s2 = canvas_api._session()
        self.assertIs(s1, s2, "_session() must return the same object on every call")

    def test_session_is_requests_session(self):
        import requests as req_lib
        s = canvas_api._session()
        self.assertIsInstance(s, req_lib.Session)

    def test_session_reused_across_request_calls(self):
        """Two _request_with_retry calls touch the same Session."""
        resp = _mock_resp(200)
        captured_sessions = []

        original_session_fn = canvas_api._session

        def tracking_session():
            s = original_session_fn()
            captured_sessions.append(s)
            return s

        with patch.object(canvas_api, "_session", side_effect=tracking_session), \
                patch("canvas_api.time.sleep"):
            # We also need the session's .get to return resp; patch the
            # singleton after creation via side_effect above.
            # Instead, just let it create a real session and mock .get on it.
            real_session = canvas_api._session()
            real_session.get = MagicMock(return_value=resp)

        # Reset captured_sessions from setup call above
        captured_sessions.clear()

        with patch.object(canvas_api, "_session", side_effect=tracking_session), \
                patch("canvas_api.time.sleep"):
            s = canvas_api._session()
            s.get = MagicMock(return_value=resp)
            canvas_api._request_with_retry(canvas_api.requests.get, URL, HEADERS)
            canvas_api._request_with_retry(canvas_api.requests.get, URL, HEADERS)

        # Both calls should have received the same session object
        self.assertGreaterEqual(len(captured_sessions), 2)
        self.assertIs(captured_sessions[0], captured_sessions[1],
                      "Session must be the same object across both calls")


class TestMockMethodCompatShim(unittest.TestCase):
    """When method is a MagicMock (no real __name__), dispatch falls back to
    calling method() directly — existing tests remain green."""

    @patch("canvas_api.time.sleep", return_value=None)
    def test_magicmock_method_called_directly(self, mock_sleep):
        """A MagicMock passed as method should be called directly (not via session)."""
        response = MagicMock()
        response.status_code = 403
        response.headers = {}

        method = MagicMock(return_value=response)
        # MagicMock.__name__ is a MagicMock object, not in _HTTP_VERBS → fallback

        result = canvas_api._request_with_retry(
            method, URL, HEADERS
        )

        self.assertIs(result, response)
        self.assertEqual(method.call_count, 1,
                         "MagicMock method must be called directly (fallback path)")
        # 403 is not retryable — no sleep
        mock_sleep.assert_not_called()

    @patch("canvas_api.time.sleep", return_value=None)
    def test_magicmock_method_named_get_uses_session(self, mock_sleep):
        """If method.__name__ == 'get', dispatch goes through the session."""
        response = _mock_resp(200)

        method = MagicMock(return_value=response)
        method.__name__ = "get"  # Explicitly set name to a known verb

        with patch.object(canvas_api, "_session") as mock_session_fn:
            sess = MagicMock()
            sess.get.return_value = response
            mock_session_fn.return_value = sess

            result = canvas_api._request_with_retry(method, URL, HEADERS)

        self.assertIs(result, response)
        sess.get.assert_called_once()
        # method itself should NOT have been called (session dispatched instead)
        method.assert_not_called()


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