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.conf import settings

if not settings.configured:
    settings.configure(
        SECRET_KEY="test-key",
        ROOT_URLCONF="tests.test_user_views",
        INSTALLED_APPS=[
            "django.contrib.auth",
            "django.contrib.contenttypes",
            "rest_framework",
        ],
        DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
        MIDDLEWARE=[],
        USE_TZ=True,
        DEFAULT_AUTO_FIELD="django.db.models.AutoField",
    )

import django

django.setup()

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

    def test_redact_code_handles_short_and_long_values(self):
        self.assertEqual(auth_flow._redact_code(""), "")
        self.assertEqual(auth_flow._redact_code("AB"), "**")
        self.assertEqual(auth_flow._redact_code("ABCD"), "A**D")
        self.assertEqual(auth_flow._redact_code("ABCDEF"), "AB**EF")

    def test_generate_verification_code_uses_allowed_characters(self):
        code = auth_flow.generate_verification_code(length=8)

        self.assertEqual(len(code), 8)
        self.assertTrue(set(code).issubset(set(auth_flow._ALLOWED_CODE_CHARS)))

    def test_generate_strong_password_returns_non_empty_string(self):
        password = auth_flow.generate_strong_password(length=16)

        self.assertIsInstance(password, str)
        self.assertTrue(password)

    @patch.object(auth_flow.logger, "isEnabledFor", return_value=True)
    @patch.object(auth_flow.logger, "debug")
    @patch.object(auth_flow, "_get_workflow_setting", return_value=True)
    def test_log_code_for_debug_logs_masked_code_when_enabled(
        self,
        _mock_get_workflow_setting,
        mock_debug,
        _mock_is_enabled_for,
    ):
        auth_flow._log_code_for_debug(label="Verification", code="ABC123")

        mock_debug.assert_called_once_with("%s code (masked): %s", "Verification", "AB**23")

    def test_workflows_settings_returns_empty_when_settings_not_configured(self):
        with patch.object(auth_flow, "settings", SimpleNamespace(configured=False)):
            self.assertEqual(auth_flow._workflows_settings(), {})

    def test_workflows_settings_warns_for_non_dict(self):
        fake_settings = SimpleNamespace(configured=True, WORKFLOWS="bad-config")

        with patch.object(auth_flow, "settings", fake_settings), patch.object(
            auth_flow.logger, "warning"
        ) as mock_warning:
            result = auth_flow._workflows_settings()

        self.assertEqual(result, {})
        mock_warning.assert_called_once()

    def test_is_enabled_and_as_int_handle_string_inputs(self):
        self.assertTrue(auth_flow._is_enabled("TRUE"))
        self.assertFalse(auth_flow._is_enabled("false"))
        self.assertEqual(auth_flow._as_int("42", 10), 42)
        self.assertEqual(auth_flow._as_int("not-an-int", 10), 10)

    def test_registration_atomic_context_returns_nullcontext_when_settings_missing(self):
        with patch.object(auth_flow, "settings", SimpleNamespace(configured=False)):
            context = auth_flow._registration_atomic_context()

        self.assertEqual(type(context).__name__, "nullcontext")

    def test_registration_atomic_context_uses_transaction_atomic_when_configured(self):
        fake_transaction = SimpleNamespace(atomic=MagicMock(return_value="atomic-context"))

        with patch.object(auth_flow, "settings", SimpleNamespace(configured=True)), patch.object(
            auth_flow, "transaction", fake_transaction
        ):
            context = auth_flow._registration_atomic_context()

        self.assertEqual(context, "atomic-context")
        fake_transaction.atomic.assert_called_once_with()

    def test_register_on_commit_returns_false_when_settings_not_configured(self):
        with patch.object(auth_flow, "settings", SimpleNamespace(configured=False)):
            self.assertFalse(auth_flow._register_on_commit(lambda: None))

    def test_register_on_commit_returns_false_when_connection_lookup_fails(self):
        fake_transaction = SimpleNamespace(
            get_connection=MagicMock(side_effect=RuntimeError("no connection")),
            on_commit=MagicMock(),
        )

        with patch.object(auth_flow, "settings", SimpleNamespace(configured=True)), patch.object(
            auth_flow, "transaction", fake_transaction
        ):
            result = auth_flow._register_on_commit(lambda: None)

        self.assertFalse(result)
        fake_transaction.on_commit.assert_not_called()

    def test_register_on_commit_returns_false_outside_atomic_block(self):
        fake_transaction = SimpleNamespace(
            get_connection=MagicMock(return_value=SimpleNamespace(in_atomic_block=False)),
            on_commit=MagicMock(),
        )

        with patch.object(auth_flow, "settings", SimpleNamespace(configured=True)), patch.object(
            auth_flow, "transaction", fake_transaction
        ):
            result = auth_flow._register_on_commit(lambda: None)

        self.assertFalse(result)
        fake_transaction.on_commit.assert_not_called()

    def test_register_on_commit_registers_callback_inside_atomic_block(self):
        callback = MagicMock()
        fake_transaction = SimpleNamespace(
            get_connection=MagicMock(return_value=SimpleNamespace(in_atomic_block=True)),
            on_commit=MagicMock(),
        )

        with patch.object(auth_flow, "settings", SimpleNamespace(configured=True)), patch.object(
            auth_flow, "transaction", fake_transaction
        ):
            result = auth_flow._register_on_commit(callback)

        self.assertTrue(result)
        fake_transaction.on_commit.assert_called_once_with(callback)

    @patch.object(auth_flow, "send_email")
    def test_send_verification_email_or_compensate_returns_true_on_success(self, mock_send_email):
        mock_send_email.return_value = SimpleNamespace(status_code=200)
        user = SimpleNamespace(email="member@example.com", delete=MagicMock())

        result = auth_flow._send_verification_email_or_compensate(
            user=user,
            email="member@example.com",
            html="<p>hello</p>",
            enabled=True,
        )

        self.assertTrue(result)
        user.delete.assert_not_called()

    @patch.object(auth_flow, "send_email", return_value=None)
    def test_send_verification_email_or_compensate_logs_delete_failure(self, _mock_send_email):
        user = SimpleNamespace(email="member@example.com", delete=MagicMock(side_effect=RuntimeError("boom")))

        with patch.object(auth_flow.logger, "exception") as mock_exception:
            result = auth_flow._send_verification_email_or_compensate(
                user=user,
                email="member@example.com",
                html="<p>hello</p>",
                enabled=True,
            )

        self.assertFalse(result)
        mock_exception.assert_called_once()

    @patch.object(auth_flow, "import_string", side_effect=ImportError("boom"))
    @patch.object(auth_flow, "_get_workflow_setting", return_value="users.missing.UserMeta")
    def test_get_user_meta_model_raises_for_import_error(
        self,
        _mock_get_workflow_setting,
        _mock_import_string,
    ):
        with self.assertRaises(auth_flow.ImproperlyConfigured):
            auth_flow._get_user_meta_model()

    @patch.object(auth_flow, "_get_workflow_setting", return_value="not-a-path")
    def test_get_user_meta_model_raises_for_invalid_path(self, _mock_get_workflow_setting):
        with self.assertRaises(auth_flow.ImproperlyConfigured):
            auth_flow._get_user_meta_model()

    def test_normalize_group_names_handles_sequences_and_scalars(self):
        result = auth_flow._normalize_group_names(["Superuser, Time Entry Admin", "Superuser", 7, None])

        self.assertEqual(result, ["Superuser", "Time Entry Admin", "7"])

    def test_get_existing_groups_returns_empty_when_no_names_provided(self):
        with patch.object(auth_flow, "_get_group_model") as mock_get_group_model:
            result = auth_flow._get_existing_groups(None)

        self.assertEqual(result, [])
        mock_get_group_model.assert_not_called()

    def test_get_group_model_returns_django_group_model(self):
        self.assertEqual(auth_flow._get_group_model().__name__, "Group")

    def test_sync_user_groups_returns_empty_without_group_manager(self):
        user = SimpleNamespace(email="member@example.com")

        with patch.object(
            auth_flow,
            "_get_existing_groups",
            return_value=[SimpleNamespace(name="Time Entry User")],
        ):
            result = auth_flow._sync_user_groups(
                user=user,
                groups=[{"name": "Time Entry User", "enabled": True}],
            )

        self.assertEqual(result, {"added": [], "removed": []})

    def test_coerce_bool_accepts_truthy_and_falsey_strings(self):
        self.assertTrue(auth_flow._coerce_bool("yes"))
        self.assertFalse(auth_flow._coerce_bool("off"))
        self.assertTrue(auth_flow._coerce_bool(True))
        self.assertTrue(auth_flow._coerce_bool(1))
        self.assertFalse(auth_flow._coerce_bool(0))

    def test_normalize_group_names_wraps_scalar_values(self):
        self.assertEqual(auth_flow._normalize_group_names(7), ["7"])

    def test_normalize_group_payload_requires_list_of_objects(self):
        with self.assertRaisesRegex(ValueError, "groups must be a list of objects with name and enabled."):
            auth_flow._normalize_group_payload("Time Entry Admin")

    def test_normalize_group_payload_rejects_non_dict_entries(self):
        with self.assertRaisesRegex(ValueError, "groups must be a list of objects with name and enabled."):
            auth_flow._normalize_group_payload(["Time Entry Admin"])

    def test_normalize_group_payload_requires_name_and_enabled(self):
        with self.assertRaisesRegex(ValueError, "Each group must include name and enabled."):
            auth_flow._normalize_group_payload([{"name": "Time Entry Admin"}])

    def test_normalize_group_payload_rejects_invalid_enabled_value(self):
        with self.assertRaisesRegex(ValueError, "Each group enabled value must be boolean."):
            auth_flow._normalize_group_payload([{"name": "Time Entry Admin", "enabled": "maybe"}])

    def test_normalize_group_payload_normalizes_and_deduplicates(self):
        result = auth_flow._normalize_group_payload(
            [
                {"name": "Time Entry Admin", "enabled": True},
                {"name": "Can Log Work", "enabled": 1},
                {"name": "Time Entry Admin", "enabled": False},
            ]
        )

        self.assertEqual(
            result,
            [
                {"name": "Time Entry Admin", "enabled": False},
                {"name": "Can Log Work", "enabled": True},
            ],
        )

    @patch.object(auth_flow, "_get_user_meta_model")
    def test_get_user_details_defaults_force_password_reset_false_and_inactive(self, mock_get_user_meta_model):
        class DoesNotExist(Exception):
            pass

        meta_model = SimpleNamespace(DoesNotExist=DoesNotExist, objects=MagicMock())
        meta_model.objects.get.side_effect = DoesNotExist
        mock_get_user_meta_model.return_value = meta_model

        user = SimpleNamespace(
            id=7,
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            groups=SimpleNamespace(all=lambda: []),
        )

        result = auth_flow.get_user_details(user)

        self.assertFalse(result["is_active"])
        self.assertFalse(result["force_password_reset"])
        self.assertEqual(result["groups"], [])

    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "get_user_model")
    def test_list_users_excludes_superuser_by_default_and_sorts_by_username_then_active(
        self,
        mock_get_user_model,
        mock_get_user_meta_model,
    ):
        class DoesNotExist(Exception):
            pass

        first_user = SimpleNamespace(
            id=3,
            username="alex@example.com",
            email="alex-inactive@example.com",
            first_name="Alex",
            last_name="User",
            is_active=False,
            groups=SimpleNamespace(all=lambda: []),
        )
        second_user = SimpleNamespace(
            id=1,
            username="alex@example.com",
            email="alex-active@example.com",
            first_name="Alex",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(all=lambda: []),
        )
        third_user = SimpleNamespace(
            id=2,
            username="bravo@example.com",
            email="bravo@example.com",
            first_name="Bravo",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(all=lambda: []),
        )
        fourth_user = SimpleNamespace(
            id=4,
            username="super@example.com",
            email="super@example.com",
            first_name="Super",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(all=lambda: [SimpleNamespace(name="Superuser")]),
        )

        user_model = self._make_user_model()
        user_model.objects.all.return_value = [first_user, second_user, third_user, fourth_user]
        mock_get_user_model.return_value = user_model

        meta_model = SimpleNamespace(DoesNotExist=DoesNotExist, objects=MagicMock())
        meta_model.objects.get.side_effect = DoesNotExist
        mock_get_user_meta_model.return_value = meta_model

        result = auth_flow.list_users()

        self.assertEqual(
            [(item["username"], item["is_active"], item["email"]) for item in result],
            [
                ("alex@example.com", True, "alex-active@example.com"),
                ("alex@example.com", False, "alex-inactive@example.com"),
                ("bravo@example.com", True, "bravo@example.com"),
            ],
        )

    @patch.object(auth_flow, "_get_group_model")
    def test_sync_user_groups_adds_and_removes_existing_groups(self, mock_get_group_model):
        enabled_group = SimpleNamespace(name="Time Entry User")
        disabled_group = SimpleNamespace(name="Can Log Work")
        group_model = SimpleNamespace(objects=MagicMock())
        group_model.objects.filter.return_value = [enabled_group, disabled_group]
        mock_get_group_model.return_value = group_model

        user = SimpleNamespace(
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
            save=MagicMock(),
            set_password=MagicMock(),
        )

        result = auth_flow._sync_user_groups(
            user=user,
            groups=[
                {"name": "Time Entry User", "enabled": True},
                {"name": "Can Log Work", "enabled": False},
                {"name": "Missing Group", "enabled": True},
            ],
        )

        self.assertEqual(
            result,
            {"added": ["Time Entry User"], "removed": ["Can Log Work"]},
        )
        user.groups.add.assert_called_once_with(enabled_group)
        user.groups.remove.assert_called_once_with(disabled_group)
        user.save.assert_not_called()

    @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_rejects_invalid_groups_payload(self, mock_get_user_model):
        user_model = self._make_user_model()
        user_model.objects.filter.return_value.exists.return_value = False
        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",
            groups="Time Entry Admin",
        )

        self.assertFalse(result.success)
        self.assertEqual(result.message, "groups must be a list of objects with name and enabled.")
        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, "get_user_model")
    def test_register_user_unexpected_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 = RuntimeError("boom")
        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, "An unexpected error occurred during registration.")
        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, "_register_on_commit", return_value=True)
    @patch.object(auth_flow, "send_email")
    @patch.object(auth_flow, "generate_strong_password", return_value="generated-password")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_group_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_admin_register_user_schedules_notice_email_on_commit(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_group_model,
        mock_get_user_meta_model,
        _mock_generate_strong_password,
        mock_send_email,
        mock_register_on_commit,
    ):
        mock_get_setting.side_effect = self._settings_getter(
            ENABLE_EMAIL_DELIVERY=True,
            COMPANY_NAME="Reusable Inc",
        )
        mock_send_email.return_value = SimpleNamespace(status_code=200, text="ok")

        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",
            groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
            delete=MagicMock(),
        )
        user_model.objects.create_user.return_value = user
        mock_get_user_model.return_value = user_model

        group_model = SimpleNamespace(objects=MagicMock())
        group_model.objects.filter.return_value = [SimpleNamespace(name="Time Entry User")]
        mock_get_group_model.return_value = group_model

        result = auth_flow.admin_register_user_and_send_setup_email(
            email="new@example.com",
            first_name="New",
            last_name="User",
            username="new@example.com",
            groups=[{"name": "Time Entry User", "enabled": True}],
        )

        self.assertTrue(result.success)
        mock_get_user_meta_model.assert_not_called()
        create_user_kwargs = user_model.objects.create_user.call_args.kwargs
        self.assertEqual(create_user_kwargs["password"], "generated-password")
        self.assertTrue(create_user_kwargs["is_active"])
        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()

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

        result = auth_flow.admin_register_user_and_send_setup_email(
            email="new@example.com",
            first_name="New",
            last_name="User",
            groups="Time Entry User",
        )

        self.assertFalse(result.success)
        self.assertEqual(result.message, "groups must be a list of objects with name and enabled.")
        user_model.objects.create_user.assert_not_called()

    @patch.object(auth_flow, "get_user_model")
    def test_admin_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.admin_register_user_and_send_setup_email(
            email="existing@example.com",
            first_name="New",
            last_name="User",
            groups=[],
        )

        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, "_register_on_commit", return_value=False)
    @patch.object(auth_flow, "send_email")
    @patch.object(auth_flow, "generate_strong_password", return_value="generated-password")
    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "_get_group_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_admin_register_user_creates_active_user_and_sends_notice(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_group_model,
        mock_get_user_meta_model,
        _mock_generate_strong_password,
        mock_send_email,
        _mock_register_on_commit,
    ):
        mock_get_setting.side_effect = self._settings_getter(
            ENABLE_EMAIL_DELIVERY=True,
            COMPANY_NAME="Reusable Inc",
        )
        mock_send_email.return_value = SimpleNamespace(status_code=200, text="ok")

        enabled_group = SimpleNamespace(name="Time Entry User")
        disabled_group = SimpleNamespace(name="Can Log Work")
        group_model = SimpleNamespace(objects=MagicMock())
        group_model.objects.filter.return_value = [enabled_group, disabled_group]
        mock_get_group_model.return_value = group_model

        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",
            groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
            delete=MagicMock(),
        )
        user_model.objects.create_user.return_value = user
        mock_get_user_model.return_value = user_model

        result = auth_flow.admin_register_user_and_send_setup_email(
            email="new@example.com",
            first_name="New",
            last_name="User",
            username="new@example.com",
            groups=[
                {"name": "Time Entry User", "enabled": True},
                {"name": "Can Log Work", "enabled": False},
            ],
        )

        self.assertTrue(result.success)
        self.assertEqual(
            result.message,
            "User created successfully. The user can use the Forgot Password flow to establish a password and login.",
        )
        mock_get_user_meta_model.assert_not_called()
        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(create_user_kwargs["password"], "generated-password")
        self.assertTrue(create_user_kwargs["is_active"])
        user.groups.add.assert_called_once_with(enabled_group)
        user.groups.remove.assert_called_once_with(disabled_group)

        kwargs = mock_send_email.call_args.kwargs
        self.assertEqual(kwargs["to"], ["new@example.com"])
        self.assertEqual(kwargs["subject"], "Your account has been created")
        self.assertIn(
            'You have been added as a registered user by an administrator. Use the "Forgot Password" flow to establish your password and login.',
            kwargs["html"],
        )
        self.assertIn("Reusable Inc", kwargs["html"])

    @patch.object(auth_flow, "generate_strong_password", return_value="generated-password")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_admin_register_user_integrity_error_logs_exception(
        self,
        mock_get_user_model,
        mock_get_setting,
        _mock_generate_strong_password,
    ):
        mock_get_setting.side_effect = self._settings_getter(ENABLE_EMAIL_DELIVERY=False)

        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.admin_register_user_and_send_setup_email(
                email="existing@example.com",
                first_name="New",
                last_name="User",
                groups=[],
            )

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

    @patch.object(auth_flow, "generate_strong_password", return_value="generated-password")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_admin_register_user_unexpected_error_logs_exception(
        self,
        mock_get_user_model,
        mock_get_setting,
        _mock_generate_strong_password,
    ):
        mock_get_setting.side_effect = self._settings_getter(ENABLE_EMAIL_DELIVERY=False)

        user_model = self._make_user_model()
        user_model.objects.filter.return_value.exists.return_value = False
        user_model.objects.create_user.side_effect = RuntimeError("boom")
        mock_get_user_model.return_value = user_model

        with patch.object(auth_flow.logger, "exception") as mock_exception:
            result = auth_flow.admin_register_user_and_send_setup_email(
                email="existing@example.com",
                first_name="New",
                last_name="User",
                groups=[],
            )

        self.assertFalse(result.success)
        self.assertEqual(result.message, "An unexpected error occurred during admin registration.")
        mock_exception.assert_called_once()

    @patch.object(auth_flow, "_register_on_commit", return_value=False)
    @patch.object(auth_flow, "send_email", return_value=None)
    @patch.object(auth_flow, "generate_strong_password", return_value="generated-password")
    @patch.object(auth_flow, "_get_group_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_admin_register_user_preserves_user_when_email_not_delivered(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_group_model,
        _mock_generate_strong_password,
        _mock_send_email,
        _mock_register_on_commit,
    ):
        mock_get_setting.side_effect = self._settings_getter(
            ENABLE_EMAIL_DELIVERY=True,
            COMPANY_NAME="Reusable Inc",
        )

        group_model = SimpleNamespace(objects=MagicMock())
        group_model.objects.filter.return_value = []
        mock_get_group_model.return_value = group_model

        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",
            groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
            delete=MagicMock(),
        )
        user_model.objects.create_user.return_value = user
        mock_get_user_model.return_value = user_model

        with patch.object(auth_flow.logger, "warning") as mock_warning:
            result = auth_flow.admin_register_user_and_send_setup_email(
                email="new@example.com",
                first_name="New",
                last_name="User",
                username="new@example.com",
                groups=[],
            )

        self.assertTrue(result.success)
        user.delete.assert_not_called()
        mock_warning.assert_called_once()

    @patch.object(auth_flow, "send_email")
    @patch.object(auth_flow, "generate_strong_password", return_value="generated-password")
    @patch.object(auth_flow, "_get_group_model")
    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_admin_register_user_disabled_email_delivery_skips_send(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_group_model,
        _mock_generate_strong_password,
        mock_send_email,
    ):
        mock_get_setting.side_effect = self._settings_getter(
            ENABLE_EMAIL_DELIVERY=False,
            COMPANY_NAME="Reusable Inc",
        )

        group_model = SimpleNamespace(objects=MagicMock())
        group_model.objects.filter.return_value = []
        mock_get_group_model.return_value = group_model

        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",
            groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
        )
        user_model.objects.create_user.return_value = user
        mock_get_user_model.return_value = user_model

        with patch.object(auth_flow.logger, "info") as mock_info:
            result = auth_flow.admin_register_user_and_send_setup_email(
                email="new@example.com",
                first_name="New",
                last_name="User",
                groups=[],
            )

        self.assertTrue(result.success)
        mock_send_email.assert_not_called()
        mock_info.assert_any_call("Email delivery is disabled. Admin registration email not sent.")

    @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")

    @patch.object(auth_flow, "_get_group_model")
    @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_applies_enabled_and_disabled_groups_when_requested(
        self,
        mock_get_user_model,
        mock_get_setting,
        mock_get_user_meta_model,
        _mock_generate_code,
        _mock_log_code_for_debug,
        mock_timezone,
        mock_get_group_model,
    ):
        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_groups = SimpleNamespace(add=MagicMock(), remove=MagicMock())
        user = SimpleNamespace(
            email="new@example.com",
            first_name="New",
            last_name="User",
            groups=user_groups,
        )
        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)

        enabled_group = SimpleNamespace(name="Time Entry Admin")
        disabled_group = SimpleNamespace(name="Can Log Work")
        group_model = SimpleNamespace(objects=MagicMock())
        group_model.objects.filter.return_value = [enabled_group, disabled_group]
        mock_get_group_model.return_value = group_model

        result = auth_flow.register_user_and_send_verification_email(
            email="new@example.com",
            first_name="New",
            last_name="User",
            username="new@example.com",
            password="pw",
            groups=[
                {"name": "Time Entry Admin", "enabled": True},
                {"name": "Can Log Work", "enabled": False},
                {"name": "Missing Group", "enabled": True},
            ],
        )

        self.assertTrue(result.success)
        user_groups.add.assert_called_once_with(enabled_group)
        user_groups.remove.assert_called_once_with(disabled_group)
        group_model.objects.filter.assert_called_once_with(
            name__in=["Time Entry Admin", "Can Log Work", "Missing Group"]
        )

    @patch.object(auth_flow, "_get_user_meta_model")
    def test_get_user_details_includes_is_active_and_groups(self, mock_get_user_meta_model):
        class DoesNotExist(Exception):
            pass

        meta_model = SimpleNamespace(
            DoesNotExist=DoesNotExist,
            objects=MagicMock(),
        )
        meta_model.objects.get.return_value = SimpleNamespace(force_password_reset=True)
        mock_get_user_meta_model.return_value = meta_model

        user = SimpleNamespace(
            id=7,
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(
                all=lambda: [SimpleNamespace(name="Superuser"), SimpleNamespace(name="Time Entry Admin")]
            ),
        )

        result = auth_flow.get_user_details(user)

        self.assertTrue(result["is_active"])
        self.assertTrue(result["force_password_reset"])
        self.assertEqual(result["groups"], ["Superuser", "Time Entry Admin"])

    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "get_user_model")
    def test_list_users_excludes_requested_groups(self, mock_get_user_model, mock_get_user_meta_model):
        class DoesNotExist(Exception):
            pass

        first_user = SimpleNamespace(
            id=2,
            username="super@example.com",
            email="super@example.com",
            first_name="Super",
            last_name="User",
            is_active=False,
            groups=SimpleNamespace(all=lambda: [SimpleNamespace(name="Superuser")]),
        )
        second_user = SimpleNamespace(
            id=1,
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(all=lambda: [SimpleNamespace(name="Time Entry User")]),
        )

        user_model = self._make_user_model()
        user_model.objects.all.return_value = [first_user, second_user]
        mock_get_user_model.return_value = user_model

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

        def get_meta(*args, **kwargs):
            user = kwargs["user"]
            if user is second_user:
                return SimpleNamespace(force_password_reset=False)
            raise DoesNotExist

        meta_model.objects.get.side_effect = get_meta
        mock_get_user_meta_model.return_value = meta_model

        result = auth_flow.list_users(exclude_groups="Superuser, Time Entry Admin")

        self.assertEqual(len(result), 1)
        self.assertEqual(result[0]["email"], "member@example.com")
        self.assertTrue(result[0]["is_active"])

    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "get_user_model")
    def test_list_users_excludes_superuser_even_without_explicit_query_filter(
        self,
        mock_get_user_model,
        mock_get_user_meta_model,
    ):
        class DoesNotExist(Exception):
            pass

        superuser_group_user = SimpleNamespace(
            id=1,
            username="super@example.com",
            email="super@example.com",
            first_name="Super",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(all=lambda: [SimpleNamespace(name="Superuser")]),
        )
        normal_user = SimpleNamespace(
            id=2,
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(all=lambda: [SimpleNamespace(name="Time Entry User")]),
        )

        user_model = self._make_user_model()
        user_model.objects.all.return_value = [superuser_group_user, normal_user]
        mock_get_user_model.return_value = user_model

        meta_model = SimpleNamespace(DoesNotExist=DoesNotExist, objects=MagicMock())
        meta_model.objects.get.side_effect = DoesNotExist
        mock_get_user_meta_model.return_value = meta_model

        result = auth_flow.list_users(exclude_groups=None)

        self.assertEqual([item["email"] for item in result], ["member@example.com"])

    @patch.object(auth_flow, "_get_group_model")
    def test_update_user_applies_is_active_and_existing_groups(self, mock_get_group_model):
        enabled_group = SimpleNamespace(name="Time Entry Admin")
        disabled_group = SimpleNamespace(name="Can Log Work")
        group_model = SimpleNamespace(objects=MagicMock())
        group_model.objects.filter.return_value = [enabled_group, disabled_group]
        mock_get_group_model.return_value = group_model

        user = SimpleNamespace(
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
            save=MagicMock(),
            set_password=MagicMock(),
        )

        result = auth_flow.update_user(
            user,
            {
                "email": "updated@example.com",
                "firstName": "Updated",
                "lastName": "Person",
                "password": "newpw123",
                "isActive": 0,
                "groups": [
                    {"name": "Time Entry Admin", "enabled": True},
                    {"name": "Can Log Work", "enabled": False},
                    {"name": "Missing Group", "enabled": True},
                ],
            },
        )

        self.assertTrue(result.success)
        self.assertEqual(user.username, "updated@example.com")
        self.assertEqual(user.email, "updated@example.com")
        self.assertEqual(user.first_name, "Updated")
        self.assertEqual(user.last_name, "Person")
        self.assertFalse(user.is_active)
        user.set_password.assert_called_once_with("newpw123")
        user.groups.add.assert_called_once_with(enabled_group)
        user.groups.remove.assert_called_once_with(disabled_group)
        user.save.assert_called_once()

    @patch.object(auth_flow, "_sync_user_groups")
    def test_update_user_does_not_mutate_groups_when_groups_field_is_omitted(self, mock_sync_user_groups):
        user = SimpleNamespace(
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
            save=MagicMock(),
            set_password=MagicMock(),
        )

        result = auth_flow.update_user(
            user,
            {
                "firstName": "Updated",
                "lastName": "Person",
            },
        )

        self.assertTrue(result.success)
        self.assertEqual(user.first_name, "Updated")
        self.assertEqual(user.last_name, "Person")
        mock_sync_user_groups.assert_not_called()

    @patch.object(auth_flow, "_get_group_model")
    def test_update_user_only_mutates_groups_provided_in_payload(self, mock_get_group_model):
        enabled_group = SimpleNamespace(name="Time Entry User")
        disabled_group = SimpleNamespace(name="Can Log Work")
        untouched_group = SimpleNamespace(name="Expense Manager")
        group_model = SimpleNamespace(objects=MagicMock())
        group_model.objects.filter.return_value = [enabled_group, disabled_group]
        mock_get_group_model.return_value = group_model

        user = SimpleNamespace(
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(
                add=MagicMock(),
                remove=MagicMock(),
                all=lambda: [enabled_group, disabled_group, untouched_group],
            ),
            save=MagicMock(),
            set_password=MagicMock(),
        )

        result = auth_flow.update_user(
            user,
            {
                "groups": [
                    {"name": "Time Entry User", "enabled": True},
                    {"name": "Can Log Work", "enabled": False},
                ]
            },
        )

        self.assertTrue(result.success)
        group_model.objects.filter.assert_called_once_with(
            name__in=["Time Entry User", "Can Log Work"]
        )
        user.groups.add.assert_called_once_with(enabled_group)
        user.groups.remove.assert_called_once_with(disabled_group)
        self.assertNotIn(untouched_group, user.groups.remove.call_args.args)
        user.save.assert_not_called()

    def test_update_user_rejects_invalid_is_active(self):
        user = SimpleNamespace(
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
            save=MagicMock(),
            set_password=MagicMock(),
        )

        result = auth_flow.update_user(user, {"isActive": "sometimes"})

        self.assertFalse(result.success)
        self.assertEqual(result.message, "isActive must be a boolean value.")
        user.save.assert_not_called()

    def test_update_user_rejects_invalid_groups_payload(self):
        user = SimpleNamespace(
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
            save=MagicMock(),
            set_password=MagicMock(),
        )

        result = auth_flow.update_user(user, {"groups": "Time Entry Admin"})

        self.assertFalse(result.success)
        self.assertEqual(result.message, "groups must be a list of objects with name and enabled.")
        user.save.assert_not_called()

    def test_update_user_returns_error_for_empty_payload(self):
        user = SimpleNamespace(
            username="member@example.com",
            email="member@example.com",
            first_name="Member",
            last_name="User",
            is_active=True,
            groups=SimpleNamespace(add=MagicMock(), remove=MagicMock()),
            save=MagicMock(),
            set_password=MagicMock(),
        )

        result = auth_flow.update_user(user, {})

        self.assertFalse(result.success)
        self.assertEqual(result.message, "No valid fields provided.")

    @patch.object(auth_flow, "timezone")
    @patch.object(auth_flow, "send_email", return_value=SimpleNamespace(status_code=200))
    @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_success_when_email_is_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, "info") as mock_info:
            result = auth_flow.send_forgot_password_code("new@example.com")

        self.assertTrue(result.success)
        mock_info.assert_any_call("Password reset code sent to %s", "new@example.com")

    def test_change_password_requires_email_and_password(self):
        result = auth_flow.change_password(email="", password="", password_verify="")

        self.assertFalse(result.success)
        self.assertEqual(result.message, "email and password are required.")

    def test_change_password_requires_matching_passwords(self):
        result = auth_flow.change_password(
            email="person@example.com",
            password="new-secret",
            password_verify="different",
        )

        self.assertFalse(result.success)
        self.assertEqual(result.message, "Password and password verification do not match.")

    @patch.object(auth_flow, "get_user_model")
    def test_change_password_rejects_invalid_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.change_password(
            email="person@example.com",
            password="new-secret",
            password_verify="new-secret",
        )

        self.assertFalse(result.success)
        self.assertEqual(result.message, "Invalid user.")

    @patch.object(auth_flow, "_get_user_meta_model")
    @patch.object(auth_flow, "get_user_model")
    def test_change_password_succeeds_without_force_password_reset_field(
        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(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)
        meta.save.assert_called_once()

    @patch.object(auth_flow, "validate_reset_code")
    def test_verify_reset_code_handles_unexpected_result(self, mock_validate_reset_code):
        mock_validate_reset_code.return_value = auth_flow.VerifyCodeOutcome(result="unexpected")

        with patch.object(auth_flow.logger, "warning") as mock_warning:
            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": "Unable to verify code."})
        mock_warning.assert_called_once()

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

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

        self.assertEqual(response.status_code, int(HTTPStatus.ACCEPTED))
        self.assertEqual(response.payload, {"detail": "Invalid code. Please check your code and try again."})

    @patch.object(auth_flow, "_get_workflow_setting")
    @patch.object(auth_flow, "get_user_model")
    def test_validate_reset_code_returns_invalid_for_missing_user(self, mock_get_user_model, mock_get_setting):
        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.side_effect = user_model.DoesNotExist
        mock_get_user_model.return_value = user_model

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

        self.assertEqual(result.result, auth_flow.VerifyCodeResult.INVALID)

    @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_returns_expired_when_code_timestamp_missing(
        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=None, 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.EXPIRED)
        self.assertIsNone(meta.verify_code)
        self.assertIsNone(meta.verify_time)

    @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_returns_invalid_when_code_missing(
        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=None, verify_time=now, 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.INVALID)
        self.assertIsNone(meta.verify_code)
        self.assertIsNone(meta.verify_time)

    @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_returns_too_many_attempts_after_final_wrong_try(
        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=2,
            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.TOO_MANY_ATTEMPTS)
        self.assertIsNone(meta.verify_code)
        self.assertIsNone(meta.verify_time)
        self.assertEqual(meta.attempts, 0)
