"""
Perception Session Receipt — The Contract Container for Embodied Perception.

"When Loom has senses, it is always inside a specific Perception Session
with a signed contract. No session, no senses."

A PerceptionSessionReceipt is the top-level receipt (like GenesisReceipt but
for embodied runs) that pins every act of perception to an explicit contract.
All sensory receipts (Image, Voice, Video, Sensorium) must reference a session_id.

Session Lifecycle:
    IDLE → SESSION_OPEN → SESSION_ACTIVE → SESSION_PAUSED → SESSION_CLOSED
    
    Guardian can force transitions (e.g., auto-close on policy violation).

Hierarchy:
    PerceptionSessionReceipt (session start/end)
        ├── SensoriumReceipt (fused perception events)
        │   ├── VisionObservationReceipt
        │   ├── VoiceObservationReceipt  
        │   └── AX/VLM detections
        ├── VideoSensorReceipt (temporal clips)
        └── ActionReceipt (decisions citing sensory events)

Key Invariant:
    "No Sessionless Perception" — All perception must occur inside an active
    PerceptionSessionReceipt; otherwise the membrane drops the data.

Usage:
    # Start a perception session
    session = PerceptionSessionReceipt(
        session_id="sess_abc123",
        episode_id="ep_xyz789",
        purpose="screen_assistance",
        sensors_enabled=[SensorType.SCREEN_CAPTURE, SensorType.MICROPHONE],
        scope=SessionScope(apps=["Code", "Terminal"], regions=None),
        consent_proof=ConsentProof(method="user_click", receipt_id="consent_123"),
        policy_bundle_id="enterprise_v2",
        trust_profile="partial",
    )
    
    # All sensory receipts reference session_id
    sensorium = SensoriumReceipt(
        session_id=session.session_id,
        screenshot_hash="sha256:abc...",
        ax_tree_hash="sha256:def...",
        ...
    )
"""

from __future__ import annotations

import logging
import uuid
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Set, Union

from pydantic import Field, field_validator, model_validator

from receipts.base import BaseReceipt, Domain
from receipts.compat.pyd import BaseModel, ConfigDictLike


logger = logging.getLogger(__name__)


# =============================================================================
# LINEAGE EMISSION HELPERS
# =============================================================================


def _emit_session_lineage_opened(
    session_id: str,
    subject: str,
    purpose: str,
    trust_profile: str,
    coherence_at_start: Optional[float] = None,
) -> bool:
    """Emit SESSION node to MemoryGraph when session opens.
    
    Non-blocking: logs warning on failure but doesn't raise.
    """
    try:
        from organism.perception_lineage_emitter import get_perception_emitter
        
        emitter = get_perception_emitter()
        return emitter.on_session_opened(
            session_id=session_id,
            subject=subject,
            purpose=purpose,
            trust_profile=trust_profile,
            coherence_at_start=coherence_at_start,
        )
    except ImportError:
        # Lineage emitter not available - silent skip
        return False
    except Exception as e:
        logger.warning(f"[PerceptionSession] Failed to emit session open lineage: {e}")
        return False


def _emit_session_lineage_closed(
    session_id: str,
    coherence_at_end: Optional[float] = None,
    delta_c: Optional[float] = None,
    omega_h_at_end: Optional[float] = None,
    omega_h_status_at_end: Optional[str] = None,
) -> bool:
    """Update SESSION node in MemoryGraph when session closes.
    
    Non-blocking: logs warning on failure but doesn't raise.
    """
    try:
        from organism.perception_lineage_emitter import get_perception_emitter
        
        emitter = get_perception_emitter()
        return emitter.on_session_closed(
            session_id=session_id,
            coherence_at_end=coherence_at_end,
            delta_c=delta_c,
            omega_h_at_end=omega_h_at_end,
            omega_h_status_at_end=omega_h_status_at_end,
        )
    except ImportError:
        return False
    except Exception as e:
        logger.warning(f"[PerceptionSession] Failed to emit session close lineage: {e}")
        return False


# =============================================================================
# ENUMS
# =============================================================================


class SessionState(str, Enum):
    """Perception session lifecycle states."""
    
    IDLE = "idle"              # No active session
    OPEN = "open"              # Session created, sensors initializing
    ACTIVE = "active"          # Sensors on, perception happening
    PAUSED = "paused"          # Temporarily suspended (user/system)
    CLOSED = "closed"          # Session ended normally
    ABORTED = "aborted"        # Session ended by Guardian/emergency


class SensorType(str, Enum):
    """Types of sensors that can be enabled in a session."""
    
    SCREEN_CAPTURE = "screen_capture"
    CAMERA = "camera"
    MICROPHONE = "microphone"
    SYSTEM_AUDIO = "system_audio"
    ACCESSIBILITY = "accessibility"  # AX tree
    KEYBOARD = "keyboard"
    MOUSE = "mouse"
    CLIPBOARD = "clipboard"


class SessionPurpose(str, Enum):
    """Declared purpose/telos for a perception session."""
    
    SCREEN_ASSISTANCE = "screen_assistance"
    CLINICAL_VISIT_NOTE = "clinical_visit_note"
    RPA_AUTOMATION = "rpa_automation"
    VOICE_TRANSCRIPTION = "voice_transcription"
    ACCESSIBILITY_AID = "accessibility_aid"
    SECURITY_AUDIT = "security_audit"
    DEVELOPMENT_DEBUG = "development_debug"
    USER_REQUESTED = "user_requested"
    CUSTOM = "custom"


class TrustProfile(str, Enum):
    """Trust levels for perception specifically."""
    
    MOCK = "mock"              # Synthetic/test data only
    OFFLINE = "offline"        # Local processing, no cloud
    PARTIAL = "partial"        # Limited cloud, aggregated data
    FULL = "full"              # Full perception with cloud
    COURT_GRADE = "court_grade"  # Maximum retention and audit


class ConsentMethod(str, Enum):
    """How consent was obtained."""
    
    USER_CLICK = "user_click"           # Explicit UI action
    VOICE_CONFIRMATION = "voice_confirmation"
    SIGNED_DOCUMENT = "signed_document"
    ORG_POLICY = "org_policy"           # Enterprise policy
    STANDING_CONSENT = "standing_consent"
    IMPLICIT_CONTEXT = "implicit_context"  # e.g., user initiated voice mode


class KillSwitchType(str, Enum):
    """Types of kill switches that can abort a session."""
    
    HOTKEY = "hotkey"           # e.g., Cmd+Shift+Escape
    WAKE_WORD = "wake_word"     # e.g., "Loom, stop"
    REMOTE_ADMIN = "remote_admin"
    GUARDIAN_VETO = "guardian_veto"
    POLICY_VIOLATION = "policy_violation"
    TIMEOUT = "timeout"
    RESOURCE_EXHAUSTION = "resource_exhaustion"
    USER_REQUEST = "user_request"


# =============================================================================
# SUPPORTING MODELS
# =============================================================================


class ConsentProof(BaseModel):
    """Proof of how consent was obtained for this session."""
    
    model_config = ConfigDictLike(frozen=True)
    
    method: ConsentMethod = Field(
        ...,
        description="How consent was obtained"
    )
    receipt_id: Optional[str] = Field(
        default=None,
        description="Receipt ID of the consent grant (if applicable)"
    )
    timestamp: datetime = Field(
        default_factory=lambda: datetime.now(timezone.utc),
        description="When consent was given"
    )
    subject: Optional[str] = Field(
        default=None,
        description="Who gave consent (user ID, org ID)"
    )
    scope_hash: Optional[str] = Field(
        default=None,
        description="Hash of the scope description shown at consent time"
    )
    expiry: Optional[datetime] = Field(
        default=None,
        description="When this consent expires"
    )


class SessionScope(BaseModel):
    """What the session is allowed to perceive."""
    
    model_config = ConfigDictLike(frozen=True)
    
    # App/window restrictions
    apps: Optional[List[str]] = Field(
        default=None,
        description="Allowed applications (None = all)"
    )
    excluded_apps: Optional[List[str]] = Field(
        default=None,
        description="Explicitly excluded applications"
    )
    windows: Optional[List[str]] = Field(
        default=None,
        description="Allowed window titles (patterns)"
    )
    
    # Screen region restrictions
    regions: Optional[List[Dict[str, int]]] = Field(
        default=None,
        description="Allowed screen regions [{x, y, width, height}]"
    )
    displays: Optional[List[int]] = Field(
        default=None,
        description="Allowed display indices (None = all)"
    )
    
    # Content restrictions
    exclude_passwords: bool = Field(
        default=True,
        description="Mask password fields"
    )
    exclude_pii_fields: bool = Field(
        default=True,
        description="Mask PII-sensitive fields"
    )
    exclude_financial: bool = Field(
        default=False,
        description="Mask financial data"
    )
    
    # Audio restrictions
    audio_apps: Optional[List[str]] = Field(
        default=None,
        description="Apps allowed for audio capture"
    )
    voice_only: bool = Field(
        default=True,
        description="Only capture voice, not system audio"
    )


class KillSwitch(BaseModel):
    """A kill switch that can abort the session."""
    
    model_config = ConfigDictLike(frozen=True)
    
    switch_type: KillSwitchType = Field(
        ...,
        description="Type of kill switch"
    )
    trigger: str = Field(
        ...,
        description="What triggers this switch (hotkey combo, word, etc.)"
    )
    priority: int = Field(
        default=0,
        ge=0,
        le=100,
        description="Priority (higher = faster response)"
    )
    enabled: bool = Field(
        default=True,
        description="Whether this switch is active"
    )


class SessionTransition(BaseModel):
    """Record of a state transition in the session."""
    
    model_config = ConfigDictLike(frozen=True)
    
    from_state: SessionState = Field(..., description="Previous state")
    to_state: SessionState = Field(..., description="New state")
    timestamp: datetime = Field(
        default_factory=lambda: datetime.now(timezone.utc)
    )
    reason: str = Field(..., description="Why the transition occurred")
    triggered_by: str = Field(
        default="system",
        description="What/who triggered the transition"
    )
    receipt_id: Optional[str] = Field(
        default=None,
        description="Related receipt (e.g., Guardian veto)"
    )


# =============================================================================
# PERCEPTION SESSION RECEIPT
# =============================================================================


class PerceptionSessionReceipt(BaseReceipt):
    """Top-level contract for an embodied perception session.
    
    This is the container that all sensory perception hangs from.
    No session = no sensors. Every VisionReceipt, VoiceReceipt, 
    SensoriumReceipt, etc. must reference a session_id.
    
    Constitutional Invariant:
        "No Sessionless Perception" — The membrane drops any sensory
        data that doesn't reference an active PerceptionSessionReceipt.
    
    Lifecycle:
        IDLE → OPEN → ACTIVE → PAUSED → CLOSED
        (Guardian can force ABORTED at any time)
    """
    
    # Override receipt_id with auto-generation
    receipt_id: str = Field(
        default_factory=lambda: f"ps_{uuid.uuid4().hex}",
        description="Unique receipt identifier"
    )
    receipt_type: Literal["PerceptionSessionReceipt"] = Field(
        default="PerceptionSessionReceipt",
        frozen=True
    )
    domain: str = Field(default="perception")
    
    # ===== SESSION IDENTITY =====
    session_id: str = Field(
        default_factory=lambda: f"sess_{uuid.uuid4().hex}",
        description="Unique session identifier"
    )
    episode_id: Optional[str] = Field(
        default=None,
        description="Episode this session belongs to (ties into Episode-first architecture)"
    )
    
    # ===== SUBJECT & PURPOSE =====
    subject: str = Field(
        ...,
        description="Who/what this session is for (user_id, org_id, tenant)"
    )
    purpose: SessionPurpose = Field(
        ...,
        description="Declared purpose/telos for this session"
    )
    purpose_description: Optional[str] = Field(
        default=None,
        description="Human-readable description of purpose"
    )
    
    # ===== SENSORS & SCOPE =====
    sensors_enabled: List[SensorType] = Field(
        ...,
        description="Which sensors are allowed in this session"
    )
    scope: SessionScope = Field(
        ...,
        description="What the session is allowed to perceive"
    )
    
    # ===== POLICY & CONSENT =====
    policy_bundle_id: str = Field(
        ...,
        description="Which Guardian/privacy/clinical profiles apply"
    )
    consent_proof: ConsentProof = Field(
        ...,
        description="How consent was obtained"
    )
    trust_profile: TrustProfile = Field(
        default=TrustProfile.PARTIAL,
        description="Trust level for this perception session"
    )
    
    # ===== KILL SWITCHES =====
    kill_switches: List[KillSwitch] = Field(
        default_factory=list,
        description="What can abort this session"
    )
    
    # ===== LIFECYCLE =====
    state: SessionState = Field(
        default=SessionState.OPEN,
        description="Current session state"
    )
    transitions: List[SessionTransition] = Field(
        default_factory=list,
        description="History of state transitions"
    )
    
    # ===== TIMING =====
    start_time: datetime = Field(
        default_factory=lambda: datetime.now(timezone.utc),
        description="When session started"
    )
    end_time: Optional[datetime] = Field(
        default=None,
        description="When session ended (None if still active)"
    )
    max_duration_seconds: Optional[int] = Field(
        default=None,
        description="Maximum allowed session duration"
    )
    
    # ===== STATISTICS (updated as session runs) =====
    observation_count: int = Field(
        default=0,
        ge=0,
        description="Number of observations in this session"
    )
    action_count: int = Field(
        default=0,
        ge=0,
        description="Number of actions taken in this session"
    )
    veto_count: int = Field(
        default=0,
        ge=0,
        description="Number of Guardian vetoes in this session"
    )
    
    # ===== GATE HINTS (COHERENCE) =====
    coherence_at_start: Optional[float] = Field(
        default=None,
        description="Coherence score when session started"
    )
    coherence_at_end: Optional[float] = Field(
        default=None,
        description="Coherence score when session ended"
    )
    delta_c_session: Optional[float] = Field(
        default=None,
        description="Net coherence change over session"
    )

    # ===== HARMONIC / MODE LINKAGE (OPTIONAL) =====
    omega_h_at_end: Optional[float] = Field(
        default=None,
        description="Ωᴴ scalar at session end (if computed)"
    )
    omega_h_status_at_end: Optional[str] = Field(
        default=None,
        description="Ωᴴ band at session end: stable|elevated_tension|critical"
    )
    harmonic_state_receipt_id: Optional[str] = Field(
        default=None,
        description="HarmonicStateReceipt ID summarizing this session (if any)"
    )
    mode_receipt_id: Optional[str] = Field(
        default=None,
        description="ModeReceipt ID documenting startup profile for this session (optional cross-domain link)"
    )
    
    def is_active(self) -> bool:
        """Check if session is currently active (can accept sensory data)."""
        return self.state in (SessionState.OPEN, SessionState.ACTIVE)
    
    def is_closed(self) -> bool:
        """Check if session has ended."""
        return self.state in (SessionState.CLOSED, SessionState.ABORTED)
    
    def can_use_sensor(self, sensor: SensorType) -> bool:
        """Check if a sensor is enabled for this session."""
        return sensor in self.sensors_enabled and self.is_active()
    
    def is_app_allowed(self, app_name: str) -> bool:
        """Check if an app is within scope."""
        # Check exclusions first
        if self.scope.excluded_apps and app_name in self.scope.excluded_apps:
            return False
        # If no whitelist, all allowed; otherwise check whitelist
        if self.scope.apps is None:
            return True
        return app_name in self.scope.apps
    
    def transition_to(
        self,
        new_state: SessionState,
        reason: str,
        triggered_by: str = "system",
        receipt_id: Optional[str] = None,
    ) -> "PerceptionSessionReceipt":
        """Create a new receipt with updated state (immutability-safe).
        
        Returns a new PerceptionSessionReceipt with:
        - Updated state
        - New transition appended to history
        - end_time set if transitioning to CLOSED or ABORTED
        """
        transition = SessionTransition(
            from_state=self.state,
            to_state=new_state,
            reason=reason,
            triggered_by=triggered_by,
            receipt_id=receipt_id,
        )
        new_transitions = list(self.transitions) + [transition]
        
        # Prepare updates
        updates = {
            "state": new_state,
            "transitions": new_transitions,
        }
        
        if new_state in (SessionState.CLOSED, SessionState.ABORTED):
            updates["end_time"] = datetime.now(timezone.utc)
        
        # Create new receipt with updates (immutable pattern)
        return self.model_copy(update=updates)
    
    # ===== COHERENCE GATE INTEGRATION =====
    
    # High-trust profiles that require coherence validation
    HIGH_TRUST_PROFILES: set = {
        TrustProfile.FULL,
        TrustProfile.COURT_GRADE,
    }
    
    def is_high_trust(self) -> bool:
        """Check if this session uses a high-trust profile."""
        return self.trust_profile in self.HIGH_TRUST_PROFILES
    
    def requires_coherence(self) -> bool:
        """Check if this session requires coherence validation.
        
        High-trust profiles (FULL, COURT_GRADE) require coherence
        tracking for the session to be valid.
        """
        return self.is_high_trust()
    
    def validate_coherence_at_start(self, coherence: Optional[float]) -> bool:
        """Validate coherence is adequate to start a high-trust session.
        
        Args:
            coherence: Current coherence score (None if unknown)
            
        Returns:
            True if session can proceed, False if coherence insufficient
            
        Note:
            For high-trust profiles, missing coherence is a failure.
            For other profiles, this always returns True.
        """
        if not self.requires_coherence():
            return True
        
        if coherence is None:
            return False  # High-trust requires known coherence
        
        # Minimum threshold for high-trust session activation
        MIN_COHERENCE_FOR_HIGH_TRUST = 0.4
        return coherence >= MIN_COHERENCE_FOR_HIGH_TRUST
    
    def activate_with_coherence(
        self,
        coherence: float,
        triggered_by: str = "system",
    ) -> "PerceptionSessionReceipt":
        """Activate session and record coherence at start.
        
        Args:
            coherence: Current coherence score to record
            triggered_by: Who triggered the activation
            
        Returns:
            New receipt in ACTIVE state with coherence_at_start set
            
        Raises:
            ValueError: If coherence is insufficient for high-trust session
        """
        if not self.validate_coherence_at_start(coherence):
            raise ValueError(
                f"Coherence {coherence} insufficient for high-trust session "
                f"(profile={self.trust_profile.value})"
            )
        
        # Transition to ACTIVE
        new_session = self.transition_to(
            new_state=SessionState.ACTIVE,
            reason=f"Session activated with coherence={coherence:.3f}",
            triggered_by=triggered_by,
        )
        
        # Record coherence at start
        activated = new_session.model_copy(update={"coherence_at_start": coherence})
        
        # Emit session open to lineage graph (non-blocking)
        _emit_session_lineage_opened(
            session_id=activated.session_id,
            subject=activated.subject,
            purpose=activated.purpose.value if isinstance(activated.purpose, Enum) else str(activated.purpose),
            trust_profile=activated.trust_profile.value if isinstance(activated.trust_profile, Enum) else str(activated.trust_profile),
            coherence_at_start=coherence,
        )
        
        return activated
    
    def close_with_coherence(
        self,
        coherence: float,
        reason: str = "Session completed",
        triggered_by: str = "system",
    ) -> "PerceptionSessionReceipt":
        """Close session and record final coherence.
        
        Args:
            coherence: Final coherence score to record
            reason: Reason for closing
            triggered_by: Who triggered the close
            
        Returns:
            New receipt in CLOSED state with coherence metrics
        """
        # Transition to CLOSED
        new_session = self.transition_to(
            new_state=SessionState.CLOSED,
            reason=reason,
            triggered_by=triggered_by,
        )
        
        # Compute delta_c if we have start coherence
        delta_c = None
        if self.coherence_at_start is not None:
            delta_c = coherence - self.coherence_at_start
        
        closed = new_session.model_copy(update={
            "coherence_at_end": coherence,
            "delta_c_session": delta_c,
        })
        
        # Emit session close to lineage graph (non-blocking)
        _emit_session_lineage_closed(
            session_id=closed.session_id,
            coherence_at_end=coherence,
            delta_c=delta_c,
        )
        
        return closed
    
    def close_with_harmonics(
        self,
        coherence: float,
        omega_h: Optional[float] = None,
        omega_status: Optional[Any] = None,
        harmonic_receipt_id: Optional[str] = None,
        reason: str = "Session completed",
        triggered_by: str = "system",
    ) -> "PerceptionSessionReceipt":
        """Close session with coherence + harmonic state metadata.

        Wraps close_with_coherence() and additionally records Ωᴴ snapshot and
        optional HarmonicStateReceipt linkage for this session.
        """
        closed = self.close_with_coherence(
            coherence=coherence,
            reason=reason,
            triggered_by=triggered_by,
        )

        updates: Dict[str, Any] = {}
        if omega_h is not None:
            updates["omega_h_at_end"] = omega_h
        if omega_status is not None:
            # Accept either enum-like or plain string and normalize
            status_value = getattr(omega_status, "value", omega_status)
            if status_value is not None:
                updates["omega_h_status_at_end"] = str(status_value).lower()
        if harmonic_receipt_id is not None:
            updates["harmonic_state_receipt_id"] = harmonic_receipt_id

        if updates:
            closed = closed.model_copy(update=updates)

        # Update lineage graph with harmonic metadata (non-blocking)
        _emit_session_lineage_closed(
            session_id=closed.session_id,
            coherence_at_end=closed.coherence_at_end,
            delta_c=closed.delta_c_session,
            omega_h_at_end=omega_h,
            omega_h_status_at_end=updates.get("omega_h_status_at_end"),
        )

        return closed
    
    def get_coherence_summary(self) -> Dict[str, Any]:
        """Get summary of coherence tracking for this session."""
        return {
            "coherence_at_start": self.coherence_at_start,
            "coherence_at_end": self.coherence_at_end,
            "delta_c_session": self.delta_c_session,
            "is_high_trust": self.is_high_trust(),
            "omega_h_at_end": self.omega_h_at_end,
            "omega_h_status_at_end": self.omega_h_status_at_end,
        }


# =============================================================================
# SENSORIUM RECEIPT — FUSED PERCEPTION
# =============================================================================


class DetectedElement(BaseModel):
    """An element detected in the visual/AX perception."""
    
    model_config = ConfigDictLike(frozen=True)
    
    element_id: str = Field(..., description="Unique ID for this element")
    element_type: str = Field(
        ...,
        description="Type: button, text_field, link, image, etc."
    )
    bounds: Dict[str, int] = Field(
        ...,
        description="Bounding box {x, y, width, height}"
    )
    text: Optional[str] = Field(
        default=None,
        description="Visible/accessible text"
    )
    confidence: float = Field(
        default=1.0,
        ge=0.0,
        le=1.0,
        description="Detection confidence"
    )
    source: str = Field(
        default="ax",
        description="Detection source: ax | vlm | ocr | hybrid"
    )
    suggested_actions: Optional[List[str]] = Field(
        default=None,
        description="Actions this element affords: click, type, scroll, etc."
    )
    risk_level: Optional[str] = Field(
        default=None,
        description="Risk assessment: safe | caution | blocked"
    )
    ax_role: Optional[str] = Field(
        default=None,
        description="Accessibility role"
    )
    ax_identifier: Optional[str] = Field(
        default=None,
        description="Accessibility identifier"
    )


class VLMDetection(BaseModel):
    """A detection from the VLM/VQA grounding pass."""
    
    model_config = ConfigDictLike(frozen=True)
    
    label: str = Field(..., description="What was detected")
    bbox: Optional[Dict[str, int]] = Field(
        default=None,
        description="Bounding box if localized"
    )
    text: Optional[str] = Field(
        default=None,
        description="Text content if applicable"
    )
    confidence: float = Field(
        default=0.0,
        ge=0.0,
        le=1.0,
        description="VLM confidence"
    )
    suggested_action: Optional[str] = Field(
        default=None,
        description="Action the VLM suggests"
    )
    semantic_type: Optional[str] = Field(
        default=None,
        description="Semantic category"
    )


class VoiceIntentSnapshot(BaseModel):
    """Voice intent at the time of this sensorium snapshot."""
    
    model_config = ConfigDictLike(frozen=True)
    
    intent_id: Optional[str] = Field(
        default=None,
        description="Voice intent receipt ID"
    )
    transcript: Optional[str] = Field(
        default=None,
        description="What the user said"
    )
    parsed_intent: Optional[str] = Field(
        default=None,
        description="Parsed intent type"
    )
    target_element_id: Optional[str] = Field(
        default=None,
        description="Element the intent targets (if resolved)"
    )
    confidence: Optional[float] = Field(
        default=None,
        ge=0.0,
        le=1.0,
        description="Intent parsing confidence"
    )
    validated_against_vision: bool = Field(
        default=False,
        description="Whether intent was validated against current screen"
    )


class SensoriumReceipt(BaseReceipt):
    """Fused perception receipt — the unified view of what Loom perceives.
    
    Combines:
    - Screenshot hash/path
    - AX tree hash/snippet
    - Focused app/window
    - Detected elements (AX + VLM)
    - Voice intent (if any)
    - Gate hints (risk/domain)
    
    Emitted per observation in the screen agent loop, with parent_ids
    back to ModeReceipt/DecisionEnvelope and always to session_id.
    
    This is the canonical "what does Loom think is on screen" object.
    """
    
    # Override receipt_id with auto-generation
    receipt_id: str = Field(
        default_factory=lambda: f"sens_{uuid.uuid4().hex}",
        description="Unique receipt identifier"
    )
    receipt_type: Literal["SensoriumReceipt"] = Field(
        default="SensoriumReceipt",
        frozen=True
    )
    domain: str = Field(default="perception")
    
    # ===== SESSION LINKAGE (REQUIRED) =====
    session_id: str = Field(
        ...,
        description="PerceptionSession this belongs to (NO SESSIONLESS PERCEPTION)"
    )
    
    # ===== TRI-TEMPORAL STAMPS =====
    event_time: datetime = Field(
        ...,
        description="When the screen state occurred"
    )
    observed_at: datetime = Field(
        default_factory=lambda: datetime.now(timezone.utc),
        description="When Loom captured this perception"
    )
    
    # ===== VISUAL PERCEPTION =====
    screenshot_hash: Optional[str] = Field(
        default=None,
        description="SHA-256 hash of screenshot (privacy-preserving)"
    )
    screenshot_path: Optional[str] = Field(
        default=None,
        description="Path to screenshot file (if retained)"
    )
    screenshot_dimensions: Optional[Dict[str, int]] = Field(
        default=None,
        description="{width, height} of the capture"
    )
    
    # ===== ACCESSIBILITY TREE =====
    ax_tree_hash: Optional[str] = Field(
        default=None,
        description="Hash of the AX tree snapshot"
    )
    ax_tree_path: Optional[str] = Field(
        default=None,
        description="Path to AX tree file (if retained)"
    )
    ax_element_count: Optional[int] = Field(
        default=None,
        description="Number of elements in AX tree"
    )
    
    # ===== FOCUS CONTEXT =====
    focused_app: Optional[str] = Field(
        default=None,
        description="Name of the focused application"
    )
    focused_window: Optional[str] = Field(
        default=None,
        description="Title of the focused window"
    )
    focused_element_id: Optional[str] = Field(
        default=None,
        description="ID of the focused UI element"
    )
    display_index: Optional[int] = Field(
        default=None,
        description="Which display this was captured from"
    )
    
    # ===== DETECTED ELEMENTS =====
    elements: List[DetectedElement] = Field(
        default_factory=list,
        description="UI elements detected via AX/VLM/OCR"
    )
    
    # ===== VLM GROUNDING =====
    vlm_detections: List[VLMDetection] = Field(
        default_factory=list,
        description="Detections from VLM/VQA pass"
    )
    vlm_model: Optional[str] = Field(
        default=None,
        description="VLM model used (e.g., 'ollama/llava', 'claude-vision')"
    )
    vlm_latency_ms: Optional[int] = Field(
        default=None,
        description="Time taken for VLM inference"
    )
    vlm_summary: Optional[str] = Field(
        default=None,
        description="VLM's natural language summary of the screen"
    )
    
    # ===== VOICE INTENT =====
    voice_intent: Optional[VoiceIntentSnapshot] = Field(
        default=None,
        description="Voice intent snapshot (if any)"
    )
    
    # ===== GATE HINTS =====
    risk_domain: Optional[str] = Field(
        default=None,
        description="Detected risk domain: clinical, financial, etc."
    )
    risk_level: Optional[str] = Field(
        default=None,
        description="Risk level: low, medium, high, critical"
    )
    clinical_patterns_detected: Optional[List[str]] = Field(
        default=None,
        description="Clinical/sensitive patterns detected"
    )
    blocked_elements: Optional[List[str]] = Field(
        default=None,
        description="Element IDs that are blocked by policy"
    )
    
    # ===== CHANGE DETECTION =====
    screen_changed: bool = Field(
        default=True,
        description="Whether screen changed since last observation"
    )
    change_hash: Optional[str] = Field(
        default=None,
        description="Hash of the change diff (SSIM/perceptual)"
    )
    change_regions: Optional[List[Dict[str, int]]] = Field(
        default=None,
        description="Regions that changed [{x, y, width, height}]"
    )
    stale_ms: Optional[int] = Field(
        default=None,
        description="How old this perception is in milliseconds"
    )
    
    # ===== LINEAGE =====
    vision_receipt_id: Optional[str] = Field(
        default=None,
        description="Underlying VisionObservationReceipt ID"
    )
    voice_receipt_id: Optional[str] = Field(
        default=None,
        description="Underlying VoiceObservationReceipt ID"
    )
    
    def get_element_by_id(self, element_id: str) -> Optional[DetectedElement]:
        """Find an element by ID."""
        for elem in self.elements:
            if elem.element_id == element_id:
                return elem
        return None
    
    def get_elements_by_type(self, element_type: str) -> List[DetectedElement]:
        """Get all elements of a specific type."""
        return [e for e in self.elements if e.element_type == element_type]
    
    def validate_intent_target(self, target_element_id: str) -> bool:
        """Check if an intent target exists in this perception."""
        return self.get_element_by_id(target_element_id) is not None
    
    def is_stale(self, max_stale_ms: int = 5000) -> bool:
        """Check if this perception is too old."""
        if self.stale_ms is None:
            return False
        return self.stale_ms > max_stale_ms
    
    # ===== VLM/VQA INTEGRATION HOOKS =====
    
    def has_vlm_grounding(self) -> bool:
        """Check if VLM grounding pass has been performed."""
        return len(self.vlm_detections) > 0 or self.vlm_summary is not None
    
    def needs_vlm_grounding(self) -> bool:
        """Check if this perception should have VLM grounding.
        
        VLM grounding is recommended when:
        - No VLM detections exist
        - Elements have low confidence
        - Clinical/sensitive content detected
        - Action targets ambiguous elements
        """
        # No existing VLM pass
        if not self.has_vlm_grounding():
            return True
        
        # Check for low-confidence elements
        low_confidence_threshold = 0.7
        for elem in self.elements:
            if elem.confidence < low_confidence_threshold and elem.source != "vlm":
                return True
        
        # Clinical patterns need VLM validation
        if self.clinical_patterns_detected:
            return True
        
        return False
    
    def with_vlm_grounding(
        self,
        detections: List[VLMDetection],
        model: str,
        latency_ms: int,
        summary: Optional[str] = None,
    ) -> "SensoriumReceipt":
        """Create new receipt with VLM grounding results.
        
        Args:
            detections: VLM detection results
            model: Model identifier (e.g., 'ollama/llava', 'claude-vision')
            latency_ms: Inference time in milliseconds
            summary: Optional natural language summary
            
        Returns:
            New SensoriumReceipt with VLM data attached
        """
        return self.model_copy(update={
            "vlm_detections": detections,
            "vlm_model": model,
            "vlm_latency_ms": latency_ms,
            "vlm_summary": summary,
        })
    
    def merge_vlm_with_ax(self) -> List[DetectedElement]:
        """Merge VLM detections with AX elements.
        
        Enriches AX elements with VLM confidence and creates new elements
        for VLM-only detections.
        
        Returns:
            List of merged DetectedElements
        """
        merged = list(self.elements)  # Start with existing
        
        for vlm in self.vlm_detections:
            # Try to match VLM detection to existing element
            matched = False
            for i, elem in enumerate(merged):
                if elem.source != "vlm" and self._vlm_matches_element(vlm, elem):
                    # Boost confidence for matched element
                    merged[i] = DetectedElement(
                        element_id=elem.element_id,
                        element_type=elem.element_type,
                        bounds=elem.bounds,
                        text=elem.text or vlm.text,
                        confidence=min(1.0, (elem.confidence + vlm.confidence) / 2 + 0.1),
                        source="hybrid",
                        suggested_actions=elem.suggested_actions or (
                            [vlm.suggested_action] if vlm.suggested_action else None
                        ),
                        risk_level=elem.risk_level,
                        ax_role=elem.ax_role,
                        ax_identifier=elem.ax_identifier,
                    )
                    matched = True
                    break
            
            # Add VLM-only detection as new element
            if not matched and vlm.bbox:
                merged.append(DetectedElement(
                    element_id=f"vlm_{hash(vlm.label) % 1000000:06d}",
                    element_type=vlm.label,
                    bounds=vlm.bbox,
                    text=vlm.text,
                    confidence=vlm.confidence,
                    source="vlm",
                    suggested_actions=[vlm.suggested_action] if vlm.suggested_action else None,
                ))
        
        return merged
    
    def _vlm_matches_element(self, vlm: VLMDetection, elem: DetectedElement) -> bool:
        """Check if VLM detection matches an existing element."""
        if vlm.bbox is None or not elem.bounds:
            return False
        
        # Calculate IoU (Intersection over Union)
        vlm_box = vlm.bbox
        elem_box = elem.bounds
        
        x1 = max(vlm_box.get("x", 0), elem_box.get("x", 0))
        y1 = max(vlm_box.get("y", 0), elem_box.get("y", 0))
        x2 = min(
            vlm_box.get("x", 0) + vlm_box.get("width", 0),
            elem_box.get("x", 0) + elem_box.get("width", 0)
        )
        y2 = min(
            vlm_box.get("y", 0) + vlm_box.get("height", 0),
            elem_box.get("y", 0) + elem_box.get("height", 0)
        )
        
        if x2 <= x1 or y2 <= y1:
            return False  # No intersection
        
        intersection = (x2 - x1) * (y2 - y1)
        vlm_area = vlm_box.get("width", 0) * vlm_box.get("height", 0)
        elem_area = elem_box.get("width", 0) * elem_box.get("height", 0)
        union = vlm_area + elem_area - intersection
        
        iou = intersection / union if union > 0 else 0
        return iou > 0.5  # 50% overlap threshold
    
    def get_vlm_detection_for_element(self, element_id: str) -> Optional[VLMDetection]:
        """Find VLM detection that matches an element."""
        elem = self.get_element_by_id(element_id)
        if elem is None:
            return None
        
        for vlm in self.vlm_detections:
            if self._vlm_matches_element(vlm, elem):
                return vlm
        return None
    
    def get_high_confidence_elements(
        self,
        threshold: float = 0.8,
        require_vlm: bool = False
    ) -> List[DetectedElement]:
        """Get elements above confidence threshold.
        
        Args:
            threshold: Minimum confidence score
            require_vlm: If True, only return VLM-validated elements
            
        Returns:
            List of high-confidence elements
        """
        result = []
        for elem in self.elements:
            if elem.confidence >= threshold:
                if require_vlm and elem.source not in ("vlm", "hybrid"):
                    continue
                result.append(elem)
        return result


# =============================================================================
# VIDEO SENSOR RECEIPT
# =============================================================================


class KeyframeData(BaseModel):
    """Data for a video keyframe."""
    
    model_config = ConfigDictLike(frozen=True)
    
    frame_index: int = Field(..., description="Frame index in the clip")
    timestamp_ms: int = Field(..., description="Milliseconds from clip start")
    frame_hash: str = Field(..., description="Hash of the frame")
    ax_hash: Optional[str] = Field(
        default=None,
        description="AX tree hash at this keyframe"
    )
    vlm_detections: Optional[List[VLMDetection]] = Field(
        default=None,
        description="VLM detections for this frame"
    )
    change_score: Optional[float] = Field(
        default=None,
        ge=0.0,
        le=1.0,
        description="How much changed from previous keyframe"
    )


class TemporalEvent(BaseModel):
    """An event detected across video frames."""
    
    model_config = ConfigDictLike(frozen=True)
    
    event_type: str = Field(
        ...,
        description="Event type: element_enter, element_leave, focus_change, etc."
    )
    element_id: Optional[str] = Field(
        default=None,
        description="Element involved"
    )
    start_frame: int = Field(..., description="Frame where event started")
    end_frame: Optional[int] = Field(
        default=None,
        description="Frame where event ended (None if ongoing)"
    )
    confidence: float = Field(
        default=1.0,
        ge=0.0,
        le=1.0,
        description="Event detection confidence"
    )
    metadata: Optional[Dict[str, Any]] = Field(
        default=None,
        description="Additional event-specific data"
    )


class VideoSensorReceipt(BaseReceipt):
    """Receipt for a video clip with temporal perception data.
    
    Captures:
    - Clip metadata (hashes, paths, timestamps)
    - Keyframe data (sampled frames with AX/VLM)
    - Temporal events (enter/leave, focus changes)
    - Motion/change summary
    
    Used for:
    - Multi-sense time travel (replay perception)
    - Temporal continuity validation
    - Voice→video validation
    """
    
    # Override receipt_id with auto-generation
    receipt_id: str = Field(
        default_factory=lambda: f"vid_{uuid.uuid4().hex}",
        description="Unique receipt identifier"
    )
    receipt_type: Literal["VideoSensorReceipt"] = Field(
        default="VideoSensorReceipt",
        frozen=True
    )
    domain: str = Field(default="perception")
    
    # ===== SESSION LINKAGE =====
    session_id: str = Field(
        ...,
        description="PerceptionSession this belongs to"
    )
    
    # ===== CLIP IDENTITY =====
    clip_id: str = Field(
        default_factory=lambda: f"clip_{uuid.uuid4().hex}",
        description="Unique clip identifier"
    )
    clip_path: Optional[str] = Field(
        default=None,
        description="Path to video file (if retained)"
    )
    clip_hash: Optional[str] = Field(
        default=None,
        description="Hash of the video file"
    )
    
    # ===== TIMING =====
    start_time: datetime = Field(
        ...,
        description="When clip recording started"
    )
    end_time: datetime = Field(
        ...,
        description="When clip recording ended"
    )
    duration_ms: int = Field(
        ...,
        ge=0,
        description="Clip duration in milliseconds"
    )
    fps: float = Field(
        default=1.0,
        ge=0.1,
        description="Frames per second"
    )
    
    # ===== FRAMES =====
    total_frames: int = Field(
        ...,
        ge=1,
        description="Total number of frames"
    )
    frame_hashes: List[str] = Field(
        default_factory=list,
        description="Hashes of all frames"
    )
    
    # ===== KEYFRAMES =====
    keyframes: List[KeyframeData] = Field(
        default_factory=list,
        description="Sampled keyframes with AX/VLM data"
    )
    keyframe_strategy: str = Field(
        default="change_based",
        description="How keyframes were selected: fixed_rate, change_based, ui_event"
    )
    
    # ===== TEMPORAL EVENTS =====
    temporal_events: List[TemporalEvent] = Field(
        default_factory=list,
        description="Events detected across frames"
    )
    temporal_summary: Optional[str] = Field(
        default=None,
        description="Natural language summary of what happened"
    )
    
    # ===== CHANGE DETECTION =====
    motion_score: Optional[float] = Field(
        default=None,
        ge=0.0,
        le=1.0,
        description="Overall motion/change level"
    )
    change_frames: Optional[List[int]] = Field(
        default=None,
        description="Frame indices where significant change occurred"
    )
    
    # ===== STALENESS =====
    stale: bool = Field(
        default=False,
        description="Whether this clip is too old for action validation"
    )
    stale_reason: Optional[str] = Field(
        default=None,
        description="Why clip is considered stale"
    )
    
    # ===== LINEAGE =====
    sensorium_receipt_ids: List[str] = Field(
        default_factory=list,
        description="SensoriumReceipts covered by this clip"
    )
    action_receipt_id: Optional[str] = Field(
        default=None,
        description="Action this clip was captured for"
    )
    
    def get_keyframe_at(self, timestamp_ms: int) -> Optional[KeyframeData]:
        """Find the nearest keyframe to a timestamp."""
        if not self.keyframes:
            return None
        
        closest = min(
            self.keyframes,
            key=lambda kf: abs(kf.timestamp_ms - timestamp_ms)
        )
        return closest
    
    def is_stale_for_action(self, max_age_ms: int = 3000) -> bool:
        """Check if clip is too old for action validation."""
        age = (datetime.now(timezone.utc) - self.end_time).total_seconds() * 1000
        return age > max_age_ms


# =============================================================================
# FACTORY FUNCTIONS
# =============================================================================


def create_perception_session(
    subject: str,
    purpose: SessionPurpose,
    sensors: List[SensorType],
    scope: SessionScope,
    consent_proof: ConsentProof,
    policy_bundle_id: str,
    episode_id: Optional[str] = None,
    trust_profile: TrustProfile = TrustProfile.PARTIAL,
    kill_switches: Optional[List[KillSwitch]] = None,
    max_duration_seconds: Optional[int] = None,
    mode_receipt_id: Optional[str] = None,
) -> PerceptionSessionReceipt:
    """Create a new perception session.
    
    Factory function to create a properly initialized PerceptionSessionReceipt
    with sensible defaults.
    """
    default_switches = [
        KillSwitch(
            switch_type=KillSwitchType.HOTKEY,
            trigger="Cmd+Shift+Escape",
            priority=100,
        ),
        KillSwitch(
            switch_type=KillSwitchType.WAKE_WORD,
            trigger="Loom, stop",
            priority=90,
        ),
        KillSwitch(
            switch_type=KillSwitchType.GUARDIAN_VETO,
            trigger="guardian_critical_veto",
            priority=100,
        ),
    ]
    
    return PerceptionSessionReceipt(
        subject=subject,
        purpose=purpose,
        sensors_enabled=sensors,
        scope=scope,
        consent_proof=consent_proof,
        policy_bundle_id=policy_bundle_id,
        episode_id=episode_id,
        trust_profile=trust_profile,
        kill_switches=kill_switches or default_switches,
        max_duration_seconds=max_duration_seconds,
        mode_receipt_id=mode_receipt_id,
        state=SessionState.OPEN,
    )


def create_sensorium_snapshot(
    session_id: str,
    event_time: datetime,
    screenshot_hash: Optional[str] = None,
    ax_tree_hash: Optional[str] = None,
    focused_app: Optional[str] = None,
    focused_window: Optional[str] = None,
    elements: Optional[List[DetectedElement]] = None,
    vlm_detections: Optional[List[VLMDetection]] = None,
    voice_intent: Optional[VoiceIntentSnapshot] = None,
) -> SensoriumReceipt:
    """Create a sensorium snapshot with common defaults."""
    return SensoriumReceipt(
        session_id=session_id,
        event_time=event_time,
        screenshot_hash=screenshot_hash,
        ax_tree_hash=ax_tree_hash,
        focused_app=focused_app,
        focused_window=focused_window,
        elements=elements or [],
        vlm_detections=vlm_detections or [],
        voice_intent=voice_intent,
    )


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

__all__ = [
    # Enums
    "SessionState",
    "SensorType",
    "SessionPurpose",
    "TrustProfile",
    "ConsentMethod",
    "KillSwitchType",
    # Supporting models
    "ConsentProof",
    "SessionScope",
    "KillSwitch",
    "SessionTransition",
    "DetectedElement",
    "VLMDetection",
    "VoiceIntentSnapshot",
    "KeyframeData",
    "TemporalEvent",
    # Core receipts
    "PerceptionSessionReceipt",
    "SensoriumReceipt",
    "VideoSensorReceipt",
    # Factory functions
    "create_perception_session",
    "create_sensorium_snapshot",
]
