# 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

"""
Familiar Security Module

Progressive trust, capability-based permissions, budget tracking.

This module adds enterprise-grade security to Familiar while maintaining
the "Moltbot-like" ease of use.

Components:
- TrustLevel: Progressive trust (STRANGER → KNOWN → TRUSTED → OWNER)
- Capability: Fine-grained permissions (22 capabilities)
- SecureSession: Per-user session with trust, caps, budget, audit
- SecurityMode: Presets (paranoid/balanced/permissive)
- Encryption at rest (optional, via FAMILIAR_ENCRYPTION_KEY)
"""

import hmac
import json
import logging
import os
import secrets
import threading
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, Set, Tuple

logger = logging.getLogger(__name__)

DATA_DIR = Path.home() / ".familiar" / "data"
SESSIONS_DIR = DATA_DIR / "sessions"
SESSIONS_DIR.mkdir(parents=True, exist_ok=True)

# ============================================================
# ENCRYPTION SUPPORT
# ============================================================

# Import encryption module (graceful degradation if not available)
try:
    from .encryption import CRYPTOGRAPHY_AVAILABLE, EncryptedStorage, EncryptionError
except ImportError:
    CRYPTOGRAPHY_AVAILABLE = False
    EncryptedStorage = None
    EncryptionError = Exception

# Global encrypted storage instance (initialized lazily)
_encrypted_storage: Optional["EncryptedStorage"] = None
_encryption_enabled: bool = False


def _get_encryption_storage() -> Optional["EncryptedStorage"]:
    """Get or initialize the encrypted storage singleton."""
    global _encrypted_storage, _encryption_enabled

    if _encrypted_storage is not None:
        return _encrypted_storage

    # Check if encryption is configured
    encryption_key = os.environ.get("FAMILIAR_ENCRYPTION_KEY")
    if encryption_key and CRYPTOGRAPHY_AVAILABLE and EncryptedStorage:
        try:
            _encrypted_storage = EncryptedStorage()
            if _encrypted_storage.is_available:
                _encryption_enabled = True
                logger.info("Session encryption enabled")
            else:
                _encrypted_storage = None
                logger.warning("Encryption key set but encryption not available")
        except Exception as e:
            logger.warning(f"Failed to initialize encryption: {e}")
            _encrypted_storage = None

    return _encrypted_storage


def is_encryption_enabled() -> bool:
    """Check if session encryption is enabled."""
    _get_encryption_storage()  # Ensure initialized
    return _encryption_enabled


# ============================================================
# TRUST LEVELS
# ============================================================


class TrustLevel(str, Enum):
    """
    Progressive trust levels - users earn capabilities over time.

    STRANGER: New user, minimal access (time, weather)
    KNOWN: Verified or 10+ positive interactions (reminders, notes, calendar read)
    TRUSTED: 50+ positive interactions (email, web requests with confirmation)
    OWNER: Explicit grant only (full access, still logged)
    """

    STRANGER = "stranger"
    KNOWN = "known"
    TRUSTED = "trusted"
    OWNER = "owner"


# ============================================================
# CAPABILITIES
# ============================================================


class Capability(str, Enum):
    """
    Fine-grained capabilities that can be granted or denied.

    Organized by risk level:
    - Read operations: Low risk, granted early
    - Write operations: Medium risk, need KNOWN trust
    - Sensitive operations: High risk, need TRUSTED + confirmation
    - Dangerous operations: Owner only, always confirm
    """

    # Read operations (low risk)
    READ_TIME = "read:time"
    READ_WEATHER = "read:weather"
    READ_CALENDAR = "read:calendar"
    READ_REMINDERS = "read:reminders"
    READ_NOTES = "read:notes"
    READ_TASKS = "read:tasks"
    READ_EMAIL = "read:email"
    READ_FILES = "read:files"
    READ_MEMORY = "read:memory"

    # Write operations (medium risk)
    WRITE_REMINDERS = "write:reminders"
    WRITE_NOTES = "write:notes"
    WRITE_CALENDAR = "write:calendar"
    WRITE_TASKS = "write:tasks"
    WRITE_MEMORY = "write:memory"
    SEND_NOTIFICATIONS = "send:notifications"

    # Sensitive operations (high risk - require confirmation)
    WRITE_EMAIL = "write:email"
    WRITE_FILES = "write:files"
    HTTP_REQUESTS = "http:requests"
    BROWSER_CONTROL = "browser:control"

    # Dangerous operations (owner only, always confirm)
    SHELL_EXECUTE = "shell:execute"
    SYSTEM_CONTROL = "system:control"


# Default capabilities by trust level
TRUST_CAPABILITIES: Dict[TrustLevel, Set[Capability]] = {
    TrustLevel.STRANGER: {
        Capability.READ_TIME,
        Capability.READ_WEATHER,
    },
    TrustLevel.KNOWN: {
        Capability.READ_TIME,
        Capability.READ_WEATHER,
        Capability.READ_CALENDAR,
        Capability.READ_REMINDERS,
        Capability.READ_NOTES,
        Capability.READ_TASKS,
        Capability.WRITE_REMINDERS,
        Capability.WRITE_NOTES,
        Capability.SEND_NOTIFICATIONS,
    },
    TrustLevel.TRUSTED: {
        Capability.READ_TIME,
        Capability.READ_WEATHER,
        Capability.READ_CALENDAR,
        Capability.READ_REMINDERS,
        Capability.READ_NOTES,
        Capability.READ_TASKS,
        Capability.READ_EMAIL,
        Capability.READ_FILES,
        Capability.READ_MEMORY,
        Capability.WRITE_REMINDERS,
        Capability.WRITE_NOTES,
        Capability.WRITE_CALENDAR,
        Capability.WRITE_TASKS,
        Capability.WRITE_MEMORY,
        Capability.SEND_NOTIFICATIONS,
        Capability.WRITE_EMAIL,  # With confirmation
        Capability.HTTP_REQUESTS,  # With confirmation
    },
    TrustLevel.OWNER: set(Capability),  # All capabilities
}

# Capabilities that always require confirmation
CONFIRMATION_REQUIRED: Set[Capability] = {
    Capability.WRITE_EMAIL,
    Capability.WRITE_FILES,
    Capability.HTTP_REQUESTS,
    Capability.BROWSER_CONTROL,
    Capability.SHELL_EXECUTE,
    Capability.SYSTEM_CONTROL,
}


# ============================================================
# SECURITY MODES
# ============================================================


class SecurityMode(str, Enum):
    """
    Preset security configurations.

    PARANOID: Maximum security, confirm everything, low budget
    BALANCED: Good defaults, confirm sensitive actions
    PERMISSIVE: Trust skills, fewer confirmations, higher budget
    """

    PARANOID = "paranoid"
    BALANCED = "balanced"
    PERMISSIVE = "permissive"


@dataclass
class SecurityConfig:
    """Configuration for a security mode."""

    mode: SecurityMode
    daily_budget: float
    max_iterations: int
    max_cost_per_request: float
    require_confirmation: Set[Capability]
    auto_upgrade_trust: bool

    @classmethod
    def from_mode(cls, mode: SecurityMode) -> "SecurityConfig":
        configs = {
            SecurityMode.PARANOID: cls(
                mode=SecurityMode.PARANOID,
                daily_budget=0.50,
                max_iterations=5,
                max_cost_per_request=0.25,
                require_confirmation=CONFIRMATION_REQUIRED
                | {
                    Capability.WRITE_CALENDAR,
                    Capability.WRITE_TASKS,
                },
                auto_upgrade_trust=False,
            ),
            SecurityMode.BALANCED: cls(
                mode=SecurityMode.BALANCED,
                daily_budget=2.00,
                max_iterations=10,
                max_cost_per_request=1.00,
                require_confirmation=CONFIRMATION_REQUIRED,
                auto_upgrade_trust=True,
            ),
            SecurityMode.PERMISSIVE: cls(
                mode=SecurityMode.PERMISSIVE,
                daily_budget=10.00,
                max_iterations=20,
                max_cost_per_request=5.00,
                require_confirmation={
                    Capability.SHELL_EXECUTE,
                    Capability.SYSTEM_CONTROL,
                },
                auto_upgrade_trust=True,
            ),
        }
        return configs[mode]


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

_SESSION_CONTEXT_MAX_BYTES = 50 * 1024  # 50KB hard cap
_TRIAGE_EMAILS_MAX = 50  # max emails stored per triage run


def _cap_session_context(ctx: Dict) -> Dict:
    """
    Enforce size limits on session_context before serialization.

    Strategy:
    1. Trim triage_emails list to _TRIAGE_EMAILS_MAX entries (keep most recent).
    2. If serialized size still exceeds _SESSION_CONTEXT_MAX_BYTES, drop
       triage_emails entirely (largest key), then drop draft_queue, then
       drop remaining keys oldest-first until under the cap.
    """
    import json as _json

    ctx = dict(ctx)  # shallow copy — don't mutate caller's dict

    # Trim triage list first (cheapest eviction)
    if "triage_emails" in ctx and isinstance(ctx["triage_emails"], list):
        ctx["triage_emails"] = ctx["triage_emails"][-_TRIAGE_EMAILS_MAX:]

    # Check size; progressively drop keys if still over cap
    evict_order = ["draft_queue", "triage_emails", "triage_at", "last_doc_id", "last_event_id"]
    for key in evict_order:
        if len(_json.dumps(ctx, default=str)) <= _SESSION_CONTEXT_MAX_BYTES:
            break
        ctx.pop(key, None)

    return ctx


# ============================================================
# SECURE SESSION
# ============================================================


@dataclass
class SecureSession:
    """
    Per-user session with trust, capabilities, budget, and audit.

    This is the core of Familiar's security model. Each user gets a session
    that tracks their trust level, granted capabilities, spending, and
    all actions taken.
    """

    user_id: str
    channel: str

    # Identity
    session_id: str = field(default_factory=lambda: secrets.token_hex(16))
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    # Trust
    trust_level: TrustLevel = TrustLevel.STRANGER
    trust_score: float = 0.0
    verified_at: Optional[datetime] = None

    # Interaction tracking
    message_count: int = 0
    positive_interactions: int = 0
    negative_interactions: int = 0
    last_interaction: Optional[datetime] = None

    # Capabilities
    _explicit_grants: Set[str] = field(default_factory=set)
    _explicit_denials: Set[str] = field(default_factory=set)

    # Budget
    daily_budget: float = 2.00
    spent_today: float = 0.0
    budget_reset_date: str = field(
        default_factory=lambda: datetime.now(timezone.utc).strftime("%Y-%m-%d")
    )

    # Confirmations
    pending_confirmations: Dict[str, Dict] = field(default_factory=dict)

    # Audit log
    audit_log: List[Dict] = field(default_factory=list)

    # Security config
    security_mode: SecurityMode = SecurityMode.BALANCED

    # Role — persisted so sync survives import failures at load time
    # Values mirror UserRole: "admin", "staff", "readonly", or None
    user_role: Optional[str] = None

    # Per-session context memory (Phase 4 triage follow-up)
    # Schema keys: triage_emails, triage_at, last_doc_id, last_event_id, draft_queue
    session_context: Dict[str, Any] = field(default_factory=dict)

    def __post_init__(self):
        self._check_budget_reset()

    # ---- Capabilities ----

    @property
    def capabilities(self) -> Set[Capability]:
        """Get effective capabilities based on trust + explicit grants/denials."""
        base = TRUST_CAPABILITIES.get(self.trust_level, set()).copy()

        # Add explicit grants
        for cap_str in self._explicit_grants:
            try:
                base.add(Capability(cap_str))
            except ValueError:
                pass

        # Remove explicit denials
        for cap_str in self._explicit_denials:
            try:
                base.discard(Capability(cap_str))
            except ValueError:
                pass

        return base

    def has_capability(self, cap: Capability) -> bool:
        """Check if session has a capability."""
        return cap in self.capabilities

    def grant_capability(self, cap: Capability):
        """Explicitly grant a capability."""
        self._explicit_grants.add(cap.value)
        self._explicit_denials.discard(cap.value)
        self.add_audit("capability_granted", {"capability": cap.value})

    def deny_capability(self, cap: Capability):
        """Explicitly deny a capability."""
        self._explicit_denials.add(cap.value)
        self._explicit_grants.discard(cap.value)
        self.add_audit("capability_denied", {"capability": cap.value})

    def requires_confirmation(self, cap: Capability) -> bool:
        """Check if capability requires confirmation."""
        config = SecurityConfig.from_mode(self.security_mode)
        return cap in config.require_confirmation

    # ---- Session Timeout ----

    def is_expired(self, timeout_minutes: int) -> bool:
        """Check if session has exceeded the given timeout.

        Uses last_interaction if available, otherwise falls back to created_at.
        """
        reference = self.last_interaction or self.created_at
        if reference is None:
            return False
        if isinstance(reference, str):
            reference = datetime.fromisoformat(reference)
        return datetime.now(timezone.utc) - reference > timedelta(minutes=timeout_minutes)

    def touch(self):
        """Update last_interaction timestamp to now."""
        self.last_interaction = datetime.now(timezone.utc)

    # ---- Trust ----

    def record_interaction(self, positive: bool = True):
        """Record an interaction and potentially upgrade trust."""
        self.message_count += 1
        self.last_interaction = datetime.now(timezone.utc)

        if positive:
            self.positive_interactions += 1
            self.trust_score = min(100, self.trust_score + 1)
        else:
            self.negative_interactions += 1
            self.trust_score = max(0, self.trust_score - 2)

        # Auto-upgrade trust
        config = SecurityConfig.from_mode(self.security_mode)
        if config.auto_upgrade_trust:
            self._check_trust_upgrade()

    def _check_trust_upgrade(self):
        """Check if trust level should be upgraded."""
        now = datetime.now(timezone.utc)
        age = now - self.created_at

        if self.trust_level == TrustLevel.STRANGER:
            # Upgrade to KNOWN after verification or 10+ positive (min 24h age)
            if age < timedelta(hours=24):
                return
            if self.verified_at or self.positive_interactions >= 10:
                self.trust_level = TrustLevel.KNOWN
                self.add_audit("trust_upgraded", {"new_level": "known"})

        elif self.trust_level == TrustLevel.KNOWN:
            # Upgrade to TRUSTED after 50+ positive (min 7 days age)
            if age < timedelta(days=7):
                return
            if self.positive_interactions >= 50:
                self.trust_level = TrustLevel.TRUSTED
                self.add_audit("trust_upgraded", {"new_level": "trusted"})

    def set_trust_level(self, level: TrustLevel):
        """Manually set trust level (admin action)."""
        old = self.trust_level
        self.trust_level = level
        self.add_audit("trust_set", {"old": old.value, "new": level.value})

    # ---- Budget ----

    @property
    def remaining_budget(self) -> float:
        """Get remaining daily budget."""
        self._check_budget_reset()
        return max(0, self.daily_budget - self.spent_today)

    def _check_budget_reset(self):
        """Reset budget if new day."""
        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        if self.budget_reset_date != today:
            self.spent_today = 0.0
            self.budget_reset_date = today

    def charge(self, amount: float) -> bool:
        """Charge against daily budget. Returns False if over budget."""
        self._check_budget_reset()

        if self.spent_today + amount > self.daily_budget:
            self.add_audit(
                "budget_exceeded", {"attempted": amount, "remaining": self.remaining_budget}
            )
            return False

        self.spent_today += amount
        return True

    # ---- Confirmations ----

    def request_confirmation(
        self,
        action: str,
        description: str,
        capability: Capability,
        expires_seconds: int = 300,
    ) -> str:
        """Request confirmation for a sensitive action."""
        token = secrets.token_hex(8)

        self.pending_confirmations[token] = {
            "action": action,
            "description": description,
            "capability": capability.value,
            "created_at": datetime.now(timezone.utc).isoformat(),
            "expires_at": (
                datetime.now(timezone.utc) + timedelta(seconds=expires_seconds)
            ).isoformat(),
            "confirmed": False,
        }

        self.add_audit(
            "confirmation_requested",
            {
                "token": token,
                "action": action,
                "capability": capability.value,
            },
        )

        return token

    def confirm_action(self, token: str) -> Optional[Dict]:
        """Confirm a pending action. Returns action details or None."""
        if token not in self.pending_confirmations:
            return None

        conf = self.pending_confirmations[token]

        # Check expiration
        expires = datetime.fromisoformat(conf["expires_at"])
        if datetime.now(timezone.utc) > expires:
            del self.pending_confirmations[token]
            return None

        conf["confirmed"] = True
        self.add_audit("confirmation_confirmed", {"token": token})

        # Remove from pending
        del self.pending_confirmations[token]

        return conf

    # ---- Audit ----

    def add_audit(self, event: str, details: Dict = None):
        """Add an audit log entry."""
        entry = {
            "timestamp": datetime.now(timezone.utc).isoformat(),
            "event": event,
            "details": details or {},
        }

        self.audit_log.append(entry)

        # Keep last 1000 entries
        if len(self.audit_log) > 1000:
            self.audit_log = self.audit_log[-1000:]

    # ---- Serialization ----

    def to_dict(self) -> Dict:
        """Serialize to dictionary."""
        return {
            "user_id": self.user_id,
            "channel": self.channel,
            "session_id": self.session_id,
            "created_at": self.created_at.isoformat(),
            "trust_level": self.trust_level.value,
            "trust_score": self.trust_score,
            "verified_at": self.verified_at.isoformat() if self.verified_at else None,
            "message_count": self.message_count,
            "positive_interactions": self.positive_interactions,
            "negative_interactions": self.negative_interactions,
            "last_interaction": self.last_interaction.isoformat()
            if self.last_interaction
            else None,
            "explicit_grants": list(self._explicit_grants),
            "explicit_denials": list(self._explicit_denials),
            "daily_budget": self.daily_budget,
            "spent_today": self.spent_today,
            "budget_reset_date": self.budget_reset_date,
            "security_mode": self.security_mode.value,
            "audit_log": self.audit_log[-100:],  # Only last 100 for storage
            "user_role": self.user_role,
            # Cap session_context at 50KB; drop oldest triage_emails entries first
            "session_context": _cap_session_context(self.session_context),
        }

    @staticmethod
    def validate_session_data(data: Any) -> Tuple[bool, str]:
        """
        Validate session data structure before deserialization.

        Args:
            data: Data to validate (expected to be dict from JSON)

        Returns:
            (is_valid: bool, error_message: str)
        """
        if not isinstance(data, dict):
            return False, "Session data must be a dictionary"

        # Required fields
        required_fields = {"user_id", "channel"}
        missing = required_fields - set(data.keys())
        if missing:
            return False, f"Missing required fields: {missing}"

        # Type validation for required fields
        if not isinstance(data.get("user_id"), str):
            return False, "user_id must be a string"
        if not isinstance(data.get("channel"), str):
            return False, "channel must be a string"

        # Validate trust level if present
        if "trust_level" in data:
            valid_trust_levels = {t.value for t in TrustLevel}
            if data["trust_level"] not in valid_trust_levels:
                return (
                    False,
                    f"Invalid trust_level: {data.get('trust_level')}. Valid: {valid_trust_levels}",
                )

        # Validate security mode if present
        if "security_mode" in data:
            valid_modes = {m.value for m in SecurityMode}
            if data["security_mode"] not in valid_modes:
                return (
                    False,
                    f"Invalid security_mode: {data.get('security_mode')}. Valid: {valid_modes}",
                )

        # Validate numeric bounds
        if "daily_budget" in data:
            budget = data["daily_budget"]
            if not isinstance(budget, (int, float)):
                return False, "daily_budget must be a number"
            if budget < 0:
                return False, "daily_budget must be non-negative"

        if "spent_today" in data:
            spent = data["spent_today"]
            if not isinstance(spent, (int, float)):
                return False, "spent_today must be a number"
            if spent < 0:
                return False, "spent_today must be non-negative"

        if "trust_score" in data:
            score = data["trust_score"]
            if not isinstance(score, (int, float)):
                return False, "trust_score must be a number"

        # Validate counters
        for counter_field in ["message_count", "positive_interactions", "negative_interactions"]:
            if counter_field in data:
                value = data[counter_field]
                if not isinstance(value, int):
                    return False, f"{counter_field} must be an integer"
                if value < 0:
                    return False, f"{counter_field} must be non-negative"

        # Validate lists
        for list_field in ["explicit_grants", "explicit_denials", "audit_log"]:
            if list_field in data:
                if not isinstance(data[list_field], list):
                    return False, f"{list_field} must be a list"

        # Validate datetime strings if present
        for dt_field in ["created_at", "last_interaction", "verified_at"]:
            if dt_field in data and data[dt_field] is not None:
                try:
                    datetime.fromisoformat(data[dt_field])
                except (ValueError, TypeError):
                    return False, f"{dt_field} must be a valid ISO datetime string"

        return True, ""

    @classmethod
    def from_dict(cls, data: Dict) -> "SecureSession":
        """Deserialize from dictionary."""
        session = cls(
            user_id=data["user_id"],
            channel=data["channel"],
        )
        session.session_id = data.get("session_id", session.session_id)
        session.created_at = (
            datetime.fromisoformat(data["created_at"])
            if data.get("created_at")
            else session.created_at
        )
        session.trust_level = TrustLevel(data.get("trust_level", "stranger"))
        session.trust_score = data.get("trust_score", 0.0)
        session.verified_at = (
            datetime.fromisoformat(data["verified_at"]) if data.get("verified_at") else None
        )
        session.message_count = data.get("message_count", 0)
        session.positive_interactions = data.get("positive_interactions", 0)
        session.negative_interactions = data.get("negative_interactions", 0)
        session.last_interaction = (
            datetime.fromisoformat(data["last_interaction"])
            if data.get("last_interaction")
            else None
        )
        session._explicit_grants = set(data.get("explicit_grants", []))
        session._explicit_denials = set(data.get("explicit_denials", []))
        session.daily_budget = data.get("daily_budget", 2.00)
        session.spent_today = data.get("spent_today", 0.0)
        session.budget_reset_date = data.get("budget_reset_date", "")
        session.security_mode = SecurityMode(data.get("security_mode", "balanced"))
        session.audit_log = data.get("audit_log", [])
        session.user_role = data.get("user_role", None)
        session.session_context = data.get("session_context", {})
        return session

    @staticmethod
    def _sanitize_filename(value: str) -> str:
        """Sanitize a value for safe use in filenames (prevent path traversal)."""
        import re

        return re.sub(r"[^a-zA-Z0-9_\-.]", "_", str(value))

    def save(self):
        """Save session to disk atomically, optionally encrypted."""
        data = json.dumps(self.to_dict(), indent=2)
        safe_channel = self._sanitize_filename(self.channel)
        safe_user_id = self._sanitize_filename(self.user_id)
        filename = f"{safe_channel}_{safe_user_id}.json"

        storage = _get_encryption_storage()
        if storage and storage.is_available:
            # Save encrypted (atomic: write tmp → rename)
            encrypted_path = SESSIONS_DIR / f"{filename}.enc"
            try:
                encrypted_data = storage.encrypt(data)
                tmp_path = encrypted_path.with_suffix(".enc.tmp")
                tmp_path.write_text(encrypted_data)
                tmp_path.chmod(0o600)
                tmp_path.replace(encrypted_path)

                # Remove unencrypted version if it exists (migration)
                unencrypted_path = SESSIONS_DIR / filename
                if unencrypted_path.exists():
                    unencrypted_path.unlink()
                    logger.debug(f"Migrated session to encrypted storage: {filename}")
            except Exception as e:
                logger.error(f"Failed to save encrypted session: {e}")
                # Fall back to unencrypted (atomic)
                path = SESSIONS_DIR / filename
                tmp_path = path.with_suffix(".json.tmp")
                tmp_path.write_text(data)
                tmp_path.replace(path)
        else:
            # Save unencrypted (atomic: write tmp → rename)
            path = SESSIONS_DIR / filename
            tmp_path = path.with_suffix(".json.tmp")
            tmp_path.write_text(data)
            tmp_path.replace(path)

    @classmethod
    def load(cls, user_id: str, channel: str) -> Optional["SecureSession"]:
        """Load session from disk with validation, handling encryption if enabled."""
        safe_channel = cls._sanitize_filename(channel)
        safe_user_id = cls._sanitize_filename(user_id)
        filename = f"{safe_channel}_{safe_user_id}.json"
        encrypted_path = SESSIONS_DIR / f"{filename}.enc"
        unencrypted_path = SESSIONS_DIR / filename

        storage = _get_encryption_storage()

        # Try encrypted file first
        if encrypted_path.exists():
            if storage and storage.is_available:
                try:
                    encrypted_data = encrypted_path.read_text()
                    decrypted_data = storage.decrypt(encrypted_data)
                    data = json.loads(decrypted_data)

                    # Validate before deserializing
                    is_valid, error = cls.validate_session_data(data)
                    if not is_valid:
                        logger.error(f"Invalid session data for {filename}: {error}")
                        return None

                    return cls.from_dict(data)
                except json.JSONDecodeError as e:
                    logger.error(f"Malformed JSON in encrypted session {filename}: {e}")
                    return None
                except Exception as e:
                    logger.error(f"Failed to load encrypted session: {e}")
                    # Don't fall back - encrypted data should stay encrypted
                    return None
            else:
                logger.warning(f"Encrypted session exists but encryption not available: {filename}")
                return None

        # Try unencrypted file
        if unencrypted_path.exists():
            try:
                raw_data = unencrypted_path.read_text()
                data = json.loads(raw_data)

                # Validate before deserializing
                is_valid, error = cls.validate_session_data(data)
                if not is_valid:
                    logger.error(f"Invalid session data for {filename}: {error}")
                    return None

                session = cls.from_dict(data)

                # Auto-migrate to encrypted if encryption is now enabled
                if storage and storage.is_available:
                    logger.info(f"Migrating session to encrypted storage: {filename}")
                    session.save()  # This will save encrypted
                    try:
                        unencrypted_path.unlink()
                        logger.info(f"Removed plaintext session after migration: {filename}")
                    except OSError as e:
                        logger.warning(f"Could not remove plaintext session {filename}: {e}")

                return session
            except json.JSONDecodeError as e:
                logger.error(f"Malformed JSON in session {filename}: {e}")
                return None
            except Exception as e:
                logger.warning(f"Failed to load session: {e}")

        return None


# ============================================================
# SESSION MANAGER
# ============================================================


class SessionManager:
    """
    Manages secure sessions across channels.

    Thread-safe implementation using RLock for nested operations.
    """

    def __init__(self, security_mode: SecurityMode = SecurityMode.BALANCED):
        self.security_mode = security_mode
        self.config = SecurityConfig.from_mode(security_mode)
        self._sessions: Dict[str, SecureSession] = {}
        self._lock = threading.RLock()  # Reentrant lock for nested calls

    def _session_key(self, user_id: str, channel: str) -> str:
        return f"{channel}:{user_id}"

    def get_or_create_session(self, user_id: str, channel: str) -> SecureSession:
        """
        Get existing session or create new one.

        Thread-safe: Uses double-checked locking to avoid holding the
        lock during disk I/O (SecureSession.load) and blocking imports
        (get_preauth_role).
        """
        key = self._session_key(user_id, channel)

        # Fast path: cache hit
        with self._lock:
            if key in self._sessions:
                return self._sessions[key]

        # Slow path: disk I/O + init outside lock
        session = SecureSession.load(user_id, channel)

        if session is None:
            # Create new session
            session = SecureSession(
                user_id=user_id,
                channel=channel,
                daily_budget=self.config.daily_budget,
                security_mode=self.security_mode,
            )
            session.add_audit(
                "session_created",
                {
                    "channel": channel,
                    "security_mode": self.security_mode.value,
                },
            )

            # ── Preauth check ─────────────────────────────────────────
            # Check before STRANGER gate fires. If this user was pre-authorized
            # by the Owner, apply their role immediately so they arrive with
            # full capabilities on their very first message.
            try:
                from familiar.skills.user_management.skill import get_preauth_role

                preauth_role = get_preauth_role(user_id)
            except ImportError:
                preauth_role = None

            if preauth_role:
                _preauth_role_to_trust = {
                    "admin": (TrustLevel.OWNER, 50.0),
                    "staff": (TrustLevel.TRUSTED, 20.0),
                    "readonly": (TrustLevel.KNOWN, 5.0),
                }
                trust, budget = _preauth_role_to_trust.get(preauth_role, (TrustLevel.KNOWN, 5.0))
                session.trust_level = trust
                session.user_role = preauth_role
                session.daily_budget = budget
                # Grant capabilities matching this trust level directly
                session._explicit_grants.update(
                    c.value for c in TRUST_CAPABILITIES.get(trust, set())
                )
                session.add_audit(
                    "preauth_applied",
                    {
                        "role": preauth_role,
                        "trust_level": trust.value,
                    },
                )
                logger.info(
                    f"Preauth applied for user={user_id} channel={channel} "
                    f"role={preauth_role} → trust={trust.value}"
                )

        # Re-check and store under lock
        with self._lock:
            if key in self._sessions:
                return self._sessions[key]
            self._sessions[key] = session

        return session

    def save_session(self, session: SecureSession):
        """Save session to disk (thread-safe)."""
        with self._lock:
            session.save()

    def save_all(self):
        """Save all sessions (thread-safe)."""
        with self._lock:
            for session in self._sessions.values():
                session.save()

    def get_all_sessions(self) -> List[SecureSession]:
        """Get all active sessions (thread-safe)."""
        with self._lock:
            return list(self._sessions.values())

    def invalidate_session(self, user_id: str, channel: str) -> bool:
        """
        Remove a session from cache (e.g., on logout).

        Returns True if session was found and removed.
        """
        key = self._session_key(user_id, channel)
        with self._lock:
            if key in self._sessions:
                del self._sessions[key]
                return True
            return False

    def get_session_count(self) -> int:
        """Get count of cached sessions."""
        with self._lock:
            return len(self._sessions)

    def cleanup_idle_sessions(self, max_idle_days: int = 30) -> int:
        """
        Remove sessions that have been idle for more than max_idle_days.
        Returns count of evicted sessions. Preserves OWNER sessions.
        """
        cutoff = datetime.now(timezone.utc) - timedelta(days=max_idle_days)
        evicted = 0

        with self._lock:
            to_remove = []
            for key, session in self._sessions.items():
                # Never evict OWNER sessions
                if session.trust_level == TrustLevel.OWNER:
                    continue
                # Check last activity
                last_active = session.last_interaction
                if last_active:
                    if isinstance(last_active, str):
                        last_active = datetime.fromisoformat(last_active)
                    if last_active < cutoff:
                        to_remove.append(key)
                elif session.created_at:
                    created = session.created_at
                    if isinstance(created, str):
                        created = datetime.fromisoformat(created)
                    if created < cutoff:
                        to_remove.append(key)

            for key in to_remove:
                del self._sessions[key]
                evicted += 1

        if evicted:
            logger.info(f"Evicted {evicted} idle sessions (>{max_idle_days} days)")
        return evicted


# ============================================================
# SKILL SECURITY
# ============================================================

# Mapping of skills to required capabilities
SKILL_CAPABILITIES: Dict[str, Set[Capability]] = {
    # ── Basic (STRANGER can use) ──────────────────────────────
    "time": {Capability.READ_TIME},
    "weather": {Capability.READ_WEATHER},
    # ── Read skills (KNOWN can use) ───────────────────────────
    "calendar": {Capability.READ_CALENDAR},
    "tasks": {Capability.READ_TASKS},
    "reminders": {Capability.READ_REMINDERS},
    "notes": {Capability.READ_NOTES},
    "memory": {Capability.READ_MEMORY},
    "knowledge": {Capability.READ_MEMORY},
    # ── Write skills (KNOWN can use) ──────────────────────────
    "add_reminder": {Capability.WRITE_REMINDERS},
    "add_note": {Capability.WRITE_NOTES},
    "add_task": {Capability.WRITE_TASKS},
    "remember": {Capability.WRITE_MEMORY},
    # ── Calendar write (KNOWN/TRUSTED) ────────────────────────
    "add_event": {Capability.WRITE_CALENDAR},
    "update_event": {Capability.WRITE_CALENDAR},
    # ── Email (TRUSTED with confirmation) ─────────────────────
    "triage": {Capability.READ_EMAIL},
    "email": {Capability.READ_EMAIL},
    "send_email": {Capability.WRITE_EMAIL},
    "reply_to_email": {Capability.WRITE_EMAIL},  # Phase 4 triage reply
    # ── Web/Browser (TRUSTED with confirmation) ───────────────
    "webapi": {Capability.HTTP_REQUESTS},
    "browser": {Capability.BROWSER_CONTROL},
    # ── System (OWNER only) ───────────────────────────────────
    "shell": {Capability.SHELL_EXECUTE},
    "gpio": {Capability.SYSTEM_CONTROL},
}

# Map skill directories → required capabilities.
# Used by _get_allowed_tools() to filter entire skills, not just
# individual tool names. A tool from skill X requires the capability
# mapped to X here. Unmapped skills default to DENY for STRANGER,
# ALLOW for KNOWN+.
SKILL_DIRECTORY_CAPABILITIES: Dict[str, Set[Capability]] = {
    # STRANGER-safe (no sensitive data)
    "tasks": {Capability.READ_TASKS},
    "knowledge_base": {Capability.READ_NOTES},
    "knowledge": {Capability.READ_MEMORY},
    "planner": {Capability.READ_TASKS},
    "bookkeeping": {Capability.READ_NOTES},
    "nonprofit": {Capability.READ_NOTES},
    "contacts": {Capability.READ_NOTES},
    "encryption": {Capability.SYSTEM_CONTROL},  # exposes key rotation, decrypt
    "triggers": {Capability.WRITE_TASKS},
    "workflows": {Capability.WRITE_TASKS},
    "reports": {Capability.READ_NOTES},
    "audit": {Capability.READ_FILES},  # exposes export_audit_trail, compliance_report
    "sessions": {Capability.SYSTEM_CONTROL},  # exposes revoke_session, create_session
    "triage": {Capability.READ_EMAIL},
    "proactive": {Capability.SEND_NOTIFICATIONS},
    "notifications": {Capability.SEND_NOTIFICATIONS},
    "smart_search": {Capability.READ_MEMORY},
    "rbac": {Capability.SYSTEM_CONTROL},
    "user_management": {Capability.SYSTEM_CONTROL},
    "phi_detection": {Capability.READ_NOTES},
    # Require setup / elevated trust
    "calendar": {Capability.READ_CALENDAR},
    "gdrive": {Capability.READ_FILES},
    "email": {Capability.READ_EMAIL},
    "sms": {Capability.WRITE_EMAIL},
    "browser": {Capability.BROWSER_CONTROL},
    "voice": {Capability.READ_FILES},
    "transcription": {Capability.READ_FILES},
    "documents": {Capability.READ_FILES},
    "filereader": {Capability.READ_FILES},
    "websearch": {Capability.HTTP_REQUESTS},
    "meetings": {Capability.READ_CALENDAR},
    "messaging": {Capability.WRITE_EMAIL},
    "email_server": {Capability.SYSTEM_CONTROL},
    "gpio": {Capability.SYSTEM_CONTROL},
    "marketplace": {Capability.SYSTEM_CONTROL},
    "webapi": {Capability.HTTP_REQUESTS},
    # Self-hosted integrations
    "homeassistant": {Capability.SYSTEM_CONTROL},
    "vaultwarden": {Capability.SYSTEM_CONTROL},
    "joplin": {Capability.WRITE_NOTES},
    "jellyfin": {Capability.HTTP_REQUESTS},
    "gitea": {Capability.HTTP_REQUESTS},
    "pihole": {Capability.SYSTEM_CONTROL},
}


def check_skill_access(skill_name: str, session: SecureSession) -> tuple:
    """
    Check if session can access a skill.

    Returns:
        (allowed: bool, missing_caps: Set[Capability], needs_confirmation: bool)
    """
    required = SKILL_CAPABILITIES.get(skill_name, set())

    if not required:
        # Unknown skill - allow by default
        return True, set(), False

    # Check all required capabilities
    missing = set()
    needs_confirm = False

    for cap in required:
        if not session.has_capability(cap):
            missing.add(cap)
        elif session.requires_confirmation(cap):
            needs_confirm = True

    return len(missing) == 0, missing, needs_confirm


# ============================================================
# EXPORTS
# ============================================================

__all__ = [
    # Enums
    "TrustLevel",
    "Capability",
    "SecurityMode",
    # Classes
    "SecureSession",
    "SessionManager",
    "SecurityConfig",
    # Constants
    "TRUST_CAPABILITIES",
    "CONFIRMATION_REQUIRED",
    "SKILL_CAPABILITIES",
    "SKILL_DIRECTORY_CAPABILITIES",
    # Functions
    "check_skill_access",
    "verify_owner_pin",
    "claim_ownership",
    "check_pin_rate_limit",
    "record_pin_attempt",
]


# ============================================================
# OWNER PIN VERIFICATION (Universal across all channels)
# ============================================================


def verify_owner_pin(candidate_pin: str) -> bool:
    """
    Verify a candidate PIN against the stored owner PIN hash.
    Uses PBKDF2-HMAC-SHA256 with salt (format: salt:hash).
    Falls back to plain SHA-256 for backward compatibility.

    Returns True if the PIN matches, False if wrong or no hash stored.
    """
    import hashlib

    pin_hash = os.environ.get("OWNER_PIN_HASH")
    if not pin_hash:
        return False

    if ":" in pin_hash:
        # PBKDF2 format: salt:hash
        salt, expected_hash = pin_hash.split(":", 1)
        candidate_hash = hashlib.pbkdf2_hmac(
            "sha256", candidate_pin.encode(), salt.encode(), 100_000
        ).hex()
        return hmac.compare_digest(candidate_hash, expected_hash)
    else:
        # Legacy SHA-256 (pre-v1.0.20)
        return hmac.compare_digest(hashlib.sha256(candidate_pin.encode()).hexdigest(), pin_hash)


# PIN attempt tracking for brute force protection
# TODO: In-memory only — clears on restart. Acceptable for self-hosted single-user
# threat model (PIN is for initial ownership claim). File/DB persistence not justified.
_pin_attempts: dict = {}  # channel_user_id -> {"count": int, "last": datetime, "locked_until": datetime|None}
_pin_attempts_lock = threading.Lock()
_PIN_MAX_ATTEMPTS = 5
_PIN_LOCKOUT_MINUTES = 15


def check_pin_rate_limit(user_id: str) -> tuple:
    """
    Check if a user is rate-limited from PIN attempts.

    Returns:
        (allowed: bool, message: str|None)
    """
    now = datetime.now(timezone.utc)
    with _pin_attempts_lock:
        record = _pin_attempts.get(user_id)

        if not record:
            return True, None

        # Check lockout
        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."

        # Reset if lockout expired
        if locked_until and now >= locked_until:
            _pin_attempts[user_id] = {"count": 0, "last": now, "locked_until": None}

        return True, None


def record_pin_attempt(user_id: str, success: bool):
    """Record a PIN attempt for rate limiting."""
    now = datetime.now(timezone.utc)

    with _pin_attempts_lock:
        if success:
            # Clear attempts on success
            _pin_attempts.pop(user_id, None)
            return

        record = _pin_attempts.get(user_id, {"count": 0, "last": now, "locked_until": None})
        record["count"] += 1
        record["last"] = now

        if record["count"] >= _PIN_MAX_ATTEMPTS:
            record["locked_until"] = now + timedelta(minutes=_PIN_LOCKOUT_MINUTES)
            logger.warning(f"PIN lockout for user {user_id}: {_PIN_MAX_ATTEMPTS} failed attempts")

        _pin_attempts[user_id] = record


def claim_ownership(user_id: str, channel_type: str, session_manager: "SessionManager") -> bool:
    """
    Claim ownership for a user across a channel. Writes OWNER_TELEGRAM_ID
    (or equivalent) to .env and promotes the user's session to OWNER.

    Called by any channel after PIN verification succeeds.

    Args:
        user_id: The channel-specific user ID
        channel_type: 'telegram', 'discord', 'signal', etc.
        session_manager: The agent's session manager

    Returns:
        True if ownership claimed successfully
    """
    from pathlib import Path

    # Map channel to env var name
    env_key = f"OWNER_{channel_type.upper()}_ID"
    os.environ[env_key] = str(user_id)

    # Also set the generic OWNER_TELEGRAM_ID for backward compat if telegram
    if channel_type == "telegram":
        os.environ["OWNER_TELEGRAM_ID"] = str(user_id)

    # Sanitize user_id before writing to .env (prevent injection)
    import re

    safe_id = re.sub(r"[^a-zA-Z0-9_\-@.]", "", str(user_id))
    if safe_id != str(user_id):
        logger.warning(f"Sanitized user_id for .env: '{user_id}' -> '{safe_id}'")

    # Persist to .env files (atomic: write tmp → rename)
    for env_path in [Path.cwd() / ".env", Path.home() / ".familiar" / ".env"]:
        if env_path.exists():
            lines = env_path.read_text().splitlines()
            found = False
            new_lines = []
            for line in lines:
                if line.startswith(f"{env_key}="):
                    new_lines.append(f"{env_key}={safe_id}")
                    found = True
                else:
                    new_lines.append(line)
            if not found:
                new_lines.append(f"{env_key}={safe_id}")
            content = "\n".join(new_lines) + "\n"
            tmp_path = env_path.with_suffix(".env.tmp")
            tmp_path.write_text(content)
            os.replace(tmp_path, env_path)

    # Promote session to OWNER
    session = session_manager.get_or_create_session(str(user_id), channel_type)
    session.set_trust_level(TrustLevel.OWNER)
    session.daily_budget = 50.0
    session_manager.save_session(session)

    logger.info(f"Ownership claimed: {channel_type} user {user_id} → OWNER")
    return True
