# #region Test.AppDependencies [C:3] [TYPE Module] [SEMANTICS test,dependencies,fastapi,di,auth]
# @BRIEF Unit tests for src/dependencies.py — DI functions, auth dependencies, feature flags.
# @RELATION BINDS_TO -> [AppDependencies]
# @TEST_EDGE: config_manager_lazy_init -> get_config_manager initializes on first call
# @TEST_EDGE: plugin_loader_lazy_init -> get_plugin_loader initializes on first call
# @TEST_EDGE: feature_disabled -> require_feature raises 404
# @TEST_EDGE: api_key_expired -> get_api_key_principal raises 401
# @TEST_EDGE: missing_auth -> require_api_key_or_jwt raises 401 when no auth provided

import sys
from pathlib import Path
from unittest.mock import MagicMock, patch, AsyncMock

import pytest
from fastapi import HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError

_src = str(Path(__file__).resolve().parent.parent / "src")
if _src not in sys.path:
    sys.path.insert(0, _src)


# ── get_config_manager ──


class TestGetConfigManager:
    """DI: get_config_manager() lazy init + reuse."""

    def test_get_config_manager_initializes(self):
        """Lazy init: creates ConfigManager on first call."""
        from src import dependencies

        dependencies.config_manager = None
        with patch('src.dependencies.init_db'), \
             patch('src.dependencies.ConfigManager') as MockCM:
            instance = MagicMock()
            MockCM.return_value = instance
            result = dependencies.get_config_manager()
            assert result is instance
            MockCM.assert_called_once()

    def test_get_config_manager_reuses(self):
        """Reuse: returns existing instance on subsequent calls."""
        from src import dependencies

        mock_cm = MagicMock()
        dependencies.config_manager = mock_cm
        result = dependencies.get_config_manager()
        assert result is mock_cm

    def test_get_config_manager_after_init(self):
        """Clearing global causes re-init."""
        from src import dependencies

        dependencies.config_manager = None
        with patch('src.dependencies.init_db'), \
             patch('src.dependencies.ConfigManager') as MockCM:
            instance = MagicMock()
            MockCM.return_value = instance
            result = dependencies.get_config_manager()
            assert result is instance


# ── require_feature ──


class TestRequireFeature:
    """DI: require_feature factory."""

    def test_feature_enabled(self):
        """Enabled feature passes."""
        from src.dependencies import require_feature

        mock_cm = MagicMock()
        mock_cm.get_config.return_value.settings.features.test_feature = True
        checker = require_feature("test_feature")
        # Should not raise
        checker(mock_cm)

    def test_feature_disabled_raises_404(self):
        """Disabled feature raises HTTPException 404."""
        from src.dependencies import require_feature

        mock_cm = MagicMock()
        mock_cm.get_config.return_value.settings.features.test_feature = False
        checker = require_feature("test_feature")
        with pytest.raises(HTTPException) as exc:
            checker(mock_cm)
        assert exc.value.status_code == status.HTTP_404_NOT_FOUND
        assert "disabled" in exc.value.detail


# ── get_plugin_loader ──


class TestGetPluginLoader:
    """DI: get_plugin_loader() lazy init."""

    def test_get_plugin_loader_initializes(self):
        """Lazy init: creates PluginLoader on first call."""
        from src import dependencies

        dependencies.plugin_loader = None
        with patch('src.dependencies.PluginLoader') as MockPL, \
             patch('src.dependencies.logger'):
            instance = MagicMock()
            MockPL.return_value = instance
            instance.get_all_plugin_configs.return_value = []
            result = dependencies.get_plugin_loader()
            assert result is instance

    def test_get_plugin_loader_reuses(self):
        """Reuse: returns existing instance."""
        from src import dependencies

        mock_pl = MagicMock()
        dependencies.plugin_loader = mock_pl
        result = dependencies.get_plugin_loader()
        assert result is mock_pl


# ── get_task_manager ──


class TestGetTaskManager:
    """DI: get_task_manager() lazy init."""

    def test_get_task_manager_initializes(self):
        """Lazy init: creates TaskManager on first call."""
        from src import dependencies

        dependencies.task_manager = None
        with patch('src.dependencies.get_plugin_loader'), \
             patch('src.dependencies.TaskManager') as MockTM, \
             patch('src.dependencies.logger'):
            instance = MagicMock()
            MockTM.return_value = instance
            result = dependencies.get_task_manager()
            assert result is instance

    def test_get_task_manager_reuses(self):
        """Reuse: returns existing instance."""
        from src import dependencies

        mock_tm = MagicMock()
        dependencies.task_manager = mock_tm
        result = dependencies.get_task_manager()
        assert result is mock_tm


# ── get_scheduler_service ──


class TestGetSchedulerService:
    """DI: get_scheduler_service() lazy init."""

    def test_get_scheduler_service_initializes(self):
        """Lazy init: creates SchedulerService on first call."""
        from src import dependencies

        dependencies.scheduler_service = None
        with patch('src.dependencies.get_task_manager'), \
             patch('src.dependencies.get_config_manager'), \
             patch('src.dependencies.SchedulerService') as MockSS, \
             patch('src.dependencies.logger'):
            instance = MagicMock()
            MockSS.return_value = instance
            result = dependencies.get_scheduler_service()
            assert result is instance

    def test_get_scheduler_service_reuses(self):
        """Reuse: returns existing instance."""
        from src import dependencies

        mock_ss = MagicMock()
        dependencies.scheduler_service = mock_ss
        result = dependencies.get_scheduler_service()
        assert result is mock_ss


# ── get_resource_service ──


class TestGetResourceService:
    """DI: get_resource_service() lazy init."""

    def test_get_resource_service_initializes(self):
        """Lazy init: creates ResourceService on first call."""
        from src import dependencies

        dependencies.resource_service = None
        with patch('src.dependencies.ResourceService') as MockRS, \
             patch('src.dependencies.logger'):
            instance = MagicMock()
            MockRS.return_value = instance
            result = dependencies.get_resource_service()
            assert result is instance

    def test_get_resource_service_reuses(self):
        """Reuse: returns existing instance."""
        from src import dependencies

        mock_rs = MagicMock()
        dependencies.resource_service = mock_rs
        result = dependencies.get_resource_service()
        assert result is mock_rs


# ── get_mapping_service ──


class TestGetMappingService:
    """DI: get_mapping_service() creates new instance each time."""

    def test_get_mapping_service(self):
        """Returns new MappingService with config_manager."""
        from src.dependencies import get_mapping_service

        mock_cm = MagicMock()
        with patch('src.dependencies.get_config_manager', return_value=mock_cm), \
             patch('src.dependencies.MappingService') as MockMS:
            instance = MagicMock()
            MockMS.return_value = instance
            result = get_mapping_service()
            assert result is instance
            MockMS.assert_called_once_with(mock_cm)


# ── get_clean_release_repository ──


class TestGetCleanReleaseRepository:
    """DI: get_clean_release_repository() returns shared singleton."""

    def test_get_clean_release_repository(self):
        """Returns the module-level singleton."""
        from src.dependencies import get_clean_release_repository
        result = get_clean_release_repository()
        assert result is not None

    def test_singleton_identity(self):
        """Multiple calls return same instance."""
        from src.dependencies import get_clean_release_repository
        r1 = get_clean_release_repository()
        r2 = get_clean_release_repository()
        assert r1 is r2


# ── get_clean_release_facade ──


class TestGetCleanReleaseFacade:
    """DI: get_clean_release_facade() builds facade with fresh DB session."""

    def test_get_clean_release_facade(self):
        """Creates CleanReleaseFacade with all repos."""
        from src.dependencies import get_clean_release_facade

        mock_db = MagicMock()
        mock_cm = MagicMock()

        with patch('src.dependencies.get_config_manager', return_value=mock_cm), \
             patch('src.dependencies.CleanReleaseFacade') as MockFacade:
            instance = MagicMock()
            MockFacade.return_value = instance
            result = get_clean_release_facade(mock_db)
            assert result is instance
            # Verify all repos passed to facade
            call_kwargs = MockFacade.call_args[1]
            assert "candidate_repo" in call_kwargs
            assert "artifact_repo" in call_kwargs
            assert "manifest_repo" in call_kwargs
            assert "audit_repo" in call_kwargs


# ── get_current_user ──


class TestGetCurrentUser:
    """DI: get_current_user extracts user from JWT."""

    def test_get_current_user_success(self):
        """Valid JWT returns User."""
        from src.dependencies import get_current_user

        mock_user = MagicMock()
        mock_user.username = "testuser"

        mock_repo = MagicMock()
        mock_repo.get_user_by_username.return_value = mock_user

        with patch('src.dependencies.decode_token', return_value={"sub": "testuser"}), \
             patch('src.dependencies.is_token_blacklisted', return_value=False), \
             patch('src.dependencies.AuthRepository', return_value=mock_repo):
            result = get_current_user("valid_token", MagicMock())
            assert result is mock_user

    def test_get_current_user_invalid_token(self):
        """Invalid JWT raises 401."""
        from src.dependencies import get_current_user

        with patch('src.dependencies.decode_token', side_effect=JWTError("bad token")):
            with pytest.raises(HTTPException) as exc:
                get_current_user("bad_token", MagicMock())
            assert exc.value.status_code == 401

    def test_get_current_user_no_sub(self):
        """Token without sub raises 401."""
        from src.dependencies import get_current_user

        with patch('src.dependencies.decode_token', return_value={"sub": None}):
            with pytest.raises(HTTPException) as exc:
                get_current_user("token", MagicMock())
            assert exc.value.status_code == 401

    def test_get_current_user_blacklisted(self):
        """Blacklisted token raises 401."""
        from src.dependencies import get_current_user

        with patch('src.dependencies.decode_token', return_value={"sub": "user"}), \
             patch('src.dependencies.is_token_blacklisted', return_value=True):
            with pytest.raises(HTTPException) as exc:
                get_current_user("revoked", MagicMock())
            assert exc.value.status_code == 401

    def test_get_current_user_not_found(self):
        """User not in DB raises 401."""
        from src.dependencies import get_current_user

        mock_repo = MagicMock()
        mock_repo.get_user_by_username.return_value = None

        with patch('src.dependencies.decode_token', return_value={"sub": "nobody"}), \
             patch('src.dependencies.is_token_blacklisted', return_value=False), \
             patch('src.dependencies.AuthRepository', return_value=mock_repo):
            with pytest.raises(HTTPException) as exc:
                get_current_user("token", MagicMock())
            assert exc.value.status_code == 401


# ── has_permission ──


class TestHasPermission:
    """DI: has_permission factory."""

    def _make_user(self, is_admin=False, permissions=None):
        """Build a mock User with roles and permissions."""
        from src.models.auth import User, Role, Permission

        user = MagicMock(spec=User)
        if is_admin:
            role = MagicMock(spec=Role)
            role.name = "Admin"
            role.is_admin = True
            role.permissions = []
            user.roles = [role]
        else:
            perms = []
            for res, act in (permissions or []):
                p = MagicMock(spec=Permission)
                p.resource = res
                p.action = act
                perms.append(p)
            role = MagicMock(spec=Role)
            role.name = "User"
            role.is_admin = False
            role.permissions = perms
            user.roles = [role]
        return user

    def test_has_permission_admin(self):
        """Admin user has all permissions."""
        from src.dependencies import has_permission

        checker = has_permission("dataset:session", "MANAGE")
        user = self._make_user(is_admin=True)
        result = checker(user)
        assert result is user

    def test_has_permission_granted(self):
        """User with matching permission passes."""
        from src.dependencies import has_permission

        checker = has_permission("dataset:session", "READ")
        user = self._make_user(permissions=[("dataset:session", "READ")])
        result = checker(user)
        assert result is user

    def test_has_permission_denied(self):
        """User without permission raises 403."""
        from src.dependencies import has_permission

        checker = has_permission("dataset:session", "DELETE")
        user = self._make_user(permissions=[("dataset:session", "READ")])

        with patch('src.core.auth.logger.log_security_event'):
            with pytest.raises(HTTPException) as exc:
                checker(user)
            assert exc.value.status_code == 403


# ── get_api_key_principal ──


class TestGetApiKeyPrincipal:
    """DI: get_api_key_principal extracts principal from API key header."""

    @pytest.mark.asyncio
    async def test_no_header_returns_none(self):
        """No X-API-Key header returns None."""
        from src.dependencies import get_api_key_principal

        request = MagicMock(spec=Request)
        request.headers.get.return_value = None

        result = await get_api_key_principal(request, MagicMock())
        assert result is None

    @pytest.mark.asyncio
    async def test_valid_key(self):
        """Valid API key returns APIKeyPrincipal."""
        from src.dependencies import get_api_key_principal

        request = MagicMock(spec=Request)
        request.headers.get.return_value = "valid-key-123"

        mock_api_key = MagicMock()
        mock_api_key.id = "key-1"
        mock_api_key.name = "Test Key"
        mock_api_key.prefix = "prefix"
        mock_api_key.active = True
        mock_api_key.expires_at = None
        mock_api_key.environment_id = "env-1"
        mock_api_key.permissions = ["dataset:read"]

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key

        with patch('src.core.auth.api_key.hash_api_key', return_value="hashed_key"):
            result = await get_api_key_principal(request, mock_db)
            assert result is not None
            assert result.name == "Test Key"
            assert result.api_key_id == "key-1"
            assert result.environment_id == "env-1"
            assert "dataset:read" in result.permissions

    @pytest.mark.asyncio
    async def test_invalid_key_raises(self):
        """Invalid API key raises 401."""
        from src.dependencies import get_api_key_principal

        request = MagicMock(spec=Request)
        request.headers.get.return_value = "bad-key"

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = None

        with patch('src.core.auth.api_key.hash_api_key', return_value="bad_hash"):
            with pytest.raises(HTTPException) as exc:
                await get_api_key_principal(request, mock_db)
            assert exc.value.status_code == 401

    @pytest.mark.asyncio
    async def test_revoked_key_raises(self):
        """Revoked API key raises 401."""
        from src.dependencies import get_api_key_principal

        request = MagicMock(spec=Request)
        request.headers.get.return_value = "revoked-key"

        mock_api_key = MagicMock()
        mock_api_key.active = False

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            with pytest.raises(HTTPException) as exc:
                await get_api_key_principal(request, mock_db)
            assert exc.value.status_code == 401
            assert "revoked" in exc.value.detail.lower()

    @pytest.mark.asyncio
    async def test_expired_key_raises(self):
        """Expired API key raises 401."""
        from datetime import UTC, datetime, timedelta
        from src.dependencies import get_api_key_principal

        request = MagicMock(spec=Request)
        request.headers.get.return_value = "expired-key"

        mock_api_key = MagicMock()
        mock_api_key.active = True
        mock_api_key.expires_at = datetime.now(UTC) - timedelta(hours=1)

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            with pytest.raises(HTTPException) as exc:
                await get_api_key_principal(request, mock_db)
            assert exc.value.status_code == 401
            assert "expired" in exc.value.detail.lower()

    @pytest.mark.asyncio
    async def test_expired_key_no_tz_raises(self):
        """Expired API key without timezone raises 401."""
        from datetime import datetime
        from src.dependencies import get_api_key_principal

        request = MagicMock(spec=Request)
        request.headers.get.return_value = "expired-key"

        mock_api_key = MagicMock()
        mock_api_key.active = True
        mock_api_key.expires_at = datetime(2020, 1, 1, 0, 0, 0)  # naive, clearly past

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            with pytest.raises(HTTPException) as exc:
                await get_api_key_principal(request, mock_db)
            assert exc.value.status_code == 401


# ── check_api_key_environment_scope ──


class TestCheckApiKeyEnvironmentScope:
    """check_api_key_environment_scope enforces key scope."""

    @pytest.mark.asyncio
    async def test_no_key_header_returns(self):
        """No API key header → no check."""
        from src.dependencies import check_api_key_environment_scope

        request = MagicMock(spec=Request)
        request.headers.get.return_value = None

        # Should not raise
        await check_api_key_environment_scope(request, MagicMock(), "env-1")

    @pytest.mark.asyncio
    async def test_key_scope_matches(self):
        """Key scope matches target → pass."""
        from src.dependencies import check_api_key_environment_scope

        request = MagicMock(spec=Request)
        request.headers.get.return_value = "valid-key"

        mock_api_key = MagicMock()
        mock_api_key.environment_id = "env-1"

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            await check_api_key_environment_scope(request, mock_db, "env-1")

    @pytest.mark.asyncio
    async def test_key_scope_mismatch_raises(self):
        """Key scope differs from target → 400."""
        from src.dependencies import check_api_key_environment_scope

        request = MagicMock(spec=Request)
        request.headers.get.return_value = "wrong-key"

        mock_api_key = MagicMock()
        mock_api_key.environment_id = "env-1"

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = mock_api_key

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            with pytest.raises(HTTPException) as exc:
                await check_api_key_environment_scope(request, mock_db, "env-2")
            assert exc.value.status_code == 400
            assert "restricted" in exc.value.detail.lower()

    @pytest.mark.asyncio
    async def test_key_not_found_returns(self):
        """Key not in DB → pass (auth handled elsewhere)."""
        from src.dependencies import check_api_key_environment_scope

        request = MagicMock(spec=Request)
        request.headers.get.return_value = "unknown-key"

        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = None

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            await check_api_key_environment_scope(request, mock_db, "env-1")


# ── require_api_key_or_jwt (full coverage) ──


class TestRequireApiKeyOrJwtBase:
    """Shared helpers for require_api_key_or_jwt tests."""

    def _build_dep(self, api_perm="dataset:read", jwt_res="dataset", jwt_act="read", env_id=None):
        from src.dependencies import require_api_key_or_jwt
        return require_api_key_or_jwt(
            required_api_permission=api_perm,
            required_jwt_resource=jwt_res,
            required_jwt_action=jwt_act,
            environment_id=env_id,
        )

    def _make_request(self, body=None, headers=None):
        req = MagicMock(spec=Request)
        req.json = AsyncMock(return_value=body or {})
        req.headers.get = MagicMock(return_value=(headers or {}).get("X-API-Key"))
        return req


class TestRequireApiKeyOrJwtJwt(TestRequireApiKeyOrJwtBase):
    """require_api_key_or_jwt — JWT auth path."""

    @pytest.mark.asyncio
    async def test_jwt_success_admin(self):
        """JWT with Admin role passes."""
        dep = self._build_dep(env_id="env-1")
        mock_user = MagicMock()
        mock_user.username = "admin"
        role = MagicMock()
        role.name = "Admin"
        role.permissions = []
        mock_user.roles = [role]
        mock_repo = MagicMock()
        mock_repo.get_user_by_username.return_value = mock_user
        request = self._make_request()

        with patch('src.dependencies.decode_token', return_value={"sub": "admin"}), \
             patch('src.dependencies.AuthRepository', return_value=mock_repo), \
             patch('src.core.auth.api_key.hash_api_key'):
            result = await dep(request, MagicMock(), MagicMock(), "valid_token")
            assert result == "jwt:admin"

    @pytest.mark.asyncio
    async def test_jwt_non_string_sub_raises_401(self):
        """JWT with non-string sub raises 401 (covers lines 298)."""
        dep = self._build_dep()
        request = self._make_request()

        with patch('src.dependencies.decode_token', return_value={"sub": 12345}):  # non-string
            with pytest.raises(HTTPException) as exc:
                await dep(request, MagicMock(), MagicMock(), "bad_token")
            assert exc.value.status_code == 401

    @pytest.mark.asyncio
    async def test_jwt_decode_exception_raises_401(self):
        """JWT decode exception raises 401 (covers lines 304-305)."""
        dep = self._build_dep()
        request = self._make_request()

        with patch('src.dependencies.decode_token', side_effect=Exception("Token expired")):
            with pytest.raises(HTTPException) as exc:
                await dep(request, MagicMock(), MagicMock(), "expired_token")
            assert exc.value.status_code == 401

    @pytest.mark.asyncio
    async def test_jwt_user_not_found_raises_401(self):
        """JWT user not in DB raises 401 (covers line 314)."""
        dep = self._build_dep()
        mock_repo = MagicMock()
        mock_repo.get_user_by_username.return_value = None
        request = self._make_request()

        with patch('src.dependencies.decode_token', return_value={"sub": "ghost"}), \
             patch('src.dependencies.AuthRepository', return_value=mock_repo), \
             patch('src.core.auth.api_key.hash_api_key'):
            with pytest.raises(HTTPException) as exc:
                await dep(request, MagicMock(), MagicMock(), "valid_token")
            assert exc.value.status_code == 401
            assert "User not found" in exc.value.detail

    @pytest.mark.asyncio
    async def test_jwt_permission_granted_via_role(self):
        """JWT non-admin user with matching permission passes (covers lines 325-331)."""
        dep = self._build_dep(jwt_res="dataset", jwt_act="read")
        mock_user = MagicMock()
        mock_user.username = "editor"
        perm = MagicMock()
        perm.resource = "dataset"
        perm.action = "read"
        role = MagicMock()
        role.name = "Editor"
        role.is_admin = False
        role.permissions = [perm]
        mock_user.roles = [role]
        mock_repo = MagicMock()
        mock_repo.get_user_by_username.return_value = mock_user
        request = self._make_request()

        with patch('src.dependencies.decode_token', return_value={"sub": "editor"}), \
             patch('src.dependencies.AuthRepository', return_value=mock_repo), \
             patch('src.core.auth.api_key.hash_api_key'):
            result = await dep(request, MagicMock(), MagicMock(), "valid_token")
            assert result == "jwt:editor"

    @pytest.mark.asyncio
    async def test_jwt_permission_denied_raises_403(self):
        """JWT user without required permission raises 403 (covers line 334)."""
        dep = self._build_dep(jwt_res="dataset", jwt_act="delete")
        mock_user = MagicMock()
        mock_user.username = "viewer"
        perm = MagicMock()
        perm.resource = "dataset"
        perm.action = "read"  # wrong action
        role = MagicMock()
        role.name = "Viewer"
        role.is_admin = False
        role.permissions = [perm]
        mock_user.roles = [role]
        mock_repo = MagicMock()
        mock_repo.get_user_by_username.return_value = mock_user
        request = self._make_request()

        with patch('src.dependencies.decode_token', return_value={"sub": "viewer"}), \
             patch('src.dependencies.AuthRepository', return_value=mock_repo), \
             patch('src.core.auth.api_key.hash_api_key'):
            with pytest.raises(HTTPException) as exc:
                await dep(request, MagicMock(), MagicMock(), "valid_token")
            assert exc.value.status_code == 403
            assert "Permission denied" in exc.value.detail

    @pytest.mark.asyncio
    async def test_jwt_env_id_from_request_body(self):
        """When environment_id is None, read from request body (covers lines 289-290)."""
        dep = self._build_dep()  # no env_id — reads from body
        mock_user = MagicMock()
        mock_user.username = "admin"
        role = MagicMock()
        role.name = "Admin"
        mock_user.roles = [role]
        mock_repo = MagicMock()
        mock_repo.get_user_by_username.return_value = mock_user
        request = self._make_request(body={"environment_id": "env-from-body"})

        with patch('src.dependencies.decode_token', return_value={"sub": "admin"}), \
             patch('src.dependencies.AuthRepository', return_value=mock_repo), \
             patch('src.core.auth.api_key.hash_api_key'):
            result = await dep(request, MagicMock(), MagicMock(), "valid_token")
            assert result == "jwt:admin"

    @pytest.mark.asyncio
    async def test_jwt_body_parse_error_ignored(self):
        """JSON parse error in request body does not crash (covers line 290 except branch)."""
        import json
        dep = self._build_dep()
        mock_user = MagicMock()
        mock_user.username = "admin"
        role = MagicMock()
        role.name = "Admin"
        mock_user.roles = [role]
        mock_repo = MagicMock()
        mock_repo.get_user_by_username.return_value = mock_user
        request = MagicMock(spec=Request)
        request.json = AsyncMock(side_effect=json.JSONDecodeError("bad json", "", 0))
        request.headers.get = MagicMock(return_value=None)

        with patch('src.dependencies.decode_token', return_value={"sub": "admin"}), \
             patch('src.dependencies.AuthRepository', return_value=mock_repo), \
             patch('src.core.auth.api_key.hash_api_key'):
            result = await dep(request, MagicMock(), MagicMock(), "valid_token")
            assert result == "jwt:admin"

    @pytest.mark.asyncio
    async def test_no_auth_raises_401(self):
        """Neither JWT nor API key → 401."""
        dep = self._build_dep()
        request = self._make_request()

        with pytest.raises(HTTPException) as exc:
            await dep(request, MagicMock(), MagicMock(), None)
        assert exc.value.status_code == 401
        assert "Authentication required" in exc.value.detail


class TestRequireApiKeyOrJwtApiKey(TestRequireApiKeyOrJwtBase):
    """require_api_key_or_jwt — API key auth path (lines 345-388)."""

    def _mock_api_key(
        self,
        active=True,
        expires_at=None,
        permissions=None,
        environment_id=None,
        name="Test Key",
        prefix="test",
    ):
        from datetime import UTC, datetime
        key = MagicMock()
        key.name = name
        key.prefix = prefix
        key.active = active
        key.expires_at = expires_at
        key.environment_id = environment_id
        key.permissions = permissions or ["dataset:read"]
        key.last_used_at = None
        return key

    def _setup(self, api_key, body=None, env_id=None):
        dep = self._build_dep(env_id=env_id)
        request = self._make_request(body=body, headers={"X-API-Key": "raw-key-value"})
        mock_db = MagicMock()
        mock_db.query.return_value.filter.return_value.first.return_value = api_key
        return dep, request, mock_db

    @pytest.mark.asyncio
    async def test_api_key_valid(self):
        """Valid API key returns principal name (covers lines 345-388 happy path)."""
        api_key = self._mock_api_key()
        dep, request, mock_db = self._setup(api_key)

        with patch('src.core.auth.api_key.hash_api_key', return_value="hashed"):
            result = await dep(request, mock_db, MagicMock(), None)
            assert result == "api_key:Test Key"
            # last_used_at should be updated
            assert api_key.last_used_at is not None

    @pytest.mark.asyncio
    async def test_api_key_not_found_raises_401(self):
        """API key not in DB raises 401 (covers line 347-351)."""
        dep, request, mock_db = self._setup(None)
        mock_db.query.return_value.filter.return_value.first.return_value = None

        with patch('src.core.auth.api_key.hash_api_key', return_value="bad_hash"):
            with pytest.raises(HTTPException) as exc:
                await dep(request, mock_db, MagicMock(), None)
            assert exc.value.status_code == 401
            assert "Invalid API key" in exc.value.detail

    @pytest.mark.asyncio
    async def test_api_key_revoked_raises_401(self):
        """Revoked API key raises 401 (covers lines 352-356)."""
        api_key = self._mock_api_key(active=False)
        dep, request, mock_db = self._setup(api_key)

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            with pytest.raises(HTTPException) as exc:
                await dep(request, mock_db, MagicMock(), None)
            assert exc.value.status_code == 401
            assert "revoked" in exc.value.detail.lower()

    @pytest.mark.asyncio
    async def test_api_key_expired_raises_401(self):
        """Expired API key raises 401 (covers lines 357-365)."""
        from datetime import UTC, datetime, timedelta
        api_key = self._mock_api_key(expires_at=datetime.now(UTC) - timedelta(hours=1))
        dep, request, mock_db = self._setup(api_key)

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            with pytest.raises(HTTPException) as exc:
                await dep(request, mock_db, MagicMock(), None)
            assert exc.value.status_code == 401
            assert "expired" in exc.value.detail.lower()

    @pytest.mark.asyncio
    async def test_api_key_expired_naive_tz(self):
        """Expired API key without timezone on expires_at (covers line 359-360)."""
        from datetime import datetime
        api_key = self._mock_api_key(expires_at=datetime(2020, 1, 1))  # naive → past
        dep, request, mock_db = self._setup(api_key)

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            with pytest.raises(HTTPException) as exc:
                await dep(request, mock_db, MagicMock(), None)
            assert exc.value.status_code == 401
            assert "expired" in exc.value.detail.lower()

    @pytest.mark.asyncio
    async def test_api_key_missing_permission_raises_403(self):
        """API key lacking required permission raises 403 (covers lines 368-373)."""
        api_key = self._mock_api_key(permissions=["dataset:other"])
        dep, request, mock_db = self._setup(api_key)

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            with pytest.raises(HTTPException) as exc:
                await dep(request, mock_db, MagicMock(), None)
            assert exc.value.status_code == 403
            assert "lacks permission" in exc.value.detail

    @pytest.mark.asyncio
    async def test_api_key_scope_mismatch_raises_400(self):
        """API key environment_id differs from target → 400 (covers lines 376-381)."""
        api_key = self._mock_api_key(environment_id="env-prod")
        dep, request, mock_db = self._setup(api_key, env_id="env-dev")

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            with pytest.raises(HTTPException) as exc:
                await dep(request, mock_db, MagicMock(), None)
            assert exc.value.status_code == 400
            assert "restricted" in exc.value.detail.lower()

    @pytest.mark.asyncio
    async def test_api_key_scope_from_request_body(self):
        """When env_id not in factory, scope comes from request body (covers env resolution)."""
        api_key = self._mock_api_key(environment_id="env-from-body")
        dep, request, mock_db = self._setup(api_key, body={"environment_id": "env-from-body"})

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            result = await dep(request, mock_db, MagicMock(), None)
            assert result == "api_key:Test Key"

    @pytest.mark.asyncio
    async def test_api_key_key_env_is_none_ok(self):
        """When API key has no environment_id, no scope check (no 400)."""
        api_key = self._mock_api_key(environment_id=None)
        dep, request, mock_db = self._setup(api_key, env_id="env-target")

        with patch('src.core.auth.api_key.hash_api_key', return_value="hash"):
            result = await dep(request, mock_db, MagicMock(), None)
            assert result == "api_key:Test Key"
