# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
User Management System - Phase 1

Multi-user support for medium nonprofit deployments.
Handles user creation, authentication, roles, and data isolation.

Roles:
- ADMIN: Full access, manage users, view all data
- STAFF: Normal access, own data only
- READONLY: View only, no actions

Authentication:
- Magic link (email-based, passwordless)
- SSO (Phase 2: Google, Microsoft, OIDC)
- Channel linking (Telegram, Discord)
"""

import hashlib
import hmac
import json
import logging
import secrets
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional

logger = logging.getLogger(__name__)

# Use centralized paths
try:
    from .paths import DATA_DIR, ensure_dir
except ImportError:
    DATA_DIR = Path.home() / ".familiar" / "data"

    def ensure_dir(p):
        Path(p).mkdir(parents=True, exist_ok=True)
        return p


# Database location
USERS_DB = DATA_DIR / "users.db"


# ============================================================
# TOKEN RATE LIMITING
# ============================================================

_token_attempts: dict = {}  # identifier -> {"count": int, "last": datetime, "locked_until": datetime|None}
_TOKEN_MAX_ATTEMPTS = 10
_TOKEN_LOCKOUT_MINUTES = 15


def _check_token_rate_limit(identifier: str) -> tuple:
    """Check if a token verification source is rate-limited.

    Returns (allowed, message).
    """
    now = datetime.now(timezone.utc)
    record = _token_attempts.get(identifier)
    if not record:
        return True, None
    locked_until = record.get("locked_until")
    if locked_until and now < locked_until:
        remaining = int((locked_until - now).total_seconds() / 60) + 1
        return False, f"Too many failed attempts. Try again in {remaining} minutes."
    if locked_until and now >= locked_until:
        _token_attempts[identifier] = {"count": 0, "last": now, "locked_until": None}
    return True, None


def _record_token_attempt(identifier: str, success: bool):
    """Record a token verification attempt for rate limiting."""
    now = datetime.now(timezone.utc)
    if success:
        _token_attempts.pop(identifier, None)
        return
    record = _token_attempts.get(identifier, {"count": 0, "last": now, "locked_until": None})
    record["count"] += 1
    record["last"] = now
    if record["count"] >= _TOKEN_MAX_ATTEMPTS:
        record["locked_until"] = now + timedelta(minutes=_TOKEN_LOCKOUT_MINUTES)
        logger.warning(
            f"Token verification lockout for {identifier}: {_TOKEN_MAX_ATTEMPTS} failed attempts"
        )
    _token_attempts[identifier] = record


# ============================================================
# MAGIC LINK CREATION RATE LIMITING
# ============================================================

_magic_link_creation: dict = {}  # email -> {"count": int, "window_start": datetime}
_MAGIC_LINK_MAX_PER_HOUR = 5


def _check_magic_link_rate_limit(email: str) -> tuple:
    """Check if magic link creation is rate-limited for this email.

    Returns (allowed, message).
    """
    now = datetime.now(timezone.utc)
    key = email.lower().strip()
    record = _magic_link_creation.get(key)
    if not record:
        return True, None
    window_start = record["window_start"]
    if now - window_start > timedelta(hours=1):
        # Window expired — reset
        _magic_link_creation.pop(key, None)
        return True, None
    if record["count"] >= _MAGIC_LINK_MAX_PER_HOUR:
        return False, "Too many login requests. Please try again later."
    return True, None


def _record_magic_link_creation(email: str):
    """Record a magic link creation for rate limiting."""
    now = datetime.now(timezone.utc)
    key = email.lower().strip()
    record = _magic_link_creation.get(key)
    if not record or now - record["window_start"] > timedelta(hours=1):
        _magic_link_creation[key] = {"count": 1, "window_start": now}
    else:
        record["count"] += 1


# ============================================================
# ENUMS AND MODELS
# ============================================================


class UserRole(str, Enum):
    """User permission levels."""

    ADMIN = "admin"  # Full access, manage users
    STAFF = "staff"  # Normal access, own data
    READONLY = "readonly"  # View only, no actions

    @property
    def can_manage_users(self) -> bool:
        return self == UserRole.ADMIN

    @property
    def can_write(self) -> bool:
        return self in (UserRole.ADMIN, UserRole.STAFF)

    @property
    def can_read(self) -> bool:
        return True  # All roles can read


class UserStatus(str, Enum):
    """User account status."""

    PENDING = "pending"  # Invited, not yet confirmed
    ACTIVE = "active"  # Normal active user
    SUSPENDED = "suspended"  # Temporarily disabled
    DEACTIVATED = "deactivated"  # Permanently disabled


@dataclass
class User:
    """User account model."""

    id: str
    email: str
    name: str
    role: UserRole
    status: UserStatus
    created_at: datetime
    last_active: Optional[datetime] = None
    settings: Dict[str, Any] = field(default_factory=dict)

    # Channel linkings
    telegram_id: Optional[int] = None
    discord_id: Optional[int] = None
    matrix_id: Optional[str] = None

    # SSO (Phase 2)
    sso_provider: Optional[str] = None
    sso_subject: Optional[str] = None

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "email": self.email,
            "name": self.name,
            "role": self.role.value,
            "status": self.status.value,
            "created_at": self.created_at.isoformat(),
            "last_active": self.last_active.isoformat() if self.last_active else None,
            "settings": self.settings,
            "telegram_id": self.telegram_id,
            "discord_id": self.discord_id,
            "matrix_id": self.matrix_id,
            "sso_provider": self.sso_provider,
            "sso_subject": self.sso_subject,
        }

    @classmethod
    def from_dict(cls, data: dict) -> "User":
        return cls(
            id=data["id"],
            email=data["email"],
            name=data["name"],
            role=UserRole(data["role"]),
            status=UserStatus(data["status"]),
            created_at=datetime.fromisoformat(data["created_at"]),
            last_active=datetime.fromisoformat(data["last_active"])
            if data.get("last_active")
            else None,
            settings=data.get("settings", {}),
            telegram_id=data.get("telegram_id"),
            discord_id=data.get("discord_id"),
            matrix_id=data.get("matrix_id"),
            sso_provider=data.get("sso_provider"),
            sso_subject=data.get("sso_subject"),
        )

    @property
    def is_active(self) -> bool:
        return self.status == UserStatus.ACTIVE

    @property
    def data_dir(self) -> Path:
        """User's isolated data directory."""
        return DATA_DIR / "users" / self.id

    @property
    def display_name(self) -> str:
        return self.name or self.email.split("@")[0]


@dataclass
class Session:
    """User session for authentication."""

    id: str
    user_id: str
    token_hash: str
    created_at: datetime
    expires_at: datetime
    ip_address: Optional[str] = None
    user_agent: Optional[str] = None

    @property
    def is_expired(self) -> bool:
        return datetime.now(timezone.utc) > self.expires_at


@dataclass
class MagicLink:
    """Email-based passwordless login link."""

    id: str
    email: str
    token_hash: str
    created_at: datetime
    expires_at: datetime
    used: bool = False

    @property
    def is_expired(self) -> bool:
        return datetime.now(timezone.utc) > self.expires_at or self.used


# ============================================================
# DATABASE SCHEMA
# ============================================================

SCHEMA_TABLES = """
-- Users table
CREATE TABLE IF NOT EXISTS users (
    id TEXT PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    name TEXT,
    role TEXT NOT NULL DEFAULT 'staff',
    status TEXT NOT NULL DEFAULT 'pending',
    created_at TEXT NOT NULL,
    last_active TEXT,
    settings TEXT DEFAULT '{}',
    telegram_id INTEGER UNIQUE,
    discord_id INTEGER UNIQUE,
    matrix_id TEXT UNIQUE,
    sso_provider TEXT,
    sso_subject TEXT
);

-- Sessions table
CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL,
    token_hash TEXT NOT NULL,
    created_at TEXT NOT NULL,
    expires_at TEXT NOT NULL,
    ip_address TEXT,
    user_agent TEXT,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

-- Magic links table
CREATE TABLE IF NOT EXISTS magic_links (
    id TEXT PRIMARY KEY,
    email TEXT NOT NULL,
    token_hash TEXT NOT NULL,
    created_at TEXT NOT NULL,
    expires_at TEXT NOT NULL,
    used INTEGER DEFAULT 0
);

-- Invitations table
CREATE TABLE IF NOT EXISTS invitations (
    id TEXT PRIMARY KEY,
    email TEXT NOT NULL,
    role TEXT NOT NULL DEFAULT 'staff',
    invited_by TEXT,
    token_hash TEXT NOT NULL,
    created_at TEXT NOT NULL,
    expires_at TEXT NOT NULL,
    used INTEGER DEFAULT 0,
    FOREIGN KEY (invited_by) REFERENCES users(id)
);

-- Audit log table
CREATE TABLE IF NOT EXISTS audit_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp TEXT NOT NULL,
    user_id TEXT,
    action TEXT NOT NULL,
    resource TEXT,
    details TEXT,
    ip_address TEXT
);
"""

SCHEMA_INDEXES = """
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_telegram ON users(telegram_id);
CREATE INDEX IF NOT EXISTS idx_users_discord ON users(discord_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_matrix ON users(matrix_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
"""

# Columns that may be missing from older databases.
# Each entry: (column_name, column_type)
# Note: SQLite ALTER TABLE cannot add UNIQUE columns, so uniqueness is
# enforced by the UNIQUE index in SCHEMA_INDEXES instead.
_USER_MIGRATIONS = [
    ("matrix_id", "TEXT"),
]


# ============================================================
# USER MANAGER
# ============================================================


class UserManager:
    """
    Manages user accounts, authentication, and sessions.

    Usage:
        manager = UserManager()

        # Create first admin
        admin = manager.create_user("admin@org.org", "Admin", UserRole.ADMIN)

        # Invite staff
        invite = manager.create_invitation("staff@org.org", UserRole.STAFF, invited_by=admin.id)

        # Authenticate
        user = manager.authenticate_session(token)
    """

    def __init__(self, db_path: Path = None):
        self.db_path = Path(db_path) if db_path else USERS_DB
        ensure_dir(self.db_path.parent)
        self._init_db()

    def _init_db(self):
        """Initialize database schema, migrate existing tables, then create indexes."""
        with self._connect() as conn:
            conn.executescript(SCHEMA_TABLES)
            self._migrate(conn)
            conn.executescript(SCHEMA_INDEXES)

    @staticmethod
    def _migrate(conn):
        """Add columns that may be missing from older databases."""
        existing = {row[1] for row in conn.execute("PRAGMA table_info(users)")}
        for col_name, col_type in _USER_MIGRATIONS:
            if col_name not in existing:
                conn.execute(f"ALTER TABLE users ADD COLUMN {col_name} {col_type}")
                logger.info(f"Migrated users table: added {col_name} column")

    @contextmanager
    def _connect(self):
        """Context manager for database connections."""
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        try:
            yield conn
            conn.commit()
        finally:
            conn.close()

    # ---- User CRUD ----

    def create_user(
        self,
        email: str,
        name: str = None,
        role: UserRole = UserRole.STAFF,
        status: UserStatus = UserStatus.ACTIVE,
    ) -> User:
        """Create a new user account."""
        user_id = secrets.token_urlsafe(16)
        now = datetime.now(timezone.utc)

        user = User(
            id=user_id,
            email=email.lower().strip(),
            name=name or email.split("@")[0],
            role=role,
            status=status,
            created_at=now,
        )

        with self._connect() as conn:
            conn.execute(
                """
                INSERT INTO users (id, email, name, role, status, created_at, settings)
                VALUES (?, ?, ?, ?, ?, ?, ?)
            """,
                (
                    user.id,
                    user.email,
                    user.name,
                    user.role.value,
                    user.status.value,
                    user.created_at.isoformat(),
                    json.dumps(user.settings),
                ),
            )

        # Create user data directory
        ensure_dir(user.data_dir)

        self._audit("user_created", user.id, f"Created user {user.email} with role {role.value}")
        logger.info(f"Created user: {user.email} ({role.value})")

        return user

    def get_user(self, user_id: str) -> Optional[User]:
        """Get user by ID."""
        with self._connect() as conn:
            row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()

        if row:
            return self._row_to_user(row)
        return None

    def get_user_by_email(self, email: str) -> Optional[User]:
        """Get user by email address."""
        with self._connect() as conn:
            row = conn.execute(
                "SELECT * FROM users WHERE email = ?", (email.lower().strip(),)
            ).fetchone()

        if row:
            return self._row_to_user(row)
        return None

    def get_user_by_telegram(self, telegram_id: int) -> Optional[User]:
        """Get user by linked Telegram account."""
        with self._connect() as conn:
            row = conn.execute(
                "SELECT * FROM users WHERE telegram_id = ?", (telegram_id,)
            ).fetchone()

        if row:
            return self._row_to_user(row)
        return None

    def get_user_by_discord(self, discord_id: int) -> Optional[User]:
        """Get user by linked Discord account."""
        with self._connect() as conn:
            row = conn.execute("SELECT * FROM users WHERE discord_id = ?", (discord_id,)).fetchone()

        if row:
            return self._row_to_user(row)
        return None

    def get_user_by_matrix(self, matrix_id: str) -> Optional[User]:
        """Get user by linked Matrix account."""
        with self._connect() as conn:
            row = conn.execute("SELECT * FROM users WHERE matrix_id = ?", (matrix_id,)).fetchone()

        if row:
            return self._row_to_user(row)
        return None

    def list_users(
        self, status: UserStatus = None, role: UserRole = None, limit: int = 100
    ) -> List[User]:
        """List users with optional filters."""
        query = "SELECT * FROM users WHERE 1=1"
        params = []

        if status:
            query += " AND status = ?"
            params.append(status.value)

        if role:
            query += " AND role = ?"
            params.append(role.value)

        query += " ORDER BY created_at DESC LIMIT ?"
        params.append(limit)

        with self._connect() as conn:
            rows = conn.execute(query, params).fetchall()

        return [self._row_to_user(row) for row in rows]

    def update_user(self, user_id: str, **updates) -> Optional[User]:
        """Update user fields."""
        allowed = {"name", "role", "status", "settings", "telegram_id", "discord_id", "matrix_id"}
        updates = {k: v for k, v in updates.items() if k in allowed}

        if not updates:
            return self.get_user(user_id)

        # Convert enums to values
        if "role" in updates and isinstance(updates["role"], UserRole):
            updates["role"] = updates["role"].value
        if "status" in updates and isinstance(updates["status"], UserStatus):
            updates["status"] = updates["status"].value
        if "settings" in updates and isinstance(updates["settings"], dict):
            updates["settings"] = json.dumps(updates["settings"])

        set_clause = ", ".join(f"{k} = ?" for k in updates.keys())
        values = list(updates.values()) + [user_id]

        with self._connect() as conn:
            conn.execute(f"UPDATE users SET {set_clause} WHERE id = ?", values)

        self._audit("user_updated", user_id, f"Updated fields: {list(updates.keys())}")

        return self.get_user(user_id)

    def update_last_active(self, user_id: str):
        """Update user's last active timestamp."""
        now = datetime.now(timezone.utc).isoformat()
        with self._connect() as conn:
            conn.execute("UPDATE users SET last_active = ? WHERE id = ?", (now, user_id))

    def deactivate_user(self, user_id: str) -> bool:
        """Deactivate a user account."""
        with self._connect() as conn:
            conn.execute(
                "UPDATE users SET status = ? WHERE id = ?", (UserStatus.DEACTIVATED.value, user_id)
            )
            # Invalidate all sessions
            conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))

        self._audit("user_deactivated", user_id, "Account deactivated")
        return True

    def link_telegram(self, user_id: str, telegram_id: int) -> bool:
        """Link a Telegram account to a user."""
        success = False
        with self._connect() as conn:
            try:
                conn.execute(
                    "UPDATE users SET telegram_id = ? WHERE id = ?", (telegram_id, user_id)
                )
                success = True
            except sqlite3.IntegrityError:
                logger.warning(f"Telegram ID {telegram_id} already linked to another user")
                return False

        if success:
            self._audit("telegram_linked", user_id, f"Linked Telegram ID: {telegram_id}")
        return success

    def link_discord(self, user_id: str, discord_id: int) -> bool:
        """Link a Discord account to a user."""
        success = False
        with self._connect() as conn:
            try:
                conn.execute("UPDATE users SET discord_id = ? WHERE id = ?", (discord_id, user_id))
                success = True
            except sqlite3.IntegrityError:
                logger.warning(f"Discord ID {discord_id} already linked to another user")
                return False

        if success:
            self._audit("discord_linked", user_id, f"Linked Discord ID: {discord_id}")
        return success

    def link_matrix(self, user_id: str, matrix_id: str) -> bool:
        """Link a Matrix account to a user."""
        success = False
        with self._connect() as conn:
            try:
                conn.execute("UPDATE users SET matrix_id = ? WHERE id = ?", (matrix_id, user_id))
                success = True
            except sqlite3.IntegrityError:
                logger.warning(f"Matrix ID {matrix_id} already linked to another user")
                return False

        if success:
            self._audit("matrix_linked", user_id, f"Linked Matrix ID: {matrix_id}")
        return success

    def _row_to_user(self, row: sqlite3.Row) -> User:
        """Convert database row to User object."""
        return User(
            id=row["id"],
            email=row["email"],
            name=row["name"],
            role=UserRole(row["role"]),
            status=UserStatus(row["status"]),
            created_at=datetime.fromisoformat(row["created_at"]),
            last_active=datetime.fromisoformat(row["last_active"]) if row["last_active"] else None,
            settings=json.loads(row["settings"] or "{}"),
            telegram_id=row["telegram_id"],
            discord_id=row["discord_id"],
            matrix_id=row["matrix_id"] if "matrix_id" in row.keys() else None,
            sso_provider=row["sso_provider"],
            sso_subject=row["sso_subject"],
        )

    # ---- Authentication ----

    def create_magic_link(self, email: str, expires_minutes: int = 15) -> str:
        """
        Create a magic link for passwordless login.
        Returns the token (send this in the email link).

        Raises ValueError if the per-email rate limit is exceeded.
        """
        allowed, msg = _check_magic_link_rate_limit(email)
        if not allowed:
            raise ValueError(msg)

        token = secrets.token_urlsafe(32)
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        now = datetime.now(timezone.utc)
        expires = now + timedelta(minutes=expires_minutes)

        link_id = secrets.token_urlsafe(16)

        with self._connect() as conn:
            conn.execute(
                """
                INSERT INTO magic_links (id, email, token_hash, created_at, expires_at)
                VALUES (?, ?, ?, ?, ?)
            """,
                (link_id, email.lower().strip(), token_hash, now.isoformat(), expires.isoformat()),
            )

        _record_magic_link_creation(email)
        logger.info(f"Created magic link for {email}")
        return token

    def verify_magic_link(self, token: str) -> Optional[User]:
        """
        Verify a magic link token and return the user.
        Creates the user if they don't exist (from invitation).
        """
        token_hash = hashlib.sha256(token.encode()).hexdigest()

        # Rate-limit by token hash prefix to prevent brute-force
        rate_key = f"magic:{token_hash[:8]}"
        allowed, msg = _check_token_rate_limit(rate_key)
        if not allowed:
            logger.warning(f"Magic link rate-limited: {msg}")
            return None

        with self._connect() as conn:
            # Find the magic link
            row = conn.execute(
                """
                SELECT * FROM magic_links
                WHERE token_hash = ? AND used = 0
            """,
                (token_hash,),
            ).fetchone()

            if not row:
                _record_token_attempt(rate_key, success=False)
                logger.warning("Invalid or used magic link")
                return None

            # Constant-time verification (defense-in-depth against SQLite timing)
            if not hmac.compare_digest(row["token_hash"], token_hash):
                _record_token_attempt(rate_key, success=False)
                logger.warning("Invalid or used magic link")
                return None

            # Check expiration
            expires = datetime.fromisoformat(row["expires_at"])
            if datetime.now(timezone.utc) > expires:
                _record_token_attempt(rate_key, success=False)
                logger.warning("Expired magic link")
                return None

            email = row["email"]

            # Mark as used
            conn.execute("UPDATE magic_links SET used = 1 WHERE id = ?", (row["id"],))

        _record_token_attempt(rate_key, success=True)

        # Get or create user
        user = self.get_user_by_email(email)
        if not user:
            # Check for pending invitation
            user = self._accept_invitation(email)

        if not user:
            logger.warning(f"No user or invitation for {email}")
            return None

        if not user.is_active:
            logger.warning(f"User {email} is not active")
            return None

        self._audit("magic_link_verified", user.id, "Login via magic link")
        return user

    def create_session(
        self,
        user: User,
        expires_hours: int = 24 * 7,
        ip_address: str = None,
        user_agent: str = None,
    ) -> str:
        """
        Create a session for an authenticated user.
        Returns the session token.
        """
        token = secrets.token_urlsafe(32)
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        now = datetime.now(timezone.utc)
        expires = now + timedelta(hours=expires_hours)

        session_id = secrets.token_urlsafe(16)

        with self._connect() as conn:
            conn.execute(
                """
                INSERT INTO sessions (id, user_id, token_hash, created_at, expires_at, ip_address, user_agent)
                VALUES (?, ?, ?, ?, ?, ?, ?)
            """,
                (
                    session_id,
                    user.id,
                    token_hash,
                    now.isoformat(),
                    expires.isoformat(),
                    ip_address,
                    user_agent,
                ),
            )

        self.update_last_active(user.id)
        self._audit("session_created", user.id, f"New session from {ip_address}")

        return token

    def authenticate_session(self, token: str) -> Optional[User]:
        """Authenticate a session token and return the user."""
        if not token:
            return None

        token_hash = hashlib.sha256(token.encode()).hexdigest()

        with self._connect() as conn:
            row = conn.execute(
                """
                SELECT u.id, u.email, u.name, u.role, u.status,
                       u.created_at, u.last_active,
                       u.sso_provider, u.sso_subject, u.settings,
                       u.telegram_id, u.discord_id,
                       s.expires_at
                FROM sessions s
                JOIN users u ON s.user_id = u.id
                WHERE s.token_hash = ?
            """,
                (token_hash,),
            ).fetchone()

        if not row:
            return None

        # Check expiration
        expires = datetime.fromisoformat(row["expires_at"])
        if datetime.now(timezone.utc) > expires:
            logger.debug("Session expired")
            return None

        user = self._row_to_user(row)

        if not user.is_active:
            return None

        # Update last active (async-friendly: don't block on this)
        self.update_last_active(user.id)

        return user

    def invalidate_session(self, token: str):
        """Invalidate a session (logout)."""
        token_hash = hashlib.sha256(token.encode()).hexdigest()

        with self._connect() as conn:
            conn.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,))

    def invalidate_all_sessions(self, user_id: str):
        """Invalidate all sessions for a user."""
        with self._connect() as conn:
            conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))

        self._audit("sessions_invalidated", user_id, "All sessions invalidated")

    # ---- Invitations ----

    def create_invitation(
        self,
        email: str,
        role: UserRole = UserRole.STAFF,
        invited_by: str = None,
        expires_days: int = 7,
    ) -> str:
        """
        Create an invitation for a new user.
        Returns the invitation token.
        """
        # Check if user already exists
        if self.get_user_by_email(email):
            raise ValueError(f"User {email} already exists")

        token = secrets.token_urlsafe(32)
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        now = datetime.now(timezone.utc)
        expires = now + timedelta(days=expires_days)

        invite_id = secrets.token_urlsafe(16)

        with self._connect() as conn:
            conn.execute(
                """
                INSERT INTO invitations (id, email, role, invited_by, token_hash, created_at, expires_at)
                VALUES (?, ?, ?, ?, ?, ?, ?)
            """,
                (
                    invite_id,
                    email.lower().strip(),
                    role.value,
                    invited_by,
                    token_hash,
                    now.isoformat(),
                    expires.isoformat(),
                ),
            )

        self._audit("invitation_created", invited_by, f"Invited {email} as {role.value}")
        logger.info(f"Created invitation for {email}")

        return token

    def _accept_invitation(self, email: str) -> Optional[User]:
        """Accept a pending invitation and create the user."""
        with self._connect() as conn:
            row = conn.execute(
                """
                SELECT * FROM invitations
                WHERE email = ? AND used = 0
                ORDER BY created_at DESC LIMIT 1
            """,
                (email.lower().strip(),),
            ).fetchone()

            if not row:
                return None

            # Check expiration
            expires = datetime.fromisoformat(row["expires_at"])
            if datetime.now(timezone.utc) > expires:
                return None

            # Mark as used
            conn.execute("UPDATE invitations SET used = 1 WHERE id = ?", (row["id"],))

        # Create the user
        user = self.create_user(email=email, role=UserRole(row["role"]), status=UserStatus.ACTIVE)

        self._audit("invitation_accepted", user.id, "Accepted invitation")

        return user

    def get_invitation_by_token(self, token: str) -> Optional[dict]:
        """
        Look up a pending invitation by its raw token.
        Returns the invitation row as a dict, or None if not found/expired/used.
        Used by the admin accept_invite route to validate the token before
        creating a magic link for the invited email.
        """
        token_hash = hashlib.sha256(token.encode()).hexdigest()

        # Rate-limit by token hash prefix to prevent brute-force
        rate_key = f"invite:{token_hash[:8]}"
        allowed, msg = _check_token_rate_limit(rate_key)
        if not allowed:
            logger.warning(f"Invitation token rate-limited: {msg}")
            return None

        now = datetime.now(timezone.utc)
        with self._connect() as conn:
            row = conn.execute(
                """
                SELECT * FROM invitations
                WHERE token_hash = ? AND used = 0
            """,
                (token_hash,),
            ).fetchone()
        if not row:
            _record_token_attempt(rate_key, success=False)
            return None

        # Constant-time verification (defense-in-depth against SQLite timing)
        if not hmac.compare_digest(row["token_hash"], token_hash):
            _record_token_attempt(rate_key, success=False)
            return None

        expires = datetime.fromisoformat(row["expires_at"])
        if now > expires:
            _record_token_attempt(rate_key, success=False)
            return None

        _record_token_attempt(rate_key, success=True)
        return dict(row)

    def list_pending_invitations(self) -> List[dict]:
        """List all pending (unused, unexpired) invitations."""
        now = datetime.now(timezone.utc).isoformat()

        with self._connect() as conn:
            rows = conn.execute(
                """
                SELECT * FROM invitations
                WHERE used = 0 AND expires_at > ?
                ORDER BY created_at DESC
            """,
                (now,),
            ).fetchall()

        return [dict(row) for row in rows]

    def revoke_invitation(self, invite_id: str):
        """Revoke a pending invitation."""
        with self._connect() as conn:
            conn.execute("DELETE FROM invitations WHERE id = ?", (invite_id,))

    # ---- Audit Log ----

    def _audit(self, action: str, user_id: str = None, details: str = None, ip_address: str = None):
        """Record an audit log entry."""
        with self._connect() as conn:
            conn.execute(
                """
                INSERT INTO audit_log (timestamp, user_id, action, details, ip_address)
                VALUES (?, ?, ?, ?, ?)
            """,
                (datetime.now(timezone.utc).isoformat(), user_id, action, details, ip_address),
            )

    def get_audit_log(
        self,
        user_id: str = None,
        action: str = None,
        start: datetime = None,
        end: datetime = None,
        limit: int = 100,
    ) -> List[dict]:
        """Query the audit log."""
        query = "SELECT * FROM audit_log WHERE 1=1"
        params = []

        if user_id:
            query += " AND user_id = ?"
            params.append(user_id)

        if action:
            query += " AND action = ?"
            params.append(action)

        if start:
            query += " AND timestamp >= ?"
            params.append(start.isoformat())

        if end:
            query += " AND timestamp <= ?"
            params.append(end.isoformat())

        query += " ORDER BY timestamp DESC LIMIT ?"
        params.append(limit)

        with self._connect() as conn:
            rows = conn.execute(query, params).fetchall()

        return [dict(row) for row in rows]

    # ---- Stats ----

    def get_stats(self) -> dict:
        """Get user statistics for admin dashboard."""
        with self._connect() as conn:
            total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
            active = conn.execute(
                "SELECT COUNT(*) FROM users WHERE status = ?", (UserStatus.ACTIVE.value,)
            ).fetchone()[0]

            by_role = {}
            for role in UserRole:
                count = conn.execute(
                    "SELECT COUNT(*) FROM users WHERE role = ?", (role.value,)
                ).fetchone()[0]
                by_role[role.value] = count

            # Recent activity (last 7 days)
            week_ago = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
            recent = conn.execute(
                "SELECT COUNT(*) FROM users WHERE last_active > ?", (week_ago,)
            ).fetchone()[0]

        return {
            "total_users": total,
            "active_users": active,
            "by_role": by_role,
            "active_last_7_days": recent,
        }


# ============================================================
# SINGLETON INSTANCE
# ============================================================

_manager: Optional[UserManager] = None


def get_user_manager() -> UserManager:
    """Get the global UserManager instance."""
    global _manager
    if _manager is None:
        _manager = UserManager()
    return _manager


# ============================================================
# CONTEXT HELPERS
# ============================================================


@dataclass
class UserContext:
    """
    Request context with authenticated user.
    Passed through the agent and skills for permission checks.
    """

    user: User
    session_token: str
    ip_address: Optional[str] = None

    @property
    def user_id(self) -> str:
        return self.user.id

    @property
    def is_admin(self) -> bool:
        return self.user.role == UserRole.ADMIN

    @property
    def can_write(self) -> bool:
        return self.user.role.can_write

    def check_permission(self, action: str) -> bool:
        """Check if user has permission for an action."""
        write_actions = {"create", "update", "delete", "send", "execute"}
        admin_actions = {"manage_users", "view_all", "system_config"}

        if action in admin_actions:
            return self.is_admin

        if action in write_actions:
            return self.can_write

        return True  # Default allow read


# ============================================================
# CLI UTILITIES
# ============================================================


def setup_first_admin(email: str, name: str = None) -> User:
    """
    Set up the first admin user.
    Called during initial setup.
    """
    manager = get_user_manager()

    # Check if any admins exist
    admins = manager.list_users(role=UserRole.ADMIN)
    if admins:
        raise ValueError("Admin user already exists. Use invite flow for additional admins.")

    user = manager.create_user(
        email=email, name=name, role=UserRole.ADMIN, status=UserStatus.ACTIVE
    )

    logger.info(f"Created first admin: {email}")
    return user


if __name__ == "__main__":
    # Quick test
    import sys

    if len(sys.argv) > 1 and sys.argv[1] == "setup":
        email = input("Admin email: ").strip()
        name = input("Admin name: ").strip() or None

        try:
            user = setup_first_admin(email, name)
            print(f"✅ Created admin: {user.email}")

            # Create magic link for first login
            manager = get_user_manager()
            token = manager.create_magic_link(email)
            print(f"🔗 Login link: /auth/verify?token={token}")
        except ValueError as e:
            print(f"❌ Error: {e}")
    else:
        print("Usage: python -m familiar.core.users setup")
