"""Regression tests for GitHub issue #209.

POST creates are non-idempotent: if a POST reaches Canvas and Canvas commits
the create, but the response is lost to a read-timeout or a 5xx, a blind
retry in `_request_with_retry` creates a DUPLICATE object (quiz/assignment/
page/module) in a live course. These tests pin the fix:

  - POST does NOT retry on ReadTimeout (request was sent; response lost —
    the create may have committed).
  - POST does NOT retry on 5xx (500/502/503/504) — same reasoning, the
    server received the request.
  - POST STILL retries on ConnectionError (e.g. ConnectTimeout / connection
    refused) — the connection was never established, so the request was
    provably never sent.
  - POST STILL retries on 429 — a pre-processing rate-limit rejection, so
    the create never reached Canvas.
  - Non-POST verbs (GET/PUT/DELETE/PATCH) are unaffected — control cases.
"""

import os
import sys
import unittest
from unittest.mock import MagicMock, 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 TestPostNotRetriedOnReadTimeout(unittest.TestCase):
    """POST + ReadTimeout must NOT be retried (request was sent)."""

    def test_post_read_timeout_invoked_once_and_raises(self):
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep") as mock_sleep:
            sess = MagicMock()
            sess.post.side_effect = canvas_api.requests.exceptions.ReadTimeout(
                "Read timed out"
            )
            mock_session_fn.return_value = sess

            with self.assertRaises(canvas_api.requests.RequestException) as cm:
                canvas_api._request_with_retry(
                    canvas_api.requests.post, URL, HEADERS, max_retries=3
                )

        self.assertEqual(
            sess.post.call_count, 1,
            "POST must be invoked exactly once on ReadTimeout — retrying "
            "risks creating a duplicate object if the original create "
            "already committed on Canvas's side"
        )
        mock_sleep.assert_not_called()

        # The diagnostic must be accurate: exactly one attempt happened, so it
        # must NOT claim "failed after 3 attempts" (which would wrongly imply
        # the blind-retry this guard prevents). It must state the POST was not
        # retried and flag the possibly-committed create — this is the message
        # an operator sees while debugging a lost, non-idempotent POST.
        msg = str(cm.exception)
        self.assertNotIn(
            "after 3 attempts", msg,
            f"short-circuit message must not imply a 3-attempt retry: {msg!r}"
        )
        self.assertIn(
            "not retried", msg,
            f"message must state the POST was not retried: {msg!r}"
        )
        self.assertIn(
            "may have reached Canvas", msg,
            f"message must flag the possibly-committed create: {msg!r}"
        )


class TestPostNotRetriedOn5xx(unittest.TestCase):
    """POST + 502 must NOT be retried (server may have committed the create)."""

    def test_post_502_invoked_once_and_returns_response(self):
        resp_502 = _mock_resp(502)
        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_502
            mock_session_fn.return_value = sess

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

        self.assertIs(result, resp_502)
        self.assertEqual(
            sess.post.call_count, 1,
            "POST must be invoked exactly once on 5xx — the caller should "
            "verify state instead of blind-resending a possibly-committed create"
        )
        mock_sleep.assert_not_called()


class TestPostStillRetriesOnConnectionError(unittest.TestCase):
    """POST + ConnectionError (connect-phase failure) must still retry —
    the connection never established, so the request was provably never sent."""

    def test_post_connection_error_then_success_retries(self):
        resp_200 = _mock_resp(200)
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep"):
            sess = MagicMock()
            sess.post.side_effect = [
                canvas_api.requests.exceptions.ConnectionError(
                    "Connection refused"
                ),
                resp_200,
            ]
            mock_session_fn.return_value = sess

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

        self.assertIs(result, resp_200)
        self.assertEqual(
            sess.post.call_count, 2,
            "POST must still retry a ConnectionError (connect-phase failure "
            "— request never sent), unlike ReadTimeout"
        )

    def test_post_connect_timeout_then_success_retries(self):
        """ConnectTimeout is a ConnectionError subclass — same connect-phase
        guarantee applies."""
        resp_200 = _mock_resp(200)
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep"):
            sess = MagicMock()
            sess.post.side_effect = [
                canvas_api.requests.exceptions.ConnectTimeout(
                    "Connection timed out"
                ),
                resp_200,
            ]
            mock_session_fn.return_value = sess

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

        self.assertIs(result, resp_200)
        self.assertEqual(sess.post.call_count, 2)


class TestPostStillRetriesOn429(unittest.TestCase):
    """POST + 429 must still retry — a pre-processing rate-limit rejection
    means the create never reached Canvas."""

    def test_post_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.post.side_effect = [resp_429, resp_200]
            mock_session_fn.return_value = sess

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

        self.assertIs(result, resp_200)
        self.assertEqual(
            sess.post.call_count, 2,
            "POST must still retry on 429 — the create was rejected before "
            "processing, so no duplicate risk exists"
        )


class TestNonPostVerbsRetryUnchanged(unittest.TestCase):
    """Control cases: non-POST verbs keep retrying on ReadTimeout/5xx exactly
    as before — only POST's retry behavior changes."""

    def test_put_read_timeout_still_retried(self):
        resp_200 = _mock_resp(200)
        with patch.object(canvas_api, "_session") as mock_session_fn, \
                patch("canvas_api.time.sleep"):
            sess = MagicMock()
            sess.put.side_effect = [
                canvas_api.requests.exceptions.ReadTimeout("Read timed out"),
                resp_200,
            ]
            mock_session_fn.return_value = sess

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

        self.assertIs(result, resp_200)
        self.assertEqual(
            sess.put.call_count, 2,
            "PUT is idempotent — ReadTimeout retry behavior must stay unchanged"
        )

    def test_get_500_still_retried(self):
        resp_500 = _mock_resp(500)
        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_500, 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,
            "GET is safe to repeat — 5xx retry behavior must stay unchanged"
        )


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