"""
Familiar v1.3.9 — Security Gap Tests
=====================================
Tests for the 5 most critical untested modules identified in the test gap analysis:

1. familiar/core/users.py        — Magic links, sessions, rate limiting, invitations
2. familiar/admin/__init__.py     — Auth decorators, login flow, route authorization
3. familiar/core/data_store.py    — Path traversal prevention, permissions
4. familiar/core/auth/__init__.py — SSO state, domain validation, session contract
5. familiar/core/sanitization.py  — Input sanitization, shell safety, SSRF

Run with:
    pytest tests/test_security_gaps.py -v
"""

import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch

import pytest

# Ensure project root on path
sys.path.insert(0, str(Path(__file__).parent.parent))

# Flask is an optional dependency (web extra) — admin tests skip if absent
try:
    import flask  # noqa: F401

    HAS_FLASK = True
except ImportError:
    HAS_FLASK = False

_skip_no_flask = pytest.mark.skipif(not HAS_FLASK, reason="Flask not installed (web extra)")


# ============================================================
# 1. USERS.PY TESTS
# ============================================================


class TestUserManager:
    """Tests for familiar.core.users.UserManager."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        """Create a fresh UserManager with a temp database."""
        from familiar.core.users import UserManager, UserRole, UserStatus, _token_attempts

        _token_attempts.clear()
        self.db_path = tmp_path / "test_users.db"
        self.manager = UserManager(db_path=self.db_path)
        self.UserRole = UserRole
        self.UserStatus = UserStatus

    # ---- User CRUD ----

    def test_create_user_basic(self):
        user = self.manager.create_user("test@example.com", "Test User")
        assert user.email == "test@example.com"
        assert user.name == "Test User"
        assert user.role == self.UserRole.STAFF
        assert user.status == self.UserStatus.ACTIVE
        assert user.id is not None

    def test_create_user_email_normalized(self):
        user = self.manager.create_user("  Test@Example.COM  ", "Test")
        assert user.email == "test@example.com"

    def test_create_user_duplicate_email_fails(self):
        self.manager.create_user("dup@example.com")
        with pytest.raises(sqlite3.IntegrityError):
            self.manager.create_user("dup@example.com")

    def test_get_user_by_id(self):
        user = self.manager.create_user("byid@example.com")
        fetched = self.manager.get_user(user.id)
        assert fetched is not None
        assert fetched.email == "byid@example.com"

    def test_get_user_by_email(self):
        self.manager.create_user("byemail@example.com")
        fetched = self.manager.get_user_by_email("byemail@example.com")
        assert fetched is not None

    def test_get_user_by_email_case_insensitive(self):
        self.manager.create_user("case@example.com")
        fetched = self.manager.get_user_by_email("CASE@EXAMPLE.COM")
        assert fetched is not None

    def test_get_nonexistent_user(self):
        assert self.manager.get_user("nonexistent") is None
        assert self.manager.get_user_by_email("nope@example.com") is None

    def test_list_users_filters(self):
        self.manager.create_user("admin@example.com", role=self.UserRole.ADMIN)
        self.manager.create_user("staff@example.com", role=self.UserRole.STAFF)

        admins = self.manager.list_users(role=self.UserRole.ADMIN)
        assert len(admins) == 1
        assert admins[0].role == self.UserRole.ADMIN

    def test_update_user(self):
        user = self.manager.create_user("upd@example.com")
        updated = self.manager.update_user(user.id, name="New Name")
        assert updated.name == "New Name"

    def test_update_user_ignores_disallowed_fields(self):
        user = self.manager.create_user("secure@example.com")
        updated = self.manager.update_user(user.id, email="hacked@evil.com", name="Safe")
        assert updated.email == "secure@example.com"  # email not updateable
        assert updated.name == "Safe"

    def test_deactivate_user(self):
        user = self.manager.create_user("deact@example.com")
        self.manager.deactivate_user(user.id)
        fetched = self.manager.get_user(user.id)
        assert fetched.status == self.UserStatus.DEACTIVATED

    def test_deactivate_invalidates_sessions(self):
        user = self.manager.create_user("sessdeact@example.com")
        token = self.manager.create_session(user)
        assert self.manager.authenticate_session(token) is not None
        self.manager.deactivate_user(user.id)
        assert self.manager.authenticate_session(token) is None

    def test_link_telegram(self):
        user = self.manager.create_user("tg@example.com")
        assert self.manager.link_telegram(user.id, 123456)
        fetched = self.manager.get_user_by_telegram(123456)
        assert fetched is not None
        assert fetched.id == user.id

    def test_link_telegram_duplicate_fails(self):
        u1 = self.manager.create_user("tg1@example.com")
        u2 = self.manager.create_user("tg2@example.com")
        assert self.manager.link_telegram(u1.id, 999)
        assert not self.manager.link_telegram(u2.id, 999)

    # ---- Magic Link Authentication ----

    def test_magic_link_happy_path(self):
        user = self.manager.create_user("magic@example.com")
        token = self.manager.create_magic_link("magic@example.com")
        verified = self.manager.verify_magic_link(token)
        assert verified is not None
        assert verified.id == user.id

    def test_magic_link_invalid_token(self):
        self.manager.create_user("magic2@example.com")
        self.manager.create_magic_link("magic2@example.com")
        assert self.manager.verify_magic_link("totally_invalid_token") is None

    def test_magic_link_used_twice(self):
        self.manager.create_user("once@example.com")
        token = self.manager.create_magic_link("once@example.com")
        assert self.manager.verify_magic_link(token) is not None
        # Second use should fail
        assert self.manager.verify_magic_link(token) is None

    def test_magic_link_expired(self):
        self.manager.create_user("expired@example.com")
        token = self.manager.create_magic_link("expired@example.com", expires_minutes=0)
        # Token expires immediately (0 minutes)
        import time

        time.sleep(0.1)
        assert self.manager.verify_magic_link(token) is None

    def test_magic_link_inactive_user(self):
        user = self.manager.create_user("inactive@example.com")
        self.manager.update_user(user.id, status=self.UserStatus.SUSPENDED)
        token = self.manager.create_magic_link("inactive@example.com")
        assert self.manager.verify_magic_link(token) is None

    # ---- Session Authentication ----

    def test_create_session_returns_token_string(self):
        user = self.manager.create_user("sess@example.com")
        token = self.manager.create_session(user)
        assert isinstance(token, str)
        assert len(token) > 16

    def test_create_session_requires_user_object(self):
        """create_session takes a User object, not a string user_id."""
        user = self.manager.create_user("contract@example.com")
        # Passing user.id (a string) should fail — it needs user.id attribute
        with pytest.raises(AttributeError):
            self.manager.create_session(user.id)

    def test_authenticate_session_happy_path(self):
        user = self.manager.create_user("auth@example.com")
        token = self.manager.create_session(user)
        authed = self.manager.authenticate_session(token)
        assert authed is not None
        assert authed.id == user.id

    def test_authenticate_session_invalid_token(self):
        assert self.manager.authenticate_session("invalid") is None
        assert self.manager.authenticate_session("") is None
        assert self.manager.authenticate_session(None) is None

    def test_authenticate_session_expired(self):
        user = self.manager.create_user("expses@example.com")
        token = self.manager.create_session(user, expires_hours=0)
        import time

        time.sleep(0.1)
        assert self.manager.authenticate_session(token) is None

    def test_invalidate_session(self):
        user = self.manager.create_user("invses@example.com")
        token = self.manager.create_session(user)
        self.manager.invalidate_session(token)
        assert self.manager.authenticate_session(token) is None

    def test_invalidate_all_sessions(self):
        user = self.manager.create_user("allses@example.com")
        t1 = self.manager.create_session(user)
        t2 = self.manager.create_session(user)
        self.manager.invalidate_all_sessions(user.id)
        assert self.manager.authenticate_session(t1) is None
        assert self.manager.authenticate_session(t2) is None

    # ---- Invitations ----

    def test_create_invitation(self):
        admin = self.manager.create_user("admin@example.com", role=self.UserRole.ADMIN)
        token = self.manager.create_invitation(
            "newbie@example.com", self.UserRole.STAFF, invited_by=admin.id
        )
        assert isinstance(token, str)
        assert len(token) > 16

    def test_invitation_duplicate_email_fails(self):
        self.manager.create_user("exists@example.com")
        with pytest.raises(ValueError, match="already exists"):
            self.manager.create_invitation("exists@example.com")

    def test_get_invitation_by_token(self):
        token = self.manager.create_invitation("inv@example.com")
        inv = self.manager.get_invitation_by_token(token)
        assert inv is not None
        assert inv["email"] == "inv@example.com"

    def test_get_invitation_by_invalid_token(self):
        self.manager.create_invitation("inv2@example.com")
        assert self.manager.get_invitation_by_token("wrong_token") is None

    def test_invitation_acceptance_via_magic_link(self):
        """Full invitation acceptance flow: invite → magic link → user created."""
        self.manager.create_invitation("flow@example.com", self.UserRole.STAFF)
        # Normally accept_invite route creates a magic link for the invited email.
        # verify_magic_link calls _accept_invitation which creates the user.
        magic_token = self.manager.create_magic_link("flow@example.com")
        user = self.manager.verify_magic_link(magic_token)
        assert user is not None
        assert user.email == "flow@example.com"
        assert user.role == self.UserRole.STAFF
        assert user.status == self.UserStatus.ACTIVE


class TestTokenRateLimiting:
    """Tests for magic link / invitation token rate limiting."""

    @pytest.fixture(autouse=True)
    def setup(self):
        from familiar.core.users import (
            _TOKEN_MAX_ATTEMPTS,
            _check_token_rate_limit,
            _record_token_attempt,
            _token_attempts,
        )

        _token_attempts.clear()
        self._check = _check_token_rate_limit
        self._record = _record_token_attempt
        self._attempts = _token_attempts
        self._max = _TOKEN_MAX_ATTEMPTS

    def test_first_attempt_allowed(self):
        allowed, msg = self._check("test_key")
        assert allowed is True
        assert msg is None

    def test_failed_attempts_tracked(self):
        for _ in range(5):
            self._record("key", success=False)
        assert self._attempts["key"]["count"] == 5
        # Not yet locked
        allowed, _ = self._check("key")
        assert allowed is True

    def test_lockout_after_max_attempts(self):
        for _ in range(self._max):
            self._record("lockme", success=False)
        allowed, msg = self._check("lockme")
        assert allowed is False
        assert "Too many failed attempts" in msg

    def test_success_clears_record(self):
        for _ in range(5):
            self._record("clearme", success=False)
        self._record("clearme", success=True)
        assert "clearme" not in self._attempts

    def test_lockout_expires(self):
        for _ in range(self._max):
            self._record("expire", success=False)
        # Manually set lockout to the past
        self._attempts["expire"]["locked_until"] = datetime.now(timezone.utc) - timedelta(minutes=1)
        allowed, _ = self._check("expire")
        assert allowed is True


class TestConstantTimeComparison:
    """Tests that token verification uses hmac.compare_digest."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        from familiar.core.users import UserManager, _token_attempts

        _token_attempts.clear()
        self.manager = UserManager(db_path=tmp_path / "hmac_test.db")

    def test_magic_link_uses_hmac(self):
        """Verify hmac.compare_digest is called during magic link verification."""
        self.manager.create_user("hmac@example.com")
        token = self.manager.create_magic_link("hmac@example.com")

        with patch("familiar.core.users.hmac.compare_digest", return_value=True) as mock_hmac:
            self.manager.verify_magic_link(token)
            assert mock_hmac.called

    def test_invitation_uses_hmac(self):
        """Verify hmac.compare_digest is called during invitation token lookup."""
        token = self.manager.create_invitation("hmac_inv@example.com")

        with patch("familiar.core.users.hmac.compare_digest", return_value=True) as mock_hmac:
            self.manager.get_invitation_by_token(token)
            assert mock_hmac.called


# ============================================================
# 2. ADMIN TESTS
# ============================================================


@_skip_no_flask
class TestAdminDecorators:
    """Tests for admin_required and login_required decorators."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        from familiar.admin import admin_bp, app
        from familiar.core.users import UserManager, UserRole, UserStatus

        self.db_path = tmp_path / "admin_test.db"
        self.manager = UserManager(db_path=self.db_path)

        # Register blueprint if not already
        if "admin" not in [bp.name for bp in app.blueprints.values()]:
            app.register_blueprint(admin_bp, url_prefix="/admin")

        app.config["TESTING"] = True
        app.config["SERVER_NAME"] = "localhost"

        # Patch get_user_manager to use our test manager
        self._patcher = patch("familiar.admin.get_user_manager", return_value=self.manager)
        self._patcher.start()

        self.app = app
        self.client = app.test_client()
        self.UserRole = UserRole
        self.UserStatus = UserStatus

    def teardown_method(self):
        self._patcher.stop()

    def _login_as(self, user):
        """Set up a session for the given user."""
        token = self.manager.create_session(user)
        with self.client.session_transaction() as sess:
            sess["user_token"] = token

    def test_admin_required_blocks_unauthenticated(self):
        with self.app.app_context():
            resp = self.client.get("/admin/")
            assert resp.status_code == 302
            assert "login" in resp.headers["Location"]

    def test_admin_required_blocks_non_admin(self):
        staff = self.manager.create_user("staff@test.com", role=self.UserRole.STAFF)
        self._login_as(staff)
        with self.app.app_context():
            resp = self.client.get("/admin/users")
            assert resp.status_code == 302
            assert "login" in resp.headers["Location"]

    def test_admin_required_allows_admin(self):
        admin = self.manager.create_user("admin@test.com", role=self.UserRole.ADMIN)
        self._login_as(admin)
        with self.app.app_context():
            # Dashboard will try to render template - we just check it doesn't redirect
            resp = self.client.get("/admin/users")
            # If it fails to render template, that's fine - it means auth passed
            assert resp.status_code != 302 or "login" not in resp.headers.get("Location", "")


@_skip_no_flask
class TestAdminLoginRoute:
    """Tests for the admin login flow."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        from familiar.admin import admin_bp, app
        from familiar.core.users import UserManager, UserRole

        self.db_path = tmp_path / "login_test.db"
        self.manager = UserManager(db_path=self.db_path)

        if "admin" not in [bp.name for bp in app.blueprints.values()]:
            app.register_blueprint(admin_bp, url_prefix="/admin")

        app.config["TESTING"] = True
        app.config["SERVER_NAME"] = "localhost"

        self._patcher = patch("familiar.admin.get_user_manager", return_value=self.manager)
        self._patcher.start()

        self.app = app
        self.client = app.test_client()
        self.UserRole = UserRole

    def teardown_method(self):
        self._patcher.stop()

    def test_email_enumeration_prevention_nonexistent(self):
        """Login with non-existent email shows generic message."""
        with self.app.app_context():
            resp = self.client.post("/admin/login", data={"email": "nobody@example.com"})
            # Should show generic "if that email is registered" message
            # and NOT "No account found"
            assert resp.status_code == 200
            data = resp.data.decode()
            assert "No account found" not in data

    def test_email_enumeration_prevention_inactive(self):
        """Login with inactive account shows same generic message."""
        from familiar.core.users import UserStatus

        user = self.manager.create_user("inactive@test.com")
        self.manager.update_user(user.id, status=UserStatus.SUSPENDED)
        with self.app.app_context():
            resp = self.client.post("/admin/login", data={"email": "inactive@test.com"})
            data = resp.data.decode()
            assert "Account is not active" not in data


@_skip_no_flask
class TestAdminAnalyticsRouteAuthorization:
    """Tests that all analytics routes require admin role."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        from familiar.admin import admin_bp, app
        from familiar.core.users import UserManager, UserRole

        self.db_path = tmp_path / "analytics_test.db"
        self.manager = UserManager(db_path=self.db_path)

        if "admin" not in [bp.name for bp in app.blueprints.values()]:
            app.register_blueprint(admin_bp, url_prefix="/admin")

        app.config["TESTING"] = True
        app.config["SERVER_NAME"] = "localhost"

        self._patcher = patch("familiar.admin.get_user_manager", return_value=self.manager)
        self._patcher.start()

        self.app = app
        self.client = app.test_client()
        self.UserRole = UserRole

    def teardown_method(self):
        self._patcher.stop()

    def _login_as(self, user):
        token = self.manager.create_session(user)
        with self.client.session_transaction() as sess:
            sess["user_token"] = token

    @pytest.mark.parametrize(
        "route",
        [
            "/admin/api/stats",
            "/admin/api/activity",
            "/admin/analytics",
            "/admin/analytics/export",
            "/admin/analytics/api/daily-costs",
            "/admin/analytics/api/user-rankings",
        ],
    )
    def test_analytics_route_blocks_non_admin(self, route):
        """Every analytics route must reject non-admin users."""
        staff = self.manager.create_user(
            f"staff_{route.replace('/', '_')}@test.com", role=self.UserRole.STAFF
        )
        self._login_as(staff)
        with self.app.app_context():
            resp = self.client.get(route)
            assert resp.status_code == 302
            assert "login" in resp.headers["Location"]


# ============================================================
# 3. DATA STORE TESTS
# ============================================================


class TestDataStorePathTraversal:
    """Tests for path traversal prevention in DataStore."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        # Patch DATA_DIR, SHARED_DIR, USERS_DIR to use tmp_path
        self.data_dir = tmp_path / "data"
        self.shared_dir = self.data_dir / "shared"
        self.users_dir = self.data_dir / "users"

        self._patches = [
            patch("familiar.core.data_store.DATA_DIR", self.data_dir),
            patch("familiar.core.data_store.SHARED_DIR", self.shared_dir),
            patch("familiar.core.data_store.USERS_DIR", self.users_dir),
        ]
        for p in self._patches:
            p.start()

        from familiar.core.data_store import DataCategory, DataScope, DataStore

        self.DataStore = DataStore
        self.DataScope = DataScope
        self.DataCategory = DataCategory

    def teardown_method(self):
        for p in self._patches:
            p.stop()

    def test_constructor_sanitizes_user_id(self):
        """Path traversal in user_id is sanitized — resolved path stays in USERS_DIR."""
        store = self.DataStore("../../../etc/passwd", "staff")
        # Slashes replaced with underscores, so traversal is neutralized
        assert "/" not in store.user_dir.name
        # Resolved path must be within USERS_DIR
        resolved = store.user_dir.resolve()
        assert str(resolved).startswith(str(self.users_dir.resolve()))

    def test_constructor_sanitizes_slashes(self):
        store = self.DataStore("user/../../root", "staff")
        # Slashes should be sanitized to underscores
        assert "/" not in store.user_dir.name
        resolved = store.user_dir.resolve()
        assert str(resolved).startswith(str(self.users_dir.resolve()))

    def test_get_path_sanitizes_target_user_id(self):
        """_get_path sanitizes target_user_id — no actual path traversal."""
        store = self.DataStore("safe_user", "admin")
        dp = store._get_path(self.DataCategory.MEMORY, target_user_id="../../etc/shadow")
        # The resolved path must stay within USERS_DIR
        resolved = dp.path.resolve()
        assert str(resolved).startswith(str(self.users_dir.resolve()))
        # No literal slash in the user-id directory component
        # The parent of memory.json should be the sanitized user dir
        user_dir_name = dp.path.parent.name
        assert "/" not in user_dir_name

    def test_get_user_data_path_sanitizes(self):
        """Admin get_user_data_path sanitizes — stays within USERS_DIR."""
        store = self.DataStore("admin_user", "admin")
        path = store.get_user_data_path("../../../etc/passwd")
        resolved = path.resolve()
        assert str(resolved).startswith(str(self.users_dir.resolve()))
        assert "/" not in path.name

    def test_get_user_data_path_requires_admin(self):
        store = self.DataStore("staff_user", "staff")
        with pytest.raises(PermissionError):
            store.get_user_data_path("other_user")

    def test_delete_user_data_sanitizes(self):
        """delete_user_data sanitizes the target_user_id."""
        store = self.DataStore("admin_user", "admin")
        # Create a directory for a "safe" user
        safe_dir = self.users_dir / "victim"
        safe_dir.mkdir(parents=True)
        (safe_dir / "data.json").write_text("{}")

        # Try to traverse — should sanitize, not find the traversal path
        result = store.delete_user_data("../../")
        # The sanitized path "___" won't exist, so it returns False
        assert result is False
        # Victim dir should still exist
        assert safe_dir.exists()

    def test_delete_user_data_requires_admin(self):
        store = self.DataStore("staff_user", "staff")
        with pytest.raises(PermissionError):
            store.delete_user_data("other_user")


class TestDataStorePermissions:
    """Tests for DataStore permission checks."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        self.data_dir = tmp_path / "data"
        self._patches = [
            patch("familiar.core.data_store.DATA_DIR", self.data_dir),
            patch("familiar.core.data_store.SHARED_DIR", self.data_dir / "shared"),
            patch("familiar.core.data_store.USERS_DIR", self.data_dir / "users"),
        ]
        for p in self._patches:
            p.start()

        from familiar.core.data_store import DataCategory, DataScope, DataStore

        self.DataStore = DataStore
        self.DataScope = DataScope
        self.DataCategory = DataCategory

    def teardown_method(self):
        for p in self._patches:
            p.stop()

    def test_readonly_cannot_write_tasks(self):
        store = self.DataStore("readonly_user", "readonly")
        with pytest.raises(PermissionError):
            store.write_json(self.DataCategory.TASKS, {"tasks": []})

    def test_staff_can_write_tasks(self):
        store = self.DataStore("staff_user", "staff")
        # Should not raise — but may fail if utils.atomic_write_json has issues
        # We just verify no PermissionError
        try:
            store.write_json(self.DataCategory.TASKS, {"tasks": []})
        except (ImportError, ModuleNotFoundError):
            pass  # atomic_write_json import may fail in test env

    def test_readonly_cannot_read_audit(self):
        store = self.DataStore("readonly_user", "readonly")
        with pytest.raises(PermissionError):
            store.resolve(self.DataCategory.AUDIT, self.DataScope.SYSTEM)

    def test_admin_can_read_audit(self):
        store = self.DataStore("admin_user", "admin")
        dp = store.resolve(self.DataCategory.AUDIT, self.DataScope.SYSTEM)
        assert dp is not None

    def test_staff_cannot_write_templates(self):
        store = self.DataStore("staff_user", "staff")
        with pytest.raises(PermissionError):
            store.write_json(self.DataCategory.TEMPLATES, {}, scope=self.DataScope.SHARED)

    def test_user_data_isolated(self):
        """Different users get different data directories."""
        store_a = self.DataStore("user_a", "staff")
        store_b = self.DataStore("user_b", "staff")
        assert store_a.user_dir != store_b.user_dir
        assert "user_a" in str(store_a.user_dir)
        assert "user_b" in str(store_b.user_dir)


# ============================================================
# 4. AUTH / SSO TESTS
# ============================================================


class TestSSOClient:
    """Tests for SSOClient state management and domain validation."""

    @pytest.fixture(autouse=True)
    def setup(self):
        from familiar.core.auth import SSOClient, SSOConfig, SSOProvider, SSOUser

        self.config = SSOConfig(
            provider=SSOProvider.GOOGLE,
            client_id="test_client_id",
            client_secret="test_secret",
            redirect_uri="http://localhost/callback",
            allowed_domains=["example.org", "partner.org"],
        )
        self.client = SSOClient(self.config)
        self.SSOUser = SSOUser
        self.SSOProvider = SSOProvider

    def test_get_authorization_url_returns_state(self):
        url, state = self.client.get_authorization_url()
        assert state is not None
        assert len(state) > 16
        assert "client_id=test_client_id" in url

    def test_verify_state_valid(self):
        _, state = self.client.get_authorization_url()
        assert self.client.verify_state(state) is True

    def test_verify_state_invalid(self):
        assert self.client.verify_state("nonexistent_state") is False

    def test_verify_state_expired(self):
        _, state = self.client.get_authorization_url()
        # Manually expire the state
        self.client._pending_states[state] = datetime.now(timezone.utc) - timedelta(minutes=1)
        assert self.client.verify_state(state) is False

    def test_validate_domain_allowed(self):
        user = self.SSOUser(sub="123", email="alice@example.org", name="Alice")
        assert self.client.validate_domain(user) is True

    def test_validate_domain_blocked(self):
        user = self.SSOUser(sub="456", email="eve@evil.com", name="Eve")
        assert self.client.validate_domain(user) is False

    def test_validate_domain_no_restrictions(self):
        """When allowed_domains is empty, all domains are allowed."""
        from familiar.core.auth import SSOClient, SSOConfig

        config = SSOConfig(
            provider=self.SSOProvider.GOOGLE,
            client_id="test",
            client_secret="test",
            redirect_uri="http://localhost/callback",
            allowed_domains=[],
        )
        client = SSOClient(config)
        user = self.SSOUser(sub="789", email="anyone@any.domain", name="Anyone")
        assert client.validate_domain(user) is True

    def test_validate_domain_case_insensitive(self):
        user = self.SSOUser(sub="111", email="alice@EXAMPLE.ORG", name="Alice")
        assert self.client.validate_domain(user) is True


class TestSSOProvisionUser:
    """Tests for SSOClient.provision_user."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        from familiar.core.auth import SSOClient, SSOConfig, SSOProvider, SSOUser
        from familiar.core.users import UserManager, UserRole

        self.manager = UserManager(db_path=tmp_path / "sso_test.db")
        self.config = SSOConfig(
            provider=SSOProvider.GOOGLE,
            client_id="test",
            client_secret="test",
            redirect_uri="http://localhost/callback",
            allowed_domains=["example.org"],
            auto_provision=True,
            default_role="staff",
        )
        self.client = SSOClient(self.config)
        self.SSOUser = SSOUser
        self.UserRole = UserRole

    def test_provision_new_user(self):
        sso_user = self.SSOUser(sub="new_sub", email="new@example.org", name="New User")
        user = self.client.provision_user(sso_user, self.manager)
        assert user is not None
        assert user.email == "new@example.org"
        assert user.role == self.UserRole.STAFF

    def test_provision_existing_user_updates(self):
        # Pre-create user
        existing = self.manager.create_user("existing@example.org", "Old Name")
        sso_user = self.SSOUser(sub="exist_sub", email="existing@example.org", name="Updated Name")
        user = self.client.provision_user(sso_user, self.manager)
        assert user.name == "Updated Name"
        assert user.id == existing.id

    def test_provision_blocked_domain(self):
        sso_user = self.SSOUser(sub="bad", email="eve@evil.com", name="Eve")
        with pytest.raises(ValueError, match="domain not allowed"):
            self.client.provision_user(sso_user, self.manager)

    def test_provision_disabled_auto_provision(self):
        from familiar.core.auth import SSOClient

        self.config.auto_provision = False
        client = SSOClient(self.config)
        sso_user = self.SSOUser(sub="noprov", email="noprov@example.org", name="NoProv")
        with pytest.raises(ValueError, match="auto-provision disabled"):
            client.provision_user(sso_user, self.manager)


class TestSSOSessionContract:
    """Tests that SSO callback correctly calls create_session with User object."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        from familiar.core.users import User, UserManager, UserRole

        self.manager = UserManager(db_path=tmp_path / "contract_test.db")
        self.User = User
        self.UserRole = UserRole

    def test_create_session_accepts_user_returns_str(self):
        """create_session(User) returns a str token, not an object."""
        user = self.manager.create_user("contract@example.com")
        token = self.manager.create_session(user)
        assert isinstance(token, str)
        # Token should be usable directly — no .token attribute needed
        assert not hasattr(token, "token")

    def test_create_session_rejects_string_id(self):
        """Passing a string user_id instead of User object should fail."""
        user = self.manager.create_user("reject@example.com")
        with pytest.raises(AttributeError):
            self.manager.create_session(user.id)  # str has no .id attribute


# ============================================================
# 5. SANITIZATION TESTS
# ============================================================


class TestSanitizeUserInput:
    """Tests for sanitize_user_input at all security levels."""

    @pytest.fixture(autouse=True)
    def setup(self):
        from familiar.core.sanitization import (
            SanitizationLevel,
            sanitize_user_input,
        )

        self.sanitize = sanitize_user_input
        self.Level = SanitizationLevel

    def test_none_level_passes_through(self):
        result = self.sanitize("<script>alert('xss')</script>", self.Level.NONE)
        assert "<script>" in result.sanitized
        assert not result.was_modified or "truncat" in str(result.warnings)

    def test_basic_escapes_html(self):
        result = self.sanitize("<script>alert('xss')</script>", self.Level.BASIC)
        assert "<script>" not in result.sanitized
        assert "&lt;script&gt;" in result.sanitized
        assert result.was_modified

    def test_basic_preserves_safe_text(self):
        result = self.sanitize("Hello world! How are you?", self.Level.BASIC)
        assert result.sanitized == "Hello world! How are you?"
        assert not result.was_modified

    def test_null_byte_removal(self):
        result = self.sanitize("hello\x00world", self.Level.NONE)
        assert "\x00" not in result.sanitized
        assert result.sanitized == "helloworld"
        assert "Null bytes" in result.warnings[0]

    def test_max_length_truncation(self):
        long_input = "A" * 20_000
        result = self.sanitize(long_input, self.Level.NONE, max_length=100)
        assert len(result.sanitized) == 100
        assert result.was_modified

    def test_strict_removes_control_chars(self):
        result = self.sanitize("hello\x01\x02\x03world", self.Level.STRICT)
        assert "\x01" not in result.sanitized
        assert "\x02" not in result.sanitized
        assert "helloworld" in result.sanitized.replace("&", "")  # may be html-escaped

    def test_strict_preserves_newlines_by_default(self):
        result = self.sanitize("line1\nline2\ttab", self.Level.STRICT)
        assert "\n" in result.sanitized or "\\n" in result.sanitized or "line1" in result.sanitized

    def test_paranoid_whitelist(self):
        result = self.sanitize("hello_world {special}", self.Level.PARANOID)
        # Underscore and curly braces are NOT in the whitelist
        assert "_" not in result.sanitized
        assert "{" not in result.sanitized
        assert "hello" in result.sanitized

    def test_paranoid_keeps_basic_punctuation(self):
        result = self.sanitize("Hello, world! How's it?", self.Level.PARANOID)
        assert result.sanitized == "Hello, world! How's it?"

    def test_non_string_input_converted(self):
        result = self.sanitize(12345, self.Level.BASIC)
        assert result.sanitized == "12345"


class TestSanitizeForPath:
    """Tests for path traversal prevention."""

    @pytest.fixture(autouse=True)
    def setup(self):
        from familiar.core.sanitization import sanitize_for_path

        self.sanitize = sanitize_for_path

    def test_normal_path_passes(self):
        result = self.sanitize("documents/report.pdf")
        assert result.sanitized == "documents/report.pdf"

    def test_traversal_dot_dot_slash_rejected(self):
        with pytest.raises(ValueError, match="traversal"):
            self.sanitize("../../../etc/passwd")

    def test_traversal_dot_dot_backslash_rejected(self):
        with pytest.raises(ValueError, match="traversal"):
            self.sanitize("..\\..\\windows\\system32")

    def test_traversal_encoded_rejected(self):
        with pytest.raises(ValueError, match="traversal"):
            self.sanitize("%2e%2e/etc/passwd")

    def test_null_byte_removed(self):
        result = self.sanitize("file\x00.txt")
        assert "\x00" not in result.sanitized

    def test_backslash_normalized(self):
        result = self.sanitize("path\\to\\file.txt")
        assert "\\" not in result.sanitized
        assert result.sanitized == "path/to/file.txt"

    def test_double_slash_normalized(self):
        result = self.sanitize("path//to///file.txt")
        assert "//" not in result.sanitized


class TestSanitizeForShell:
    """Tests for shell command validation."""

    @pytest.fixture(autouse=True)
    def setup(self):
        from familiar.core.sanitization import sanitize_for_shell

        self.sanitize = sanitize_for_shell

    def test_safe_command_passes(self):
        result = self.sanitize("ls -la")
        assert result.sanitized == "ls -la"
        assert not result.was_modified

    def test_null_byte_removed(self):
        result = self.sanitize("ls\x00-la")
        assert "\x00" not in result.sanitized

    def test_dangerous_command_rejected(self):
        """Shell injection patterns should be detected and rejected."""
        result = self.sanitize("ls; rm -rf /")
        # The sanitized field should be empty for dangerous commands
        if result.was_modified:
            assert result.sanitized == "" or "Dangerous" in str(result.warnings)


class TestSanitizeForLogging:
    """Tests for log sanitization."""

    @pytest.fixture(autouse=True)
    def setup(self):
        from familiar.core.sanitization import sanitize_for_logging

        self.sanitize = sanitize_for_logging

    def test_truncation(self):
        result = self.sanitize("A" * 5000, max_length=100)
        assert len(result) <= 200  # Includes the truncation message

    def test_control_chars_escaped(self):
        result = self.sanitize("hello\nworld\ttab")
        # Control chars should be escaped in log output
        assert isinstance(result, str)


class TestStripAnsiCodes:
    """Tests for ANSI code removal."""

    @pytest.fixture(autouse=True)
    def setup(self):
        from familiar.core.sanitization import strip_ansi_codes

        self.strip = strip_ansi_codes

    def test_removes_color_codes(self):
        colored = "\x1b[31mRed text\x1b[0m"
        assert self.strip(colored) == "Red text"

    def test_preserves_normal_text(self):
        assert self.strip("Normal text") == "Normal text"

    def test_removes_cursor_codes(self):
        cursor = "\x1b[2JScreen cleared"
        assert self.strip(cursor) == "Screen cleared"


class TestCheckSSRF:
    """Tests for SSRF protection."""

    @pytest.fixture(autouse=True)
    def setup(self):
        from familiar.core.sanitization import check_ssrf

        self.check = check_ssrf

    def test_blocks_file_scheme(self):
        safe, msg = self.check("file:///etc/passwd")
        assert not safe
        assert "scheme" in msg.lower()

    def test_blocks_data_scheme(self):
        safe, msg = self.check("data:text/html,<script>alert(1)</script>")
        assert not safe

    def test_blocks_ftp_scheme(self):
        safe, msg = self.check("ftp://evil.com/malware")
        assert not safe

    def test_blocks_gopher_scheme(self):
        safe, msg = self.check("gopher://evil.com/")
        assert not safe

    def test_blocks_javascript_scheme(self):
        safe, msg = self.check("javascript:alert(1)")
        assert not safe

    def test_blocks_localhost(self):
        safe, msg = self.check("http://127.0.0.1/admin")
        assert not safe
        assert "private" in msg.lower() or "internal" in msg.lower()

    def test_blocks_private_10_range(self):
        safe, msg = self.check("http://10.0.0.1/internal")
        assert not safe

    def test_blocks_private_172_range(self):
        safe, msg = self.check("http://172.16.0.1/internal")
        assert not safe

    def test_blocks_private_192_range(self):
        safe, msg = self.check("http://192.168.1.1/router")
        assert not safe

    def test_blocks_metadata_endpoint(self):
        """169.254.169.254 is the cloud metadata endpoint (AWS, GCP, Azure)."""
        safe, msg = self.check("http://169.254.169.254/latest/meta-data/")
        assert not safe

    def test_rejects_empty_hostname(self):
        safe, msg = self.check("http:///path")
        assert not safe

    def test_rejects_invalid_url(self):
        safe, msg = self.check("")
        assert not safe

    def test_allows_public_https(self):
        """Public HTTPS URLs should be allowed."""
        # Use a well-known public IP (Google DNS)
        with patch("familiar.core.sanitization._socket.getaddrinfo") as mock_gai:
            mock_gai.return_value = [
                (2, 1, 6, "", ("8.8.8.8", 443))  # Public IP
            ]
            safe, msg = self.check("https://example.com/api")
            assert safe


# ============================================================
# AUDIT LOG TESTS (bonus coverage)
# ============================================================


class TestAuditLog:
    """Tests for the audit logging in UserManager."""

    @pytest.fixture(autouse=True)
    def setup(self, tmp_path):
        from familiar.core.users import UserManager, _token_attempts

        _token_attempts.clear()
        self.manager = UserManager(db_path=tmp_path / "audit_test.db")

    def test_user_creation_audited(self):
        self.manager.create_user("audited@example.com")
        logs = self.manager.get_audit_log(action="user_created")
        assert len(logs) >= 1
        assert "audited@example.com" in logs[0]["details"]

    def test_session_creation_audited(self):
        user = self.manager.create_user("sessaudit@example.com")
        self.manager.create_session(user)
        logs = self.manager.get_audit_log(action="session_created")
        assert len(logs) >= 1

    def test_audit_log_filtered_by_user(self):
        u1 = self.manager.create_user("u1@example.com")
        u2 = self.manager.create_user("u2@example.com")
        logs_u1 = self.manager.get_audit_log(user_id=u1.id)
        logs_u2 = self.manager.get_audit_log(user_id=u2.id)
        # Each user's creation should only appear in their own log
        for log in logs_u1:
            assert log["user_id"] == u1.id
        for log in logs_u2:
            assert log["user_id"] == u2.id

    def test_deactivation_audited(self):
        user = self.manager.create_user("deactaudit@example.com")
        self.manager.deactivate_user(user.id)
        logs = self.manager.get_audit_log(action="user_deactivated")
        assert len(logs) >= 1


class TestUserContext:
    """Tests for UserContext permission checking."""

    @pytest.fixture(autouse=True)
    def setup(self):
        from familiar.core.users import User, UserContext, UserRole, UserStatus

        self.admin_user = User(
            id="admin1",
            email="admin@test.com",
            name="Admin",
            role=UserRole.ADMIN,
            status=UserStatus.ACTIVE,
            created_at=datetime.now(timezone.utc),
        )
        self.staff_user = User(
            id="staff1",
            email="staff@test.com",
            name="Staff",
            role=UserRole.STAFF,
            status=UserStatus.ACTIVE,
            created_at=datetime.now(timezone.utc),
        )
        self.readonly_user = User(
            id="ro1",
            email="ro@test.com",
            name="ReadOnly",
            role=UserRole.READONLY,
            status=UserStatus.ACTIVE,
            created_at=datetime.now(timezone.utc),
        )
        self.UserContext = UserContext

    def test_admin_can_manage_users(self):
        ctx = self.UserContext(user=self.admin_user, session_token="tok")
        assert ctx.check_permission("manage_users") is True

    def test_staff_cannot_manage_users(self):
        ctx = self.UserContext(user=self.staff_user, session_token="tok")
        assert ctx.check_permission("manage_users") is False

    def test_staff_can_write(self):
        ctx = self.UserContext(user=self.staff_user, session_token="tok")
        assert ctx.check_permission("create") is True
        assert ctx.check_permission("execute") is True

    def test_readonly_cannot_write(self):
        ctx = self.UserContext(user=self.readonly_user, session_token="tok")
        assert ctx.check_permission("create") is False
        assert ctx.check_permission("delete") is False

    def test_readonly_can_read(self):
        ctx = self.UserContext(user=self.readonly_user, session_token="tok")
        assert ctx.check_permission("read") is True
