from datetime import datetime, timedelta, timezone
from http import HTTPStatus
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import MagicMock, patch

from django_workflows.users.services import auth_flow


class AuthFlowTests(TestCase):
    @staticmethod
    def _make_user_model() -> SimpleNamespace:
        class DoesNotExist(Exception):
            pass

        return SimpleNamespace(DoesNotExist=DoesNotExist, objects=MagicMock())

    @staticmethod
    def _make_meta_model(meta: SimpleNamespace) -> SimpleNamespace:
        manager = MagicMock()
        manager.get_or_create.return_value = (meta, True)
        return SimpleNamespace(objects=manager)

    @staticmethod
    def _settings_getter(**overrides):
        defaults = {
            "COMPANY_NAME": "Acme Co",
            "ENABLE_EMAIL_DELIVERY": False,
            "RESET_CODE_TTL_MINUTES": 10,
            "MAX_VERIFY_ATTEMPTS": 3,
        }
        defaults.update(overrides)

        def _get(name, default=None):
            return defaults.get(name, default)

        return _get

    @patch.object(auth_flow, "get_user_model")
    def test_register_user_rejects_existing_email(self, mock_get_user_model):
        user_model = self._make_user_model()
        user_model.objects.filter.return_value.exists.return_value = True
        mock_get_user_model.return_value = user_model

        result = auth_flow.register_user_and_send_verification_email(
            email="existing@example.com",
            first_name="First",
            last_name="Last",
            password="pw",
        )

        self.assertFalse(result.success)
        self.assertEqual(result.message, "A user with this email already exists.")
        user_model.objects.create_user.assert_not_called()

    @patch.object(auth_flow, "get_user_model")
    def test_register_user_integrity_error_logs_exception(self, mock_get_user_model):
        user_model = self._make_user_model()
        user_model.objects.filter.return_value.exists.return_value = False
        user_model.objects.create_user.side_effect = auth_flow.IntegrityError("duplicate")
        mock_get_user_model.return_value = user_model

        with patch.object(auth_flow.logger, "exception") as mock_exception:
            result = auth_flow.register_user_and_send_verification_email(
                email="existing@example.com",
                first_name="First",
                last_name="Last",
                password="pw",
            )

        self.assertFalse(result.success)
        self.assertEqual(result.message, "Email already exists, login or reset password.")
        mock_exception.assert_called_once()

    @patch.object(auth_flow, "_register_on_commit", return_value=True)
    @patch.object(auth_flow, "timezone")
    @patch.object(auth_flow, "send_email")
    @patch.object(auth_flow, "generate_verification_code", return_value="ABC123")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_register_user_schedules_verification_email_on_commit(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_user_meta_model,
        _mock_generate_code,
        mock_send_email,
        mock_timezone,
        mock_register_on_commit,
    ):
        now = datetime(2026, 3, 14, 12, 0, tzinfo=timezone.utc)
        mock_timezone.now.return_value = now
        mock_send_email.return_value = SimpleNamespace(status_code=200, text="ok")
        mock_get_setting.side_effect = self._settings_getter(
            ENABLE_EMAIL_DELIVERY=True,
            COMPANY_NAME="Reusable Inc",
            RESET_CODE_TTL_MINUTES=15,
        )

        user_model = self._make_user_model()
        user_model.objects.filter.return_value.exists.return_value = False
        user = SimpleNamespace(email="new@example.com", first_name="New", last_name="User", delete=MagicMock())
        user_model.objects.create_user.return_value = user
        mock_get_user_model.return_value = user_model

        meta = SimpleNamespace(verify_code=None, verify_time=None, attempts=0, save=MagicMock())
        mock_get_user_meta_model.return_value = self._make_meta_model(meta)

        result = auth_flow.register_user_and_send_verification_email(
            email="new@example.com",
            first_name="New",
            last_name="User",
            password="pw",
        )

        self.assertTrue(result.success)
        mock_register_on_commit.assert_called_once()
        callback = mock_register_on_commit.call_args.args[0]
        self.assertTrue(callable(callback))
        mock_send_email.assert_not_called()
        callback()
        mock_send_email.assert_called_once()
        user.delete.assert_not_called()

    @patch.object(auth_flow, "timezone")
    @patch.object(auth_flow, "send_email")
    @patch.object(auth_flow, "generate_verification_code", return_value="ABC123")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_register_user_creates_meta_and_sends_email(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_user_meta_model,
        _mock_generate_code,
        mock_send_email,
        mock_timezone,
    ):
        now = datetime(2026, 3, 14, 12, 0, tzinfo=timezone.utc)
        mock_timezone.now.return_value = now
        mock_send_email.return_value = SimpleNamespace(status_code=200, text="ok")
        mock_get_setting.side_effect = self._settings_getter(
            ENABLE_EMAIL_DELIVERY=True,
            COMPANY_NAME="Reusable Inc",
            RESET_CODE_TTL_MINUTES=15,
        )

        user_model = self._make_user_model()
        user_model.objects.filter.return_value.exists.return_value = False
        user = SimpleNamespace(email="new@example.com", first_name="New", last_name="User", delete=MagicMock())
        user_model.objects.create_user.return_value = user
        mock_get_user_model.return_value = user_model

        meta = SimpleNamespace(verify_code=None, verify_time=None, attempts=0, save=MagicMock())
        mock_get_user_meta_model.return_value = self._make_meta_model(meta)

        result = auth_flow.register_user_and_send_verification_email(
            email="new@example.com",
            first_name="New",
            last_name="User",
            password="pw",
        )

        self.assertTrue(result.success)
        user_model.objects.create_user.assert_called_once()
        create_user_kwargs = user_model.objects.create_user.call_args.kwargs
        self.assertEqual(create_user_kwargs["email"], "new@example.com")
        self.assertEqual(create_user_kwargs["username"], "new@example.com")
        self.assertEqual(meta.verify_code, "ABC123")
        self.assertEqual(meta.verify_time, now)
        meta.save.assert_called_once()

        kwargs = mock_send_email.call_args.kwargs
        self.assertEqual(kwargs["to"], ["new@example.com"])
        self.assertEqual(kwargs["subject"], "Verify your account")
        self.assertTrue(kwargs["enabled"])
        self.assertIn("ABC123", kwargs["html"])
        self.assertIn("15 minutes", kwargs["html"])
        self.assertIn("Reusable Inc", kwargs["html"])
        user.delete.assert_not_called()

    @patch.object(auth_flow, "timezone")
    @patch.object(auth_flow, "send_email", return_value=None)
    @patch.object(auth_flow, "generate_verification_code", return_value="ABC123")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_register_user_returns_failure_when_verification_email_not_delivered(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_user_meta_model,
        _mock_generate_code,
        _mock_send_email,
        mock_timezone,
    ):
        now = datetime(2026, 3, 14, 12, 0, tzinfo=timezone.utc)
        mock_timezone.now.return_value = now
        mock_get_setting.side_effect = self._settings_getter(
            ENABLE_EMAIL_DELIVERY=True,
            COMPANY_NAME="Reusable Inc",
            RESET_CODE_TTL_MINUTES=15,
        )

        user_model = self._make_user_model()
        user_model.objects.filter.return_value.exists.return_value = False
        user = SimpleNamespace(email="new@example.com", first_name="New", last_name="User", delete=MagicMock())
        user_model.objects.create_user.return_value = user
        mock_get_user_model.return_value = user_model

        meta = SimpleNamespace(verify_code=None, verify_time=None, attempts=0, save=MagicMock())
        mock_get_user_meta_model.return_value = self._make_meta_model(meta)

        with patch.object(auth_flow.logger, "warning") as mock_warning:
            result = auth_flow.register_user_and_send_verification_email(
                email="new@example.com",
                first_name="New",
                last_name="User",
                password="pw",
            )

        self.assertFalse(result.success)
        self.assertIn("could not send a verification email", result.message.lower())
        mock_warning.assert_called_once()
        user.delete.assert_called_once()

    @patch.object(auth_flow, "get_user_model")
    def test_send_forgot_password_code_is_silent_for_missing_user(self, mock_get_user_model):
        user_model = self._make_user_model()
        user_model.objects.get.side_effect = user_model.DoesNotExist
        mock_get_user_model.return_value = user_model

        result = auth_flow.send_forgot_password_code("missing@example.com")

        self.assertTrue(result.success)
        self.assertEqual(result.message, "If the email exists, a reset code has been sent.")

    @patch.object(auth_flow, "timezone")
    @patch.object(auth_flow, "send_email", return_value=None)
    @patch.object(auth_flow, "generate_verification_code", return_value="ABC123")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_forgot_password_logs_warning_when_email_not_delivered(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_user_meta_model,
        _mock_generate_code,
        _mock_send_email,
        mock_timezone,
    ):
        now = datetime(2026, 3, 14, 12, 0, tzinfo=timezone.utc)
        mock_timezone.now.return_value = now
        mock_get_setting.side_effect = self._settings_getter(
            ENABLE_EMAIL_DELIVERY=True,
            COMPANY_NAME="Reusable Inc",
            RESET_CODE_TTL_MINUTES=15,
        )

        user_model = self._make_user_model()
        user = SimpleNamespace(email="new@example.com", first_name="New", last_name="User")
        user_model.objects.get.return_value = user
        mock_get_user_model.return_value = user_model

        meta = SimpleNamespace(verify_code=None, verify_time=None, attempts=0, save=MagicMock())
        mock_get_user_meta_model.return_value = self._make_meta_model(meta)

        with patch.object(auth_flow.logger, "warning") as mock_warning:
            result = auth_flow.send_forgot_password_code("new@example.com")

        self.assertTrue(result.success)
        self.assertEqual(result.message, "If the email exists, a reset code has been sent.")
        mock_warning.assert_called_once()

    @patch.object(auth_flow, "timezone")
    @patch.object(auth_flow, "_log_code_for_debug")
    @patch.object(auth_flow, "generate_verification_code", return_value="ABC123")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_register_user_disabled_email_delivery_does_not_log_raw_code(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_user_meta_model,
        _mock_generate_code,
        mock_log_code_for_debug,
        mock_timezone,
    ):
        now = datetime(2026, 3, 14, 12, 0, tzinfo=timezone.utc)
        mock_timezone.now.return_value = now
        mock_get_setting.side_effect = self._settings_getter(
            ENABLE_EMAIL_DELIVERY=False,
            COMPANY_NAME="Reusable Inc",
            RESET_CODE_TTL_MINUTES=15,
        )

        user_model = self._make_user_model()
        user_model.objects.filter.return_value.exists.return_value = False
        user = SimpleNamespace(email="new@example.com", first_name="New", last_name="User")
        user_model.objects.create_user.return_value = user
        mock_get_user_model.return_value = user_model

        meta = SimpleNamespace(verify_code=None, verify_time=None, attempts=0, save=MagicMock())
        mock_get_user_meta_model.return_value = self._make_meta_model(meta)

        with patch.object(auth_flow.logger, "info") as mock_info:
            result = auth_flow.register_user_and_send_verification_email(
                email="new@example.com",
                first_name="New",
                last_name="User",
                password="pw",
            )

        self.assertTrue(result.success)
        logged_text = "\n".join(" ".join(map(str, call.args)) for call in mock_info.call_args_list)
        self.assertNotIn("ABC123", logged_text)
        mock_log_code_for_debug.assert_called_once_with(label="Verification", code="ABC123")

    @patch.object(auth_flow, "timezone")
    @patch.object(auth_flow, "_log_code_for_debug")
    @patch.object(auth_flow, "generate_verification_code", return_value="ABC123")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_forgot_password_disabled_email_delivery_does_not_log_raw_code(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_user_meta_model,
        _mock_generate_code,
        mock_log_code_for_debug,
        mock_timezone,
    ):
        now = datetime(2026, 3, 14, 12, 0, tzinfo=timezone.utc)
        mock_timezone.now.return_value = now
        mock_get_setting.side_effect = self._settings_getter(
            ENABLE_EMAIL_DELIVERY=False,
            COMPANY_NAME="Reusable Inc",
            RESET_CODE_TTL_MINUTES=15,
        )

        user_model = self._make_user_model()
        user = SimpleNamespace(email="new@example.com", first_name="New", last_name="User")
        user_model.objects.get.return_value = user
        mock_get_user_model.return_value = user_model

        meta = SimpleNamespace(verify_code=None, verify_time=None, attempts=0, save=MagicMock())
        mock_get_user_meta_model.return_value = self._make_meta_model(meta)

        with patch.object(auth_flow.logger, "info") as mock_info:
            result = auth_flow.send_forgot_password_code("new@example.com")

        self.assertTrue(result.success)
        logged_text = "\n".join(" ".join(map(str, call.args)) for call in mock_info.call_args_list)
        self.assertNotIn("ABC123", logged_text)
        mock_log_code_for_debug.assert_called_once_with(label="Password reset", code="ABC123")

    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "get_user_model")
    def test_change_password_updates_user_and_meta(self, mock_get_user_model, mock_get_user_meta_model):
        user_model = self._make_user_model()
        user = SimpleNamespace(set_password=MagicMock(), save=MagicMock())
        user_model.objects.get.return_value = user
        mock_get_user_model.return_value = user_model

        meta = SimpleNamespace(force_password_reset=True, save=MagicMock())
        mock_get_user_meta_model.return_value = self._make_meta_model(meta)

        result = auth_flow.change_password(
            email="person@example.com",
            password="new-secret",
            password_verify="new-secret",
        )

        self.assertTrue(result.success)
        user.set_password.assert_called_once_with("new-secret")
        user.save.assert_called_once()
        self.assertFalse(meta.force_password_reset)
        meta.save.assert_called_once()

    @patch.object(auth_flow, "timezone")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_validate_reset_code_wrong_code_increments_attempts(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_user_meta_model,
        mock_timezone,
    ):
        now = datetime(2026, 3, 14, 12, 0, tzinfo=timezone.utc)
        mock_timezone.now.return_value = now
        mock_get_setting.side_effect = self._settings_getter(MAX_VERIFY_ATTEMPTS=3, RESET_CODE_TTL_MINUTES=10)

        user_model = self._make_user_model()
        user_model.objects.get.return_value = SimpleNamespace(is_active=False, save=MagicMock())
        mock_get_user_model.return_value = user_model

        meta = SimpleNamespace(
            verify_code="ABC123",
            verify_time=now - timedelta(minutes=1),
            attempts=0,
            save=MagicMock(),
        )
        mock_get_user_meta_model.return_value = self._make_meta_model(meta)

        result = auth_flow.validate_reset_code(email="person@example.com", code="WRONG")

        self.assertEqual(result.result, auth_flow.VerifyCodeResult.INVALID)
        self.assertEqual(result.attempts_remaining, 2)
        self.assertEqual(meta.attempts, 1)
        meta.save.assert_called_once()

    @patch.object(auth_flow, "timezone")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_validate_reset_code_success_activates_user(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_user_meta_model,
        mock_timezone,
    ):
        now = datetime(2026, 3, 14, 12, 0, tzinfo=timezone.utc)
        mock_timezone.now.return_value = now
        mock_get_setting.side_effect = self._settings_getter(MAX_VERIFY_ATTEMPTS=3, RESET_CODE_TTL_MINUTES=10)

        user = SimpleNamespace(is_active=False, save=MagicMock())
        user_model = self._make_user_model()
        user_model.objects.get.return_value = user
        mock_get_user_model.return_value = user_model

        meta = SimpleNamespace(
            verify_code="ABC123",
            verify_time=now - timedelta(minutes=1),
            attempts=0,
            save=MagicMock(),
        )
        mock_get_user_meta_model.return_value = self._make_meta_model(meta)

        result = auth_flow.validate_reset_code(email="person@example.com", code="ABC123")

        self.assertEqual(result.result, auth_flow.VerifyCodeResult.SUCCESS)
        self.assertTrue(user.is_active)
        user.save.assert_called_once()
        self.assertIsNone(meta.verify_code)
        self.assertIsNone(meta.verify_time)
        self.assertEqual(meta.attempts, 0)

    @patch.object(auth_flow, "timezone")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_validate_reset_code_hits_too_many_attempts(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_user_meta_model,
        mock_timezone,
    ):
        now = datetime(2026, 3, 14, 12, 0, tzinfo=timezone.utc)
        mock_timezone.now.return_value = now
        mock_get_setting.side_effect = self._settings_getter(MAX_VERIFY_ATTEMPTS=3, RESET_CODE_TTL_MINUTES=10)

        user_model = self._make_user_model()
        user_model.objects.get.return_value = SimpleNamespace(is_active=False, save=MagicMock())
        mock_get_user_model.return_value = user_model

        meta = SimpleNamespace(
            verify_code="ABC123",
            verify_time=now - timedelta(minutes=1),
            attempts=3,
            save=MagicMock(),
        )
        mock_get_user_meta_model.return_value = self._make_meta_model(meta)

        result = auth_flow.validate_reset_code(email="person@example.com", code="ABC123")

        self.assertEqual(result.result, auth_flow.VerifyCodeResult.TOO_MANY_ATTEMPTS)
        self.assertIsNone(meta.verify_code)
        self.assertIsNone(meta.verify_time)
        self.assertEqual(meta.attempts, 0)

    @patch.object(auth_flow, "validate_reset_code")
    def test_verify_reset_code_maps_success_result(self, mock_validate_reset_code):
        mock_validate_reset_code.return_value = auth_flow.VerifyCodeOutcome(
            result=auth_flow.VerifyCodeResult.SUCCESS,
        )

        response = auth_flow.verify_reset_code(email="person@example.com", code="ab12")

        self.assertEqual(response.status_code, int(HTTPStatus.OK))
        self.assertEqual(response.payload, {"detail": "Code verified."})
        mock_validate_reset_code.assert_called_once_with(email="person@example.com", code="AB12")

    @patch.object(auth_flow, "validate_reset_code")
    def test_verify_reset_code_maps_invalid_result(self, mock_validate_reset_code):
        mock_validate_reset_code.return_value = auth_flow.VerifyCodeOutcome(
            result=auth_flow.VerifyCodeResult.INVALID,
            attempts_remaining=1,
        )

        response = auth_flow.verify_reset_code(email="person@example.com", code="ab12")

        self.assertEqual(response.status_code, int(HTTPStatus.ACCEPTED))
        self.assertEqual(response.payload["attempts_remaining"], 1)
        mock_validate_reset_code.assert_called_once_with(email="person@example.com", code="AB12")

    @patch.object(auth_flow, "validate_reset_code")
    def test_verify_reset_code_maps_expired_result(self, mock_validate_reset_code):
        mock_validate_reset_code.return_value = auth_flow.VerifyCodeOutcome(
            result=auth_flow.VerifyCodeResult.EXPIRED,
        )

        response = auth_flow.verify_reset_code(email="person@example.com", code="ab12")

        self.assertEqual(response.status_code, int(HTTPStatus.BAD_REQUEST))
        self.assertEqual(response.payload, {"detail": "Code expired. Please request a new code."})
        mock_validate_reset_code.assert_called_once_with(email="person@example.com", code="AB12")

    @patch.object(auth_flow, "validate_reset_code")
    def test_verify_reset_code_maps_too_many_attempts_result(self, mock_validate_reset_code):
        mock_validate_reset_code.return_value = auth_flow.VerifyCodeOutcome(
            result=auth_flow.VerifyCodeResult.TOO_MANY_ATTEMPTS,
        )

        response = auth_flow.verify_reset_code(email="person@example.com", code="ab12")

        self.assertEqual(response.status_code, int(HTTPStatus.BAD_REQUEST))
        self.assertEqual(response.payload, {"detail": "Too many attempts. Please request a new code."})
        mock_validate_reset_code.assert_called_once_with(email="person@example.com", code="AB12")
