import json

from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.test import TestCase
from django.test import override_settings
from webauthn import base64url_to_bytes
from webauthn.helpers import bytes_to_base64url

from .models import AuthData

User = get_user_model()


class EncodingRoundTripTest(TestCase):
    """Verify that existing stored credential data survives the encoding
    round-trip with py_webauthn 2.x's base64url helpers. This matters because
    the old library stored credential_id and public_key as base64url strings,
    and we need them to decode/re-encode identically."""

    SAMPLE_CREDENTIAL_IDS = [
        "8rG3eeipR6WaqmJDTRqSRQ",
        "XVZgFTP-xv7_eAMcxsNok6CSDekHtmU-Pl2PbSo2dWddSz4nLt24YRFMtwG1loa6",
    ]

    SAMPLE_PUBLIC_KEY = (
        "pQECAyYgASFYIF1WYBUz_sb-_3gDHMaNRULfBJIjDLXtDSCT98D3nVrP"
        "IlggR38yOdC3fvPePqjTc92quNyuzRK82o8t4bu--IvdKeiha2NyZWRQ"
        "cm90ZWN0Ag"
    )

    def test_credential_id_round_trip(self) -> None:
        for credential_id in self.SAMPLE_CREDENTIAL_IDS:
            with self.subTest(credential_id=credential_id):
                raw_bytes = base64url_to_bytes(credential_id)
                re_encoded = bytes_to_base64url(raw_bytes)
                self.assertEqual(re_encoded, credential_id)

    def test_public_key_round_trip(self) -> None:
        raw_bytes = base64url_to_bytes(self.SAMPLE_PUBLIC_KEY)
        re_encoded = bytes_to_base64url(raw_bytes)
        self.assertEqual(re_encoded, self.SAMPLE_PUBLIC_KEY)

    def test_credential_id_decodes_to_nonempty_bytes(self) -> None:
        for credential_id in self.SAMPLE_CREDENTIAL_IDS:
            with self.subTest(credential_id=credential_id):
                raw_bytes = base64url_to_bytes(credential_id)
                self.assertIsInstance(raw_bytes, bytes)
                self.assertGreater(len(raw_bytes), 0)

    def test_public_key_decodes_to_nonempty_bytes(self) -> None:
        raw_bytes = base64url_to_bytes(self.SAMPLE_PUBLIC_KEY)
        self.assertIsInstance(raw_bytes, bytes)
        self.assertGreater(len(raw_bytes), 0)

    def test_public_key_starts_with_cbor_map(self) -> None:
        """COSE keys are CBOR maps. A CBOR map with 5 items starts with 0xa5."""
        raw_bytes = base64url_to_bytes(self.SAMPLE_PUBLIC_KEY)
        self.assertEqual(raw_bytes[0], 0xA5)


@override_settings(SITE_ID=1)
class RegisterBeginTest(TestCase):
    def setUp(self) -> None:
        Site.objects.update_or_create(
            id=1, defaults={"domain": "testserver", "name": "Test"}
        )
        self.user = User.objects.create_user(username="testuser", password="testpass")
        self.client.login(username="testuser", password="testpass")

    def test_returns_valid_options(self) -> None:
        response = self.client.post("/webauthin/register/begin/")
        self.assertEqual(response.status_code, 200)
        data = json.loads(response.content)
        self.assertIn("challenge", data)
        self.assertIn("rp", data)
        self.assertIn("user", data)
        self.assertEqual(data["rp"]["id"], "testserver")

    def test_stores_challenge_in_session(self) -> None:
        self.client.post("/webauthin/register/begin/")
        session = self.client.session
        self.assertIn("challenge", session)
        # Verify it's a valid base64url string by decoding it.
        raw = base64url_to_bytes(session["challenge"])
        self.assertGreater(len(raw), 0)

    def test_requires_login(self) -> None:
        self.client.logout()
        response = self.client.post("/webauthin/register/begin/")
        self.assertEqual(response.status_code, 302)


@override_settings(SITE_ID=1)
class RegisterVerifyTest(TestCase):
    def setUp(self) -> None:
        Site.objects.update_or_create(
            id=1, defaults={"domain": "testserver", "name": "Test"}
        )
        self.user = User.objects.create_user(username="testuser", password="testpass")
        self.client.login(username="testuser", password="testpass")

    def test_no_challenge_returns_error_redirect(self) -> None:
        response = self.client.post(
            "/webauthin/register/verify/",
            data=json.dumps({"id": "fake"}),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        data = json.loads(response.content)
        self.assertIn("redirect", data)

    def test_invalid_credential_returns_error_redirect(self) -> None:
        # Set a challenge in the session so we get past that check.
        session = self.client.session
        session["challenge"] = bytes_to_base64url(b"fake-challenge-bytes-1234567890ab")
        session.save()

        response = self.client.post(
            "/webauthin/register/verify/",
            data=json.dumps({"id": "fake", "rawId": "fake", "type": "public-key"}),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        data = json.loads(response.content)
        self.assertIn("redirect", data)

    def test_challenge_is_consumed(self) -> None:
        """The challenge should be removed from the session after verification
        to prevent replay attacks."""
        session = self.client.session
        session["challenge"] = bytes_to_base64url(b"fake-challenge-bytes-1234567890ab")
        session.save()

        self.client.post(
            "/webauthin/register/verify/",
            data=json.dumps({"id": "fake"}),
            content_type="application/json",
        )
        session = self.client.session
        self.assertNotIn("challenge", session)


@override_settings(SITE_ID=1)
class LoginBeginTest(TestCase):
    def setUp(self) -> None:
        Site.objects.update_or_create(
            id=1, defaults={"domain": "testserver", "name": "Test"}
        )

    def test_returns_valid_options(self) -> None:
        response = self.client.post("/webauthin/login/begin/")
        self.assertEqual(response.status_code, 200)
        data = json.loads(response.content)
        self.assertIn("challenge", data)
        self.assertIn("rpId", data)
        self.assertEqual(data["rpId"], "testserver")

    def test_stores_challenge_in_session(self) -> None:
        self.client.post("/webauthin/login/begin/")
        session = self.client.session
        self.assertIn("challenge", session)

    def test_does_not_require_login(self) -> None:
        response = self.client.post("/webauthin/login/begin/")
        self.assertEqual(response.status_code, 200)


@override_settings(SITE_ID=1)
class LoginVerifyTest(TestCase):
    def setUp(self) -> None:
        Site.objects.update_or_create(
            id=1, defaults={"domain": "testserver", "name": "Test"}
        )

    def test_no_challenge_returns_error_redirect(self) -> None:
        response = self.client.post(
            "/webauthin/login/verify/",
            data=json.dumps({"id": "fake"}),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        data = json.loads(response.content)
        self.assertIn("redirect", data)

    def test_malformed_json_returns_error_redirect(self) -> None:
        session = self.client.session
        session["challenge"] = bytes_to_base64url(b"fake-challenge-bytes-1234567890ab")
        session.save()

        response = self.client.post(
            "/webauthin/login/verify/",
            data="not json",
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        data = json.loads(response.content)
        self.assertIn("redirect", data)

    def test_missing_id_returns_error_redirect(self) -> None:
        session = self.client.session
        session["challenge"] = bytes_to_base64url(b"fake-challenge-bytes-1234567890ab")
        session.save()

        response = self.client.post(
            "/webauthin/login/verify/",
            data=json.dumps({"no_id_field": "value"}),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        data = json.loads(response.content)
        self.assertIn("redirect", data)

    def test_unknown_credential_returns_error_redirect(self) -> None:
        session = self.client.session
        session["challenge"] = bytes_to_base64url(b"fake-challenge-bytes-1234567890ab")
        session.save()

        response = self.client.post(
            "/webauthin/login/verify/",
            data=json.dumps({"id": "nonexistent-credential-id"}),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        data = json.loads(response.content)
        self.assertIn("redirect", data)

    def test_challenge_is_consumed(self) -> None:
        """The challenge should be removed from the session after verification."""
        session = self.client.session
        session["challenge"] = bytes_to_base64url(b"fake-challenge-bytes-1234567890ab")
        session.save()

        self.client.post(
            "/webauthin/login/verify/",
            data=json.dumps({"id": "fake"}),
            content_type="application/json",
        )
        session = self.client.session
        self.assertNotIn("challenge", session)


@override_settings(SITE_ID=1)
class AuthBackendTest(TestCase):
    """Test that the auth backend correctly handles credential lookup and
    returns None for missing/invalid credentials without raising."""

    def setUp(self) -> None:
        Site.objects.update_or_create(
            id=1, defaults={"domain": "testserver", "name": "Test"}
        )
        self.user = User.objects.create_user(username="testuser", password="testpass")

    def test_missing_credential_returns_none(self) -> None:
        """authenticate() should return None when the credential_id doesn't
        match any stored AuthData."""
        self.client.login(username="testuser", password="testpass")
        session = self.client.session
        session["challenge"] = bytes_to_base64url(b"fake-challenge-bytes-1234567890ab")
        session.save()

        response = self.client.post(
            "/webauthin/login/verify/",
            data=json.dumps(
                {
                    "id": "nonexistent",
                    "rawId": "nonexistent",
                    "response": {
                        "authenticatorData": "AAAA",
                        "clientDataJSON": "AAAA",
                        "signature": "AAAA",
                    },
                    "type": "public-key",
                }
            ),
            content_type="application/json",
        )
        data = json.loads(response.content)
        self.assertIn("redirect", data)


@override_settings(SITE_ID=1)
class KeyManagementTest(TestCase):
    def setUp(self) -> None:
        Site.objects.update_or_create(
            id=1, defaults={"domain": "testserver", "name": "Test"}
        )
        self.user = User.objects.create_user(username="testuser", password="testpass")
        self.client.login(username="testuser", password="testpass")
        self.key = AuthData.objects.create(
            user=self.user,
            credential_id="test-credential-id",
            public_key="test-public-key",
        )

    def test_rename_key(self) -> None:
        response = self.client.post(
            "/webauthin/key/rename/",
            data={"key_id": self.key.pk, "name": "My YubiKey"},
        )
        self.assertEqual(response.status_code, 302)
        self.key.refresh_from_db()
        self.assertEqual(self.key.name, "My YubiKey")

    def test_rename_other_users_key_fails(self) -> None:
        other_user = User.objects.create_user(
            username="otheruser", password="otherpass"
        )
        other_key = AuthData.objects.create(
            user=other_user,
            credential_id="other-credential-id",
            public_key="other-public-key",
        )
        self.client.post(
            "/webauthin/key/rename/",
            data={"key_id": other_key.pk, "name": "Hacked"},
        )
        other_key.refresh_from_db()
        self.assertNotEqual(other_key.name, "Hacked")

    def test_delete_key(self) -> None:
        response = self.client.post(
            "/webauthin/key/delete/",
            data={"key_id": self.key.pk},
        )
        self.assertEqual(response.status_code, 302)
        self.assertFalse(AuthData.objects.filter(pk=self.key.pk).exists())

    def test_delete_other_users_key_fails(self) -> None:
        other_user = User.objects.create_user(
            username="otheruser", password="otherpass"
        )
        other_key = AuthData.objects.create(
            user=other_user,
            credential_id="other-credential-id",
            public_key="other-public-key",
        )
        self.client.post(
            "/webauthin/key/delete/",
            data={"key_id": other_key.pk},
        )
        self.assertTrue(AuthData.objects.filter(pk=other_key.pk).exists())
