# 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 Skill

Create, update, list, and deactivate user accounts.
Wraps core/users.py UserManager with JSON fallback.

Dependencies: None (sqlite3 stdlib, with JSON fallback)
"""

import hashlib
import json
import logging
import os
import secrets
from datetime import datetime
from pathlib import Path


def _get_data_dir():
    """Get data directory (tenant-scoped in Reflection, default in standalone)."""
    try:
        from familiar.core import paths

        return paths.DATA_DIR
    except ImportError:
        return Path.home() / ".familiar" / "data"


try:
    from familiar.core.users import UserRole, get_user_manager

    CORE_USERS_AVAILABLE = True
except ImportError:
    CORE_USERS_AVAILABLE = False

try:
    from familiar.core.utils import generate_id
except ImportError:
    generate_id = None

logger = logging.getLogger(__name__)


def _get_data_file():
    """Get data file path (re-evaluates for tenant scoping)."""
    return _get_data_dir() / "users.json"


DATA_FILE = _get_data_file()  # Default for backward compat

_DEFAULT_DATA = {"users": [], "version": 1}


def _gen_id():
    if generate_id:
        return generate_id()
    return secrets.token_hex(6)


def _hash_password(password, salt=None):
    """Hash password with PBKDF2-SHA256."""
    if salt is None:
        salt = os.urandom(16)
    key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600000)
    return salt.hex() + ":" + key.hex()


def _verify_password(password, stored_hash):
    """Verify password against stored PBKDF2 hash."""
    try:
        salt_hex, key_hex = stored_hash.split(":", 1)
        salt = bytes.fromhex(salt_hex)
        expected = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600000)
        return expected.hex() == key_hex
    except (ValueError, TypeError):
        return False


# === JSON Fallback User Store ===


def _load_data():
    if _get_data_file().exists():
        try:
            with open(_get_data_file(), encoding="utf-8") as f:
                return json.load(f)
        except (json.JSONDecodeError, IOError) as e:
            logger.debug("JSON parse error suppressed: %s", e)
    return json.loads(json.dumps(_DEFAULT_DATA))


def _save_data(data):
    _get_data_file().parent.mkdir(parents=True, exist_ok=True)
    with open(_get_data_file(), "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)


def _find_user(db, user_id=None, email=None):
    for u in db.get("users", []):
        if user_id and u["id"] == user_id:
            return u
        if email and u.get("email", "").lower() == email.lower():
            return u
    return None


# === Tool Handlers ===


def create_user(data):
    """Create a new user account."""
    name = data.get("name", "").strip()
    email = data.get("email", "").strip()
    role = data.get("role", "staff").lower()
    password = data.get("password", "").strip()

    if not name or not email:
        return "Please provide name and email."
    if role not in ("admin", "staff", "readonly", "editor", "auditor", "volunteer"):
        return f"Invalid role: {role}. Use: admin, staff, readonly, editor, auditor, volunteer"

    if CORE_USERS_AVAILABLE:
        try:
            mgr = get_user_manager()
            existing = mgr.get_user_by_email(email)
            if existing:
                return f"User with email {email} already exists."
            core_role = (
                UserRole.ADMIN
                if role == "admin"
                else UserRole.READONLY
                if role in ("readonly", "auditor")
                else UserRole.STAFF
            )
            user = mgr.create_user(name=name, email=email, role=core_role)
            return f"✅ User created: {name} ({email}) [{core_role.value}] ID: {user.id}"
        except Exception as e:
            logger.warning(f"Core user creation failed: {e}")

    # JSON fallback
    db = _load_data()
    if _find_user(db, email=email):
        return f"User with email {email} already exists."

    user = {
        "id": _gen_id(),
        "name": name,
        "email": email,
        "role": role,
        "status": "active",
        "password_hash": _hash_password(password) if password else "",
        "created_at": datetime.now().isoformat(),
        "updated_at": datetime.now().isoformat(),
        "last_active": None,
        "metadata": {},
    }
    db["users"].append(user)
    _save_data(db)

    return f"✅ User created: {name} ({email}) [{role}] ID: {user['id']}"


def update_user(data):
    """Update user details (name, email, role, password)."""
    user_id = data.get("user_id", "").strip()
    email_lookup = data.get("email_lookup", "").strip()

    if not user_id and not email_lookup:
        return "Please provide user_id or email_lookup."

    if CORE_USERS_AVAILABLE:
        try:
            mgr = get_user_manager()
            user = mgr.get_user(user_id) if user_id else mgr.get_user_by_email(email_lookup)
            if not user:
                return f"User not found: {user_id or email_lookup}"
            updates = {}
            if data.get("name"):
                updates["name"] = data["name"]
            if data.get("email"):
                updates["email"] = data["email"]
            if data.get("role"):
                role_map = {
                    "admin": UserRole.ADMIN,
                    "staff": UserRole.STAFF,
                    "readonly": UserRole.READONLY,
                }
                if data["role"] in role_map:
                    updates["role"] = role_map[data["role"]]
            updated = mgr.update_user(user.id, **updates)
            if updated:
                return f"✅ User updated: {updated.name} ({updated.email})"
            return "Update failed."
        except Exception as e:
            logger.warning(f"Core user update failed: {e}")

    # JSON fallback
    db = _load_data()
    user = _find_user(db, user_id=user_id) or _find_user(db, email=email_lookup)
    if not user:
        return f"User not found: {user_id or email_lookup}"

    if data.get("name"):
        user["name"] = data["name"]
    if data.get("email"):
        user["email"] = data["email"]
    if data.get("role"):
        user["role"] = data["role"]
    if data.get("password"):
        user["password_hash"] = _hash_password(data["password"])
    user["updated_at"] = datetime.now().isoformat()

    _save_data(db)
    return f"✅ User updated: {user['name']} ({user['email']}) [{user['role']}]"


def list_users(data):
    """List all users with role and status."""
    role_filter = data.get("role", "").lower()
    status_filter = data.get("status", "").lower()

    if CORE_USERS_AVAILABLE:
        try:
            mgr = get_user_manager()
            users = mgr.list_users()
            if role_filter:
                users = [u for u in users if u.role.value == role_filter]
            if status_filter:
                users = [u for u in users if u.status.value == status_filter]
            if not users:
                return "No users found."
            lines = [f"👥 Users ({len(users)}):\n"]
            for u in users:
                emoji = "🟢" if u.is_active else "🔴"
                lines.append(f"  {emoji} {u.name} ({u.email}) [{u.role.value}] ID: {u.id}")
            return "\n".join(lines)
        except Exception as e:
            logger.warning(f"Core user list failed: {e}")

    db = _load_data()
    users = db.get("users", [])
    if role_filter:
        users = [u for u in users if u["role"] == role_filter]
    if status_filter:
        users = [u for u in users if u.get("status") == status_filter]

    if not users:
        return "No users found."

    lines = [f"👥 Users ({len(users)}):\n"]
    for u in users:
        emoji = "🟢" if u.get("status") == "active" else "🔴"
        last = u.get("last_active", "never")
        if last and last != "never":
            last = last[:16]
        lines.append(f"  {emoji} {u['name']} ({u['email']}) [{u['role']}] ID: {u['id']}")
        lines.append(f"     Last active: {last}")

    return "\n".join(lines)


def deactivate_user(data):
    """Deactivate a user account (preserves data, revokes access)."""
    user_id = data.get("user_id", "").strip()
    email = data.get("email", "").strip()

    if not user_id and not email:
        return "Please provide user_id or email."

    if CORE_USERS_AVAILABLE:
        try:
            mgr = get_user_manager()
            user = mgr.get_user(user_id) if user_id else mgr.get_user_by_email(email)
            if not user:
                return f"User not found: {user_id or email}"
            mgr.deactivate_user(user.id)
            mgr.invalidate_all_sessions(user.id)
            return f"✅ User deactivated: {user.name} ({user.email}). All sessions revoked."
        except Exception as e:
            logger.warning(f"Core deactivation failed: {e}")

    db = _load_data()
    user = _find_user(db, user_id=user_id) or _find_user(db, email=email)
    if not user:
        return f"User not found: {user_id or email}"

    user["status"] = "deactivated"
    user["deactivated_at"] = datetime.now().isoformat()
    _save_data(db)

    return f"✅ User deactivated: {user['name']} ({user['email']})"


def user_profile(data):
    """View detailed user profile."""
    user_id = data.get("user_id", "").strip()
    email = data.get("email", "").strip()

    if not user_id and not email:
        return "Please provide user_id or email."

    if CORE_USERS_AVAILABLE:
        try:
            mgr = get_user_manager()
            user = mgr.get_user(user_id) if user_id else mgr.get_user_by_email(email)
            if not user:
                return f"User not found: {user_id or email}"
            lines = [
                f"👤 User Profile: {user.name}",
                f"  Email: {user.email}",
                f"  Role: {user.role.value}",
                f"  Status: {user.status.value}",
                f"  ID: {user.id}",
                f"  Created: {user.created_at}",
            ]
            return "\n".join(lines)
        except Exception as e:
            logger.warning(f"Core profile failed: {e}")

    db = _load_data()
    user = _find_user(db, user_id=user_id) or _find_user(db, email=email)
    if not user:
        return f"User not found: {user_id or email}"

    lines = [
        f"👤 User Profile: {user['name']}",
        f"  Email: {user['email']}",
        f"  Role: {user['role']}",
        f"  Status: {user.get('status', 'active')}",
        f"  ID: {user['id']}",
        f"  Created: {user.get('created_at', '')[:19]}",
        f"  Last active: {user.get('last_active', 'never')}",
    ]
    return "\n".join(lines)


# === Tool Definitions ===

TOOLS = [
    {
        "name": "create_user",
        "description": (
            "Create a new user account with name, email, role, and optional password. "
            "Passwords are hashed with PBKDF2-SHA256 (600,000 iterations) before storage. "
            "Uses the core UserManager when available, falling back to a JSON store. "
            "Duplicate email addresses are rejected."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "Full name"},
                "email": {"type": "string", "description": "Email address"},
                "role": {
                    "type": "string",
                    "enum": ["admin", "staff", "readonly", "editor", "auditor", "volunteer"],
                    "default": "staff",
                    "description": "Role to assign to the new user",
                },
                "password": {
                    "type": "string",
                    "description": "Initial password (hashed with PBKDF2)",
                },
            },
            "required": ["name", "email"],
        },
        "handler": create_user,
        "category": "user_management",
    },
    {
        "name": "update_user",
        "description": (
            "Update an existing user's details including name, email, role, or password. "
            "Look up the user by user_id or email_lookup. "
            "Only provided fields are updated; omitted fields remain unchanged. "
            "Password changes are re-hashed with PBKDF2-SHA256."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "user_id": {"type": "string", "description": "User ID to update"},
                "email_lookup": {
                    "type": "string",
                    "description": "Email address to look up the user by",
                },
                "name": {"type": "string", "description": "New full name"},
                "email": {"type": "string", "description": "New email address"},
                "role": {"type": "string", "description": "New role to assign"},
                "password": {"type": "string", "description": "New password (will be hashed)"},
            },
        },
        "handler": update_user,
        "category": "user_management",
    },
    {
        "name": "list_users",
        "description": (
            "List all user accounts with role, status, and last activity timestamp. "
            "Supports filtering by role and status (active, deactivated). "
            "Active users are shown with a green indicator, deactivated with red. "
            "Uses the core UserManager when available, falling back to the JSON store."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "role": {"type": "string", "description": "Filter by role"},
                "status": {"type": "string", "description": "Filter: active, deactivated"},
            },
        },
        "handler": list_users,
        "category": "user_management",
    },
    {
        "name": "deactivate_user",
        "description": (
            "Deactivate a user account, preserving all data but revoking access and invalidating all active sessions. "
            "Look up the user by user_id or email. "
            "Deactivation is reversible by updating the user's status back to active. "
            "Records the deactivation timestamp for audit purposes."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "user_id": {"type": "string", "description": "User ID to deactivate"},
                "email": {"type": "string", "description": "Email address to look up the user by"},
            },
        },
        "handler": deactivate_user,
        "category": "user_management",
    },
    {
        "name": "user_profile",
        "description": (
            "View a detailed user profile including name, email, role, status, creation date, and last activity. "
            "Look up the user by user_id or email. "
            "Uses the core UserManager when available, falling back to the JSON store."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "user_id": {"type": "string", "description": "User ID to look up"},
                "email": {"type": "string", "description": "Email address to look up the user by"},
            },
        },
        "handler": user_profile,
        "category": "user_management",
    },
    {
        "name": "promote_user",
        "description": "Manually set a user's trust level (STRANGER, KNOWN, TRUSTED, OWNER). Owner-only action that bridges user management with the security trust system.",
        "input_schema": {
            "type": "object",
            "properties": {
                "user_id": {
                    "type": "string",
                    "description": "Telegram user ID or session user ID to promote",
                },
                "trust_level": {
                    "type": "string",
                    "enum": ["stranger", "known", "trusted", "owner"],
                    "description": "Target trust level",
                },
            },
            "required": ["user_id", "trust_level"],
        },
        "handler": lambda data: _promote_user(data),
        "category": "user_management",
    },
    {
        "name": "list_sessions",
        "description": "List all active sessions with trust levels, interaction counts, and budgets. Shows who is connected and at what trust level.",
        "input_schema": {"type": "object", "properties": {}},
        "handler": lambda data: _list_sessions(data),
        "category": "user_management",
    },
    {
        "name": "user_preauth",
        "description": (
            "Grant, revoke, or list pre-authorizations. Pre-authorized users get their role "
            "immediately on first message — no interaction grinding required. OWNER-only. "
            "Examples: grant sarah@gjl.org staff access · revoke 123456789 · list preauth"
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["grant", "revoke", "list"],
                    "description": "grant: add entry · revoke: remove · list: show all",
                },
                "identity": {
                    "type": "string",
                    "description": "Email address or Telegram user ID to grant/revoke",
                },
                "role": {
                    "type": "string",
                    "enum": ["admin", "staff", "readonly"],
                    "description": "Role to grant (required for action=grant)",
                },
            },
            "required": ["action"],
        },
        "handler": lambda data: _user_preauth(data),
        "category": "user_management",
    },
]


def _promote_user(data):
    """Bridge: manually set trust level on a security session."""
    user_id = data.get("user_id")
    target_level = data.get("trust_level", "").upper()

    if not user_id:
        return "Please provide a user_id (Telegram ID or session ID)"

    try:
        from core.security import TrustLevel

        valid_levels = {
            "STRANGER": TrustLevel.STRANGER,
            "KNOWN": TrustLevel.KNOWN,
            "TRUSTED": TrustLevel.TRUSTED,
            "OWNER": TrustLevel.OWNER,
        }

        if target_level not in valid_levels:
            return f"Invalid trust level '{target_level}'. Use: stranger, known, trusted, or owner"

        new_level = valid_levels[target_level]

        # Check caller is OWNER (via context) — MANDATORY
        context = data.get("_context", {})
        caller_session = context.get("session")
        if not caller_session:
            return "⛔ Only OWNER can promote users. No session context available."
        if caller_session.trust_level != TrustLevel.OWNER:
            return (
                "⛔ Only OWNER can promote users. Your current trust level: "
                + caller_session.trust_level.value
            )

        # Find the target session — try telegram channel first, then default
        sessions_mgr = context.get("session_manager")
        if not sessions_mgr:
            # Try to get it from the agent
            agent = context.get("agent")
            if agent and hasattr(agent, "sessions"):
                sessions_mgr = agent.sessions

        if not sessions_mgr:
            return "⚠️ Session manager not available. Promote from CLI or restart."

        # Look for session across channels
        target_session = None
        for channel in ["telegram", "discord", "cli", "default"]:
            sid = f"{user_id}_{channel}"
            all_sessions = sessions_mgr.get_all_sessions()
            for s in all_sessions:
                if s.user_id == str(user_id) or s.session_id == sid:
                    target_session = s
                    break
            if target_session:
                break

        if not target_session:
            # Create a new session for this user
            target_session = sessions_mgr.get_or_create_session(str(user_id), "telegram")

        old_level = target_session.trust_level.value
        target_session.set_trust_level(new_level)

        # If promoting to OWNER, give higher budget
        if new_level == TrustLevel.OWNER:
            target_session.daily_budget = 50.0
        elif new_level == TrustLevel.TRUSTED:
            target_session.daily_budget = 20.0

        sessions_mgr.save_session(target_session)

        return (
            f"✅ User {user_id} promoted: {old_level} → {new_level.value}\n"
            f"   Capabilities: {len(target_session.capabilities)} active\n"
            f"   Daily budget: ${target_session.daily_budget:.2f}"
        )

    except ImportError:
        return "⚠️ Security module not available"
    except Exception as e:
        return f"⚠️ Promotion failed: {e}"


def _list_sessions(data):
    """List all active sessions with trust levels."""
    try:
        context = data.get("_context", {})
        agent = context.get("agent")

        if not agent or not hasattr(agent, "sessions"):
            # Fallback: try session_manager from context
            sessions_mgr = context.get("session_manager")
            if not sessions_mgr:
                return "⚠️ Session manager not available"
        else:
            sessions_mgr = agent.sessions

        all_sessions = sessions_mgr.get_all_sessions()

        if not all_sessions:
            return "No active sessions."

        lines = [f"📋 Active Sessions ({len(all_sessions)}):\n"]
        for s in all_sessions:
            trust = s.trust_level.value.upper()
            interactions = s.positive_interactions
            budget = f"${s.remaining_budget:.2f}" if hasattr(s, "remaining_budget") else "—"
            channel = s.session_id.split("_")[-1] if "_" in s.session_id else "unknown"
            lines.append(
                f"  {s.user_id} [{trust}] via {channel}\n"
                f"    Interactions: {interactions} | Budget: {budget}"
            )

        return "\n".join(lines)

    except Exception as e:
        return f"⚠️ Error listing sessions: {e}"


# ── Preauth helpers (used by Phase 1 user_preauth tool) ───────────────────────


def _normalize_channel_id(channel_id) -> str:
    """
    Normalize a channel user ID to a consistent string key for preauth lookups.

    Telegram sends user IDs as integers from the Bot API but the session system
    stores them as strings. Mixing types causes silent preauth misses.
    Always call this before writing to or reading from the preauth table.

    >>> _normalize_channel_id(123456789)
    '123456789'
    >>> _normalize_channel_id("123456789")
    '123456789'
    >>> _normalize_channel_id("alice@example.com")
    'alice@example.com'
    """
    return (
        str(channel_id).strip().lower() if "@" not in str(channel_id) else str(channel_id).strip()
    )


def _preauth_path() -> Path:
    """Return path to the preauth JSON file."""
    return _get_data_dir() / "preauth.json"


def _load_preauth() -> dict:
    """Load preauth table; returns empty dict on missing/corrupt file."""
    p = _preauth_path()
    if not p.exists():
        return {}
    try:
        raw = json.loads(p.read_text())
        # Normalize all keys on load so the table is always consistent
        return {_normalize_channel_id(k): v for k, v in raw.items()}
    except (json.JSONDecodeError, OSError) as e:
        logger.warning(f"preauth.json unreadable: {e} — treating as empty")
        return {}


def _save_preauth(table: dict) -> None:
    """Persist preauth table atomically (write-then-rename)."""
    p = _preauth_path()
    p.parent.mkdir(parents=True, exist_ok=True)
    tmp = p.with_suffix(".json.tmp")
    # Always normalize keys before saving
    normalized = {_normalize_channel_id(k): v for k, v in table.items()}
    tmp.write_text(json.dumps(normalized, indent=2))
    tmp.replace(p)
    p.chmod(0o600)


def get_preauth_role(channel_id) -> str | None:
    """
    Return the pre-authorized role string for a channel user ID, or None.

    This is called at session-creation time in security.py *before* the
    STRANGER gate fires, so pre-authorized staff get the right trust level
    on their very first message.
    """
    table = _load_preauth()
    entry = table.get(_normalize_channel_id(channel_id))
    if entry:
        return entry.get("role")
    return None


def _user_preauth(data: dict) -> str:
    """
    Grant, revoke, or list pre-authorizations.

    OWNER-only: enforced by checking the caller's session trust level.
    Entries are keyed by normalized channel ID (email or Telegram user ID
    as string) so integer Telegram IDs from the Bot API never cause misses.
    """
    context = data.get("_context", {})
    action = data.get("action", "").strip().lower()
    identity = (data.get("identity") or "").strip()
    role = (data.get("role") or "").strip().lower()

    # ── OWNER gate ────────────────────────────────────────────────────
    caller_session = context.get("session")
    if caller_session:
        try:
            from familiar.core.security import TrustLevel

            if caller_session.trust_level != TrustLevel.OWNER:
                return (
                    "⛔ Only the OWNER can manage pre-authorizations.\n"
                    f"   Your current trust level: {caller_session.trust_level.value}"
                )
        except ImportError:
            return "⛔ Security module not available — cannot verify OWNER status. Denying access."

    if action not in ("grant", "revoke", "list"):
        return "Please specify action: grant, revoke, or list."

    table = _load_preauth()

    # ── LIST ──────────────────────────────────────────────────────────
    if action == "list":
        if not table:
            return "📋 No pre-authorizations on file."
        lines = [f"📋 Pre-authorizations ({len(table)} entries)\n"]
        for key, entry in sorted(table.items()):
            granted_by = entry.get("granted_by", "unknown")
            granted_at = entry.get("granted_at", "")[:10]
            lines.append(
                f"  {entry.get('role', '?'):10s}  {key}  (granted by {granted_by} on {granted_at})"
            )
        return "\n".join(lines)

    # grant / revoke require identity
    if not identity:
        return f"Please provide an identity (email or Telegram user ID) to {action}."

    normalized_id = _normalize_channel_id(identity)

    # ── REVOKE ────────────────────────────────────────────────────────
    if action == "revoke":
        if normalized_id not in table:
            return f"⚠️ No preauth entry found for '{identity}'."
        entry = table.pop(normalized_id)
        _save_preauth(table)

        # Immediately downgrade any live session for this user.
        # Best-effort: session may not exist yet or may be on a different channel.
        try:
            from familiar.core.security import TrustLevel

            sessions_mgr = context.get("session_manager")
            agent = context.get("agent")
            if not sessions_mgr and agent and hasattr(agent, "sessions"):
                sessions_mgr = agent.sessions
            if sessions_mgr:
                for s in sessions_mgr.get_all_sessions():
                    if _normalize_channel_id(s.user_id) == normalized_id:
                        s.trust_level = TrustLevel.STRANGER
                        s.user_role = None
                        s.add_audit(
                            "preauth_revoked",
                            {
                                "revoked_by": str(
                                    caller_session.user_id if caller_session else "owner"
                                )
                            },
                        )
                        sessions_mgr.save_session(s)
                        logger.info(
                            f"Preauth revoked: live session downgraded for user={s.user_id}"
                        )
        except Exception as e:
            logger.warning(f"Could not downgrade live session after revoke: {e}")

        return (
            f"✅ Pre-authorization revoked for '{identity}'.\n"
            f"   Was: {entry.get('role', '?')} · "
            f"Next message from this user will be treated as STRANGER."
        )

    # ── GRANT ─────────────────────────────────────────────────────────
    valid_roles = ("admin", "staff", "readonly")
    if role not in valid_roles:
        return (
            f"Please specify a role to grant: {', '.join(valid_roles)}.\n"
            f"Example: grant {identity} staff"
        )

    caller_id = str(caller_session.user_id) if caller_session else "owner"
    table[normalized_id] = {
        "role": role,
        "identity": identity,  # preserve original for display
        "granted_by": caller_id,
        "granted_at": datetime.utcnow().isoformat() + "Z",
    }
    _save_preauth(table)

    # If this user already has a live session, upgrade it immediately
    # (handles the case where they messaged before the grant was made).
    try:
        from familiar.core.security import TRUST_CAPABILITIES, TrustLevel

        _role_map = {
            "admin": (TrustLevel.OWNER, 50.0),
            "staff": (TrustLevel.TRUSTED, 20.0),
            "readonly": (TrustLevel.KNOWN, 5.0),
        }
        trust, budget = _role_map[role]
        sessions_mgr = context.get("session_manager")
        agent = context.get("agent")
        if not sessions_mgr and agent and hasattr(agent, "sessions"):
            sessions_mgr = agent.sessions
        upgraded = 0
        if sessions_mgr:
            for s in sessions_mgr.get_all_sessions():
                if _normalize_channel_id(s.user_id) == normalized_id:
                    s.trust_level = trust
                    s.user_role = role
                    s.daily_budget = budget
                    s._explicit_grants.update(c.value for c in TRUST_CAPABILITIES.get(trust, set()))
                    s.add_audit("preauth_granted_live", {"role": role, "granted_by": caller_id})
                    sessions_mgr.save_session(s)
                    upgraded += 1
                    logger.info(
                        f"Preauth granted: live session upgraded for user={s.user_id} → {trust.value}"
                    )
    except Exception as e:
        logger.warning(f"Could not upgrade live session after grant: {e}")
        upgraded = 0

    role_labels = {
        "admin": "full access (Owner)",
        "staff": "email, calendar, Drive, triage",
        "readonly": "read-only calendar and tasks",
    }
    live_note = (
        "  Their current session was upgraded immediately."
        if upgraded
        else "  Takes effect on their next message."
    )

    return (
        f"✅ Pre-authorization granted.\n\n"
        f"  Who:    {identity}\n"
        f"  Role:   {role}  ({role_labels.get(role, '')})\n"
        f"  Saved:  ~/.familiar/data/preauth.json\n"
        f"{live_note}"
    )
