//! Nostrverse: Virtual spaces as Nostr events
//!
//! This app implements spatial views for nostrverse - a protocol where
//! spaces and objects are Nostr events (kinds 37555, 37556, 10555).
//!
//! Spaces are rendered as 3D scenes using renderbud's PBR pipeline,
//! embedded in egui via wgpu paint callbacks.

mod convert;
mod model_cache;
mod nostr_events;
mod presence;
mod room_state;
mod room_view;
mod subscriptions;
mod tilemap;

pub use room_state::{
    NostrverseAction, NostrverseState, RoomObject, RoomObjectType, RoomUser, SpaceData, SpaceInfo,
    SpaceRef,
};
pub use room_view::{NostrverseResponse, render_editing_panel, show_room_view};

use enostr::{NormRelayUrl, Pubkey, RelayId};
use glam::Vec3;
use nostrdb::Filter;
use notedeck::{AppContext, AppResponse, ScopedSubIdentity, SubConfig, SubKey, SubOwnerKey};
use renderbud::Transform;

use egui_wgpu::wgpu;

/// Demo pubkey (jb55) used for testing
const DEMO_PUBKEY_HEX: &str = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245";
const FALLBACK_PUBKEY_HEX: &str =
    "0000000000000000000000000000000000000000000000000000000000000001";

fn demo_pubkey() -> Pubkey {
    Pubkey::from_hex(DEMO_PUBKEY_HEX)
        .unwrap_or_else(|_| Pubkey::from_hex(FALLBACK_PUBKEY_HEX).unwrap())
}

/// Scoped-sub identity for nostrverse's dedicated relay room/presence feed.
fn nostrverse_remote_sub_identity() -> ScopedSubIdentity {
    ScopedSubIdentity::account(
        SubOwnerKey::new("nostrverse-owner"),
        SubKey::new("nostrverse-room-presence"),
    )
}

/// Publish a locally ingested note to the dedicated nostrverse relay.
fn publish_ingested_note(
    publisher: &mut notedeck::ExplicitPublishApi<'_, '_>,
    relay_url: &NormRelayUrl,
    note: &nostrdb::Note<'_>,
) {
    publisher.publish_note(note, vec![RelayId::Websocket(relay_url.clone())]);
}

fn configured_relay_url() -> NormRelayUrl {
    let raw = std::env::var("NOSTRVERSE_RELAY")
        .unwrap_or_else(|_| NostrverseApp::DEFAULT_RELAY.to_string());
    match NormRelayUrl::new(&raw) {
        Ok(url) => url,
        Err(err) => {
            tracing::warn!(
                "Invalid NOSTRVERSE_RELAY '{}': {err:?}; falling back to {}",
                raw,
                NostrverseApp::DEFAULT_RELAY
            );
            NormRelayUrl::new(NostrverseApp::DEFAULT_RELAY).expect("default nostrverse relay URL")
        }
    }
}

fn room_filter() -> Filter {
    Filter::new().kinds([kinds::ROOM as u64]).build()
}

fn presence_filter() -> Filter {
    Filter::new().kinds([kinds::PRESENCE as u64]).build()
}

/// Avatar scale: water bottle model is ~0.26m, scaled to human height (~1.8m)
const AVATAR_SCALE: f32 = 7.0;
/// How fast the avatar yaw lerps toward the target (higher = faster)
const AVATAR_YAW_LERP_SPEED: f32 = 10.0;
/// How fast remote avatar position lerps toward extrapolated target
const AVATAR_POS_LERP_SPEED: f32 = 8.0;
/// Maximum extrapolation time (seconds) before clamping dead reckoning
const MAX_EXTRAPOLATION_TIME: f64 = 3.0;
/// Maximum extrapolation distance from last known position
const MAX_EXTRAPOLATION_DISTANCE: f32 = 10.0;

/// Demo space in protoverse .space format
pub const DEMO_SPACE: &str = r#"(space (name "Notedeck HQ")
  (group
    (tilemap (width 10) (height 10)
      (tileset "grass" "stone" "water" "sand" "dirt")
      (data "0 0 0 0 0 0 0 0 0 0 0 0 4 4 0 0 0 0 0 0 0 4 4 4 0 0 3 3 0 0 0 4 4 0 0 3 3 2 3 0 0 0 0 0 0 0 3 3 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0"))

    (room (shape rectangle)
          (name "Living Room")
          (material "hardwood")
          (condition "cozy")
          (width 4) (depth 4) (height 2.4)
      (group
        (wall (id back-wall-left)
              (name "back wall")
              (material "plaster")
              (width 2) (height 2.4) (depth 0.1)
              (model-url "https://jb55.com/s/wall.glb")
              (position 1 0 0)
              (rotation 0 90 0))
        (wall (id back-wall-right)
              (name "window wall")
              (material "plaster")
              (width 2) (height 2.4) (depth 0.2)
              (model-url "https://jb55.com/s/wall-window-square.glb")
              (position 3 0 0)
              (rotation 0 90 0))
        (wall (id left-wall)
              (material "plaster")
              (width 2) (height 2.4) (depth 0.1)
              (model-url "https://jb55.com/s/wall.glb")
              (position 0 0 2))
        (wall (id right-wall)
              (material "plaster")
              (width 2) (height 2.4) (depth 0.1)
              (model-url "https://jb55.com/s/wall.glb")
              (position 4 0 2))
        (door (id front-door)
              (name "front door")
              (width 2) (height 2.4) (depth 0.2)
              (model-url "https://jb55.com/s/wall-doorway-square.glb")
              (position 1 0 4)
              (rotation 0 90 0))
        (wall (id front-wall-right)
              (material "plaster")
              (width 2) (height 2.4) (depth 0.1)
              (model-url "https://jb55.com/s/wall.glb")
              (position 3 0 4)
              (rotation 0 90 0))

        (chair (id sofa)
               (name "sofa")
               (material "fabric")
               (condition "comfortable")
               (width 0.98) (height 0.46) (depth 0.41)
               (model-url "https://jb55.com/s/sofa.glb")
               (position 0.5 0 0.5)
               (rotation 0 90 0))
        (table (id coffee-table)
               (name "coffee table")
               (material "oak")
               (condition "worn")
               (width 0.66) (height 0.23) (depth 0.4)
               (location near sofa)
               (model-url "https://jb55.com/s/coffee-table.glb")
               (position 2 0 2))
        (chair (id lounge-chair)
               (name "lounge chair")
               (material "leather")
               (width 0.49) (height 0.46) (depth 0.41)
               (location near coffee-table)
               (model-url "https://jb55.com/s/lounge-chair.glb")
               (position 3.5 0 1.5)
               (rotation 0 -90 0))
        (table (id tv-cabinet)
               (name "TV cabinet")
               (material "pine")
               (width 0.8) (height 0.31) (depth 0.25)
               (model-url "https://jb55.com/s/tv-cabinet.glb")
               (position 0.3 0 3.5)
               (rotation 0 90 0))
        (shelf (id bookcase)
               (name "bookcase")
               (material "oak")
               (condition "full")
               (width 0.4) (height 0.88) (depth 0.25)
               (model-url "https://jb55.com/s/bookcase.glb")
               (position 3.5 0 0.3))
        (light (id floor-lamp)
               (name "floor lamp")
               (location floor)
               (state on)
               (width 0.15) (height 0.86) (depth 0.18)
               (model-url "https://jb55.com/s/lamp-floor.glb")
               (position 3.5 0 3.5))
        (rug (id living-rug)
             (name "rug")
             (shape rectangle)
             (material "wool")
             (condition "soft")
             (width 1.57) (height 0.01) (depth 0.92)
             (model-url "https://jb55.com/s/rug-rectangle.glb")
             (position 2 0.01 2))
        (plant (id potted-plant)
               (name "potted plant")
               (condition "healthy")
               (width 0.09) (height 0.12) (depth 0.1)
               (model-url "https://jb55.com/s/potted-plant.glb")
               (position 0.3 0 0.3))))

    (room (shape rectangle)
          (name "Office")
          (material "carpet")
          (condition "tidy")
          (width 2) (depth 4) (height 2.4)
      (group
        (wall (id office-back-wall)
              (name "window wall")
              (material "plaster")
              (width 4) (height 2.4) (depth 0.2)
              (model-url "https://jb55.com/s/wall-window-wide-square.glb")
              (position -3 0 0)
              (rotation 0 90 0))
        (wall (id office-left-wall)
              (material "plaster")
              (width 2) (height 2.4) (depth 0.1)
              (model-url "https://jb55.com/s/wall.glb")
              (position -4 0 2))
        (door (id office-door)
              (name "office door")
              (width 2) (height 2.4) (depth 0.2)
              (model-url "https://jb55.com/s/wall-doorway-square.glb")
              (position -2 0 2))
        (wall (id office-front-wall)
              (material "plaster")
              (width 2) (height 2.4) (depth 0.1)
              (model-url "https://jb55.com/s/wall.glb")
              (position -3 0 4)
              (rotation 0 90 0))

        (table (id desk)
               (name "desk")
               (material "walnut")
               (condition "sturdy")
               (width 0.73) (height 0.38) (depth 0.39)
               (model-url "https://jb55.com/s/desk.glb")
               (position -3.5 0 1)
               (rotation 0 90 0))
        (chair (id desk-chair)
               (name "desk chair")
               (material "mesh")
               (condition "ergonomic")
               (width 0.29) (height 0.19) (depth 0.27)
               (location near desk)
               (model-url "https://jb55.com/s/chair-desk.glb")
               (position -3 0 1.5))
        (screen (id computer-screen)
                (name "computer screen")
                (condition "on")
                (width 0.39) (height 0.29) (depth 0.1)
                (location top-of desk)
                (model-url "https://jb55.com/s/computer-screen.glb")
                (position -3.5 0.38 0.8)
                (rotation 0 90 0))
        (keyboard (id keyboard)
                  (name "keyboard")
                  (width 0.28) (height 0.03) (depth 0.12)
                  (location top-of desk)
                  (model-url "https://jb55.com/s/computer-keyboard.glb")
                  (position -3.2 0.38 1)
                  (rotation 0 90 0))
        (shelf (id office-bookcase)
               (name "wide bookcase")
               (material "oak")
               (condition "organized")
               (width 0.8) (height 0.79) (depth 0.25)
               (model-url "https://jb55.com/s/bookcase-wide.glb")
               (position -2.3 0 3.5)
               (rotation 0 -90 0))
        (plant (id office-plant)
               (name "potted plant")
               (condition "healthy")
               (width 0.09) (height 0.12) (depth 0.1)
               (model-url "https://jb55.com/s/potted-plant.glb")
               (position -3.7 0 3.5))
        (light (id desk-lamp)
               (name "desk lamp")
               (state on)
               (width 0.15) (height 0.31) (depth 0.18)
               (location top-of desk)
               (model-url "https://jb55.com/s/lamp-table.glb")
               (position -2.5 0.38 0.8))))))"#;

/// Event kinds for nostrverse
pub mod kinds {
    /// Room event kind (addressable)
    pub const ROOM: u16 = 37555;
    /// Object event kind (addressable)
    pub const OBJECT: u16 = 37556;
    /// Presence event kind (user-replaceable)
    pub const PRESENCE: u16 = 10555;
}

/// Nostrverse app - a 3D spatial canvas for virtual spaces
pub struct NostrverseApp {
    /// Current space state
    state: NostrverseState,
    /// 3D renderer (None if wgpu unavailable)
    renderer: Option<renderbud::egui::EguiRenderer>,
    /// GPU device for model loading (Arc-wrapped internally by wgpu)
    device: Option<wgpu::Device>,
    /// GPU queue for model loading (Arc-wrapped internally by wgpu)
    queue: Option<wgpu::Queue>,
    /// Whether the app has been initialized
    initialized: bool,
    /// Cached avatar model AABB for ground placement
    avatar_bounds: Option<renderbud::Aabb>,
    /// Local nostrdb subscription for space events
    room_sub: Option<subscriptions::RoomSubscription>,
    /// Presence publisher (throttled heartbeats)
    presence_pub: presence::PresencePublisher,
    /// Presence expiry (throttled stale-user cleanup)
    presence_expiry: presence::PresenceExpiry,
    /// Local nostrdb subscription for presence events
    presence_sub: Option<subscriptions::PresenceSubscription>,
    /// Cached space naddr string (avoids format! per frame)
    space_naddr: String,
    /// Event ID of the last save we made (to skip our own echo in polls)
    last_save_id: Option<[u8; 32]>,
    /// Monotonic time tracker (seconds since app start)
    start_time: std::time::Instant,
    /// Model download/cache manager (initialized lazily in initialize())
    model_cache: Option<model_cache::ModelCache>,
    /// Tile image download/cache for URL-based tileset entries
    tile_image_cache: tilemap::TileImageCache,
    /// Dedicated relay URL for multiplayer sync (from NOSTRVERSE_RELAY env)
    relay_url: NormRelayUrl,
}

impl NostrverseApp {
    const DEFAULT_RELAY: &str = "ws://relay.jb55.com";

    /// Create a new nostrverse app with a space reference
    pub fn new(space_ref: SpaceRef, render_state: Option<&egui_wgpu::RenderState>) -> Self {
        let renderer = render_state.map(|rs| renderbud::egui::EguiRenderer::new(rs, (800, 600)));

        let device = render_state.map(|rs| rs.device.clone());
        let queue = render_state.map(|rs| rs.queue.clone());

        let relay_url = configured_relay_url();

        let space_naddr = space_ref.to_naddr();
        Self {
            state: NostrverseState::new(space_ref),
            renderer,
            device,
            queue,
            initialized: false,
            avatar_bounds: None,
            room_sub: None,
            presence_pub: presence::PresencePublisher::new(),
            presence_expiry: presence::PresenceExpiry::new(),
            presence_sub: None,
            space_naddr,
            last_save_id: None,
            start_time: std::time::Instant::now(),
            model_cache: None,
            tile_image_cache: tilemap::TileImageCache::new(),
            relay_url,
        }
    }

    /// Create with a demo space
    pub fn demo(render_state: Option<&egui_wgpu::RenderState>) -> Self {
        let space_ref = SpaceRef::new("demo-room".to_string(), demo_pubkey());
        Self::new(space_ref, render_state)
    }

    /// Load a glTF model and return its handle
    fn load_model(&self, path: &str) -> Option<renderbud::Model> {
        let renderer = self.renderer.as_ref()?;
        let device = self.device.as_ref()?;
        let queue = self.queue.as_ref()?;
        let mut r = renderer.renderer.lock().unwrap();
        match r.load_gltf_model(device, queue, path) {
            Ok(model) => Some(model),
            Err(e) => {
                tracing::warn!("Failed to load model {}: {}", path, e);
                None
            }
        }
    }

    /// Initialize: ingest demo space into local nostrdb and subscribe.
    fn initialize(&mut self, ctx: &mut AppContext<'_>) {
        if self.initialized {
            return;
        }

        // Initialize model cache
        let cache_dir = ctx.path.path(notedeck::DataPathType::Cache).join("models");
        self.model_cache = Some(model_cache::ModelCache::new(cache_dir));

        // Subscribe to space and presence events in local nostrdb
        self.room_sub = Some(subscriptions::RoomSubscription::new(ctx.ndb));
        self.presence_sub = Some(subscriptions::PresenceSubscription::new(ctx.ndb));

        // Declare remote room/presence feed on the dedicated relay.
        let relays = std::iter::once(self.relay_url.clone()).collect();
        let config = SubConfig::live(vec![room_filter(), presence_filter()])
            .explicit_relays(relays)
            .build();
        let _ = ctx
            .remote
            .scoped_subs(ctx.accounts)
            .set_sub(nostrverse_remote_sub_identity(), config);
        tracing::info!(
            "Declared nostrverse scoped relay subscription on {}",
            self.relay_url
        );

        // Try to load an existing space from nostrdb first
        let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn");
        self.load_space_from_ndb(ctx.ndb, &txn);

        // Only ingest the demo space if no saved space was found
        if self.state.space.is_none() {
            let space = match protoverse::parse(DEMO_SPACE) {
                Ok(s) => s,
                Err(e) => {
                    tracing::error!("Failed to parse demo space: {}", e);
                    return;
                }
            };

            if let Some(kp) = ctx.accounts.selected_filled() {
                let builder = nostr_events::build_space_event(&space, &self.state.space_ref.id);
                if let Some(note) = nostr_events::ingest_event(builder, ctx.ndb, kp) {
                    let mut publisher = ctx.remote.publisher_explicit();
                    publish_ingested_note(&mut publisher, &self.relay_url, &note);
                }
            }
            // room_sub (set up above) will pick up the ingested event
            // on the next poll_space_updates() frame.
        }

        // Add self user
        let self_pubkey = *ctx.accounts.selected_account_pubkey();
        self.state.users = vec![
            RoomUser::new(self_pubkey, "jb55".to_string(), Vec3::new(-2.0, 0.0, -2.0))
                .with_self(true),
        ];

        // Assign avatar model (use first model with id "obj2" as placeholder)
        let avatar_model = self
            .state
            .objects
            .iter()
            .find(|o| o.id == "obj2")
            .and_then(|o| o.model_handle);
        let avatar_bounds = avatar_model.and_then(|m| {
            let renderer = self.renderer.as_ref()?;
            let r = renderer.renderer.lock().unwrap();
            r.model_bounds(m)
        });
        if let Some(model) = avatar_model {
            for user in &mut self.state.users {
                user.model_handle = Some(model);
            }
        }
        self.avatar_bounds = avatar_bounds;

        // Switch to third-person camera mode
        if let Some(renderer) = &self.renderer {
            let self_pos = self
                .state
                .self_user()
                .map(|u| u.position)
                .unwrap_or(Vec3::ZERO);
            let mut r = renderer.renderer.lock().unwrap();
            r.set_third_person_mode(self_pos);
        }

        self.initialized = true;
    }

    /// Apply a parsed Space to the state: convert, load models, update state.
    /// Preserves renderer scene handles for objects that still exist by ID,
    /// and removes orphaned scene objects from the renderer.
    fn apply_space(&mut self, space: &protoverse::Space) {
        let mut data = convert::convert_space(space);

        // Transfer scene/model handles from existing objects with matching IDs
        for new_obj in &mut data.objects {
            if let Some(old_obj) = self.state.objects.iter().find(|o| o.id == new_obj.id) {
                new_obj.scene_object_id = old_obj.scene_object_id;
                new_obj.model_handle = old_obj.model_handle;
            }
        }

        // Remove orphaned scene objects (old objects not in the new set)
        if let Some(renderer) = &self.renderer {
            let mut r = renderer.renderer.lock().unwrap();
            for old_obj in &self.state.objects {
                if let Some(scene_id) = old_obj.scene_object_id
                    && !data.objects.iter().any(|o| o.id == old_obj.id)
                {
                    r.remove_object(scene_id);
                }
            }
            // Remove old tilemap scene object — will be rebuilt from new data
            if let Some(tm) = self.state.tilemap()
                && let Some(scene_id) = tm.scene_object_id
            {
                r.remove_object(scene_id);
            }
        }

        self.load_object_models(&mut data.objects);
        self.state.space = Some(data.info);
        self.state.objects = data.objects;
        self.state.dirty = false;
    }

    /// Load space state from a nostrdb query result.
    fn load_space_from_ndb(&mut self, ndb: &nostrdb::Ndb, txn: &nostrdb::Transaction) {
        let notes = subscriptions::RoomSubscription::query_existing(ndb, txn);

        for note in &notes {
            let Some(space_id) = nostr_events::get_space_id(note) else {
                continue;
            };
            if space_id != self.state.space_ref.id {
                continue;
            }

            let Some(space) = nostr_events::parse_space_event(note) else {
                tracing::warn!("Failed to parse space event content");
                continue;
            };

            self.apply_space(&space);
            tracing::info!("Loaded space '{}' from nostrdb", space_id);
            return;
        }
    }

    /// Save current space state: build Space, serialize, ingest as new nostr event.
    fn save_space(&mut self, ctx: &mut AppContext<'_>) {
        let Some(info) = &self.state.space else {
            tracing::warn!("save_space: no space to save");
            return;
        };
        let Some(kp) = ctx.accounts.selected_filled() else {
            tracing::warn!("save_space: no keypair available");
            return;
        };

        let space = convert::build_space(info, &self.state.objects);
        let builder = nostr_events::build_space_event(&space, &self.state.space_ref.id);
        if let Some(note) = nostr_events::ingest_event(builder, ctx.ndb, kp) {
            self.last_save_id = Some(*note.id());
            publish_ingested_note(&mut ctx.remote.publisher_explicit(), &self.relay_url, &note);
        }
        tracing::info!("Saved space '{}'", self.state.space_ref.id);
    }

    /// Load 3D models for objects, then resolve any semantic locations
    /// (e.g. "top-of obj1") to concrete positions using AABB bounds.
    ///
    /// For remote URLs (http/https), the model cache handles async download
    /// and disk caching. Models that aren't yet downloaded will be loaded
    /// on a future frame via `poll_model_downloads`.
    fn load_object_models(&mut self, objects: &mut [RoomObject]) {
        let renderer = self.renderer.as_ref();
        let model_bounds_fn = |m: Option<renderbud::Model>| -> Option<renderbud::Aabb> {
            let r = renderer?.renderer.lock().unwrap();
            r.model_bounds(m?)
        };

        // Phase 1: Load all models and cache their AABB bounds.
        // Remote URLs may return None (download in progress); those objects
        // will get their model_handle assigned later via poll_model_downloads.
        let mut bounds_by_id: std::collections::HashMap<String, renderbud::Aabb> =
            std::collections::HashMap::new();

        for obj in objects.iter_mut() {
            // Skip if already loaded
            if obj.model_handle.is_some() {
                if let Some(bounds) = model_bounds_fn(obj.model_handle) {
                    bounds_by_id.insert(obj.id.clone(), bounds);
                }
                continue;
            }

            if let Some(url) = obj.model_url.clone() {
                let local_path = if let Some(cache) = &mut self.model_cache {
                    cache.request(&url)
                } else {
                    Some(std::path::PathBuf::from(&url))
                };

                if let Some(path) = local_path {
                    let model = self.load_model(path.to_str().unwrap_or(&url));
                    if let Some(bounds) = model_bounds_fn(model) {
                        bounds_by_id.insert(obj.id.clone(), bounds);
                    }
                    obj.model_handle = model;
                    if let Some(cache) = &mut self.model_cache {
                        cache.mark_loaded(&url);
                    }
                }
            }
        }

        resolve_locations(objects, &bounds_by_id);
    }

    /// Poll for completed model downloads, load into GPU, and re-resolve
    /// semantic locations so dependent objects are positioned correctly.
    fn poll_model_downloads(&mut self) {
        let Some(cache) = &mut self.model_cache else {
            return;
        };

        let ready = cache.poll();
        if ready.is_empty() {
            return;
        }

        let mut any_loaded = false;
        for (url, path) in ready {
            let path_str = path.to_string_lossy();
            let model = self.load_model(&path_str);

            if model.is_none() {
                tracing::warn!("Failed to load cached model at {}", path_str);
                continue;
            }

            for obj in &mut self.state.objects {
                if obj.model_url.as_deref() == Some(&url) && obj.model_handle.is_none() {
                    obj.model_handle = model;
                    obj.scene_object_id = None;
                    any_loaded = true;
                }
            }

            if let Some(cache) = &mut self.model_cache {
                cache.mark_loaded(&url);
            }
        }

        if any_loaded {
            resolve_object_locations(self.renderer.as_ref(), &mut self.state.objects);
        }
    }

    /// Poll the space subscription for updates.
    /// Skips applying updates while the space has unsaved local edits.
    fn poll_space_updates(&mut self, ndb: &nostrdb::Ndb) {
        if self.state.dirty {
            return;
        }
        let Some(sub) = &self.room_sub else {
            return;
        };
        let txn = nostrdb::Transaction::new(ndb).expect("txn");
        let notes = sub.poll(ndb, &txn);

        for note in &notes {
            // Skip our own save — the in-memory state is already correct
            if let Some(last_id) = &self.last_save_id
                && note.id() == last_id
            {
                self.last_save_id = None;
                continue;
            }

            let Some(space_id) = nostr_events::get_space_id(note) else {
                continue;
            };
            if space_id != self.state.space_ref.id {
                continue;
            }

            let Some(space) = nostr_events::parse_space_event(note) else {
                continue;
            };

            self.apply_space(&space);
            tracing::info!("Space '{}' updated from nostrdb", space_id);
        }
    }

    /// Run one tick of presence: publish local position, poll remote, expire stale.
    fn tick_presence(&mut self, ctx: &mut AppContext<'_>) {
        let now = self.start_time.elapsed().as_secs_f64();

        // Publish our position (throttled — only on change or keep-alive)
        if let Some(kp) = ctx.accounts.selected_filled() {
            let self_pos = self
                .state
                .self_user()
                .map(|u| u.position)
                .unwrap_or(Vec3::ZERO);

            if let Some(note) =
                self.presence_pub
                    .maybe_publish(ctx.ndb, kp, &self.space_naddr, self_pos, now)
            {
                publish_ingested_note(&mut ctx.remote.publisher_explicit(), &self.relay_url, &note);
            }
        }

        // Poll for remote presence events
        let self_pubkey = *ctx.accounts.selected_account_pubkey();
        if let Some(sub) = &self.presence_sub {
            let changed = presence::poll_presence(
                sub,
                ctx.ndb,
                &self.space_naddr,
                &self_pubkey,
                &mut self.state.users,
                now,
            );

            // Assign avatar model to new users
            if changed {
                let avatar_model = self.state.self_user().and_then(|u| u.model_handle);
                if let Some(model) = avatar_model {
                    for user in &mut self.state.users {
                        if user.model_handle.is_none() {
                            user.model_handle = Some(model);
                        }
                    }
                }
            }
        }

        // Expire stale remote users (throttled to every ~10s)
        let expired = self
            .presence_expiry
            .maybe_expire(&mut self.state.users, now);
        if !expired.is_empty() {
            tracing::info!("Expired {} stale users", expired.len());
            // Clean up scene objects so avatars don't linger in the 3D scene
            if let Some(renderer) = &self.renderer {
                let mut r = renderer.renderer.lock().unwrap();
                for user in &expired {
                    if let Some(scene_id) = user.scene_object_id {
                        r.remove_object(scene_id);
                    }
                }
            }
        }
    }

    /// Sync space objects and user avatars to the renderbud scene
    fn sync_scene(&mut self) {
        let Some(renderer) = &self.renderer else {
            return;
        };
        let mut r = renderer.renderer.lock().unwrap();

        sync_objects_to_scene(&mut self.state.objects, &mut r);

        // Download URL-based tile images and build/rebuild tilemap
        if let Some(tm) = self.state.tilemap_mut() {
            // Start downloads for any URL tileset entries
            self.tile_image_cache.request_tiles(&tm.tileset);

            // Poll downloads; rebuild tilemap model if new images arrived
            let new_images = self.tile_image_cache.poll(&mut tm.tile_images);
            if new_images {
                // Remove old model so it gets rebuilt with new images
                if let Some(obj_id) = tm.scene_object_id.take() {
                    r.remove_object(obj_id);
                }
                tm.model_handle = None;
            }

            if tm.model_handle.is_none()
                && let (Some(device), Some(queue)) = (&self.device, &self.queue)
            {
                tm.model_handle = Some(tilemap::build_tilemap_model(tm, &mut r, device, queue));
            }
            if tm.scene_object_id.is_none()
                && let Some(model) = tm.model_handle
            {
                let transform = renderbud::Transform {
                    translation: glam::Vec3::ZERO,
                    rotation: glam::Quat::IDENTITY,
                    scale: glam::Vec3::ONE,
                };
                tm.scene_object_id = Some(r.place_object(model, transform));
            }
        }

        // Update self-user's position from the camera controller
        if let Some(pos) = r.avatar_position()
            && let Some(self_user) = self.state.self_user_mut()
        {
            self_user.position = pos;
            self_user.display_position = pos;
        }

        // Smoothly lerp avatar yaw toward controller target
        let dt = 1.0 / 60.0_f32;
        if let Some(target_yaw) = r.avatar_yaw() {
            self.state.smooth_avatar_yaw = lerp_yaw(
                self.state.smooth_avatar_yaw,
                target_yaw,
                AVATAR_YAW_LERP_SPEED * dt,
            );
        }

        let now = self.start_time.elapsed().as_secs_f64();
        let avatar_y_offset = self
            .avatar_bounds
            .map(|b| (b.max.y - b.min.y) * 0.5)
            .unwrap_or(0.0)
            * AVATAR_SCALE;

        update_remote_user_positions(&mut self.state.users, dt, now);
        sync_users_to_scene(
            &mut self.state.users,
            self.state.smooth_avatar_yaw,
            avatar_y_offset,
            &mut r,
        );
    }

    /// Get the current state
    pub fn state(&self) -> &NostrverseState {
        &self.state
    }

    /// Get mutable state
    pub fn state_mut(&mut self) -> &mut NostrverseState {
        &mut self.state
    }
}

impl notedeck::App for NostrverseApp {
    fn update(&mut self, ctx: &mut AppContext<'_>, _egui_ctx: &egui::Context) {
        self.initialize(ctx);
        self.poll_space_updates(ctx.ndb);
        self.poll_model_downloads();
        self.tick_presence(ctx);
        self.sync_scene();
    }

    fn render(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse {
        let available = ui.available_size();
        let panel_width = 240.0;

        // Main layout: 3D view + editing panel
        ui.allocate_ui(available, |ui| {
            ui.horizontal(|ui| {
                let room_width = if self.state.edit_mode {
                    available.x - panel_width
                } else {
                    available.x
                };

                ui.allocate_ui(egui::vec2(room_width, available.y), |ui| {
                    if let Some(renderer) = &self.renderer {
                        let response = show_room_view(ui, &mut self.state, renderer);

                        if let Some(action) = response.action {
                            self.handle_action(action, ctx);
                        }
                    } else {
                        ui.centered_and_justified(|ui| {
                            ui.label("3D rendering unavailable (no wgpu)");
                        });
                    }
                });

                // Editing panel (always visible in edit mode)
                if self.state.edit_mode {
                    ui.allocate_ui_with_layout(
                        egui::vec2(panel_width, available.y),
                        egui::Layout::top_down(egui::Align::LEFT),
                        |ui| {
                            egui::Frame::default().inner_margin(8.0).show(ui, |ui| {
                                if let Some(action) = render_editing_panel(ui, &mut self.state) {
                                    self.handle_action(action, ctx);
                                }
                            });
                        },
                    );
                }
            });
        });

        AppResponse::none()
    }
}

impl NostrverseApp {
    fn handle_action(&mut self, action: NostrverseAction, ctx: &mut AppContext<'_>) {
        match action {
            NostrverseAction::MoveObject { id, position } => {
                if let Some(obj) = self.state.get_object_mut(&id) {
                    obj.position = position;
                    self.state.dirty = true;
                }
            }
            NostrverseAction::SelectObject(selected) => {
                // Update renderer outline highlight
                if let Some(renderer) = &self.renderer {
                    let scene_id = selected.as_ref().and_then(|sel_id| {
                        self.state
                            .objects
                            .iter()
                            .find(|o| &o.id == sel_id)
                            .and_then(|o| o.scene_object_id)
                    });
                    renderer.renderer.lock().unwrap().set_selected(scene_id);
                }
                self.state.selected_object = selected;
            }
            NostrverseAction::SaveSpace => {
                self.save_space(ctx);
                self.state.dirty = false;
            }
            NostrverseAction::AddObject(mut obj) => {
                // Try to load model immediately (handles local + cached remote)
                if let Some(url) = obj.model_url.clone() {
                    let local_path = self.model_cache.as_mut().and_then(|c| c.request(&url));
                    if let Some(path) = local_path {
                        obj.model_handle = self.load_model(path.to_str().unwrap_or(&url));
                        if obj.model_handle.is_some()
                            && let Some(cache) = &mut self.model_cache
                        {
                            cache.mark_loaded(&url);
                        }
                    }
                }
                self.state.objects.push(obj);
                self.state.dirty = true;
            }
            NostrverseAction::RemoveObject(id) => {
                remove_object(&id, &mut self.state, self.renderer.as_ref());
            }
            NostrverseAction::RotateObject { id, rotation } => {
                if let Some(obj) = self.state.get_object_mut(&id) {
                    obj.rotation = rotation;
                    self.state.dirty = true;
                }
            }
            NostrverseAction::ResetSpace => {
                if let Ok(space) = protoverse::parse(DEMO_SPACE) {
                    self.apply_space(&space);
                    self.save_space(ctx);
                    self.state.dirty = false;
                }
            }
            NostrverseAction::DuplicateObject(id) => {
                let Some(src) = self.state.objects.iter().find(|o| o.id == id).cloned() else {
                    return;
                };
                let new_id = format!("{}-copy-{}", src.id, self.state.objects.len());
                let mut dup = src;
                dup.id = new_id.clone();
                dup.name = format!("{} (copy)", dup.name);
                dup.position.x += 0.5;
                // Clear scene node — sync_scene will create a new one.
                // Keep model_handle: it's a shared ref to loaded GPU data.
                dup.scene_object_id = None;
                self.state.objects.push(dup);
                self.state.dirty = true;
                self.state.selected_object = Some(new_id);
            }
        }
    }
}

/// Remove an object from both the state and the renderer scene graph.
fn remove_object(
    id: &str,
    state: &mut NostrverseState,
    renderer: Option<&renderbud::egui::EguiRenderer>,
) {
    if let Some(renderer) = renderer {
        let mut r = renderer.renderer.lock().unwrap();
        if let Some(scene_id) = state
            .objects
            .iter()
            .find(|o| o.id == id)
            .and_then(|o| o.scene_object_id)
        {
            r.remove_object(scene_id);
        }
        if state.selected_object.as_deref() == Some(id) {
            r.set_selected(None);
        }
    }
    state.objects.retain(|o| o.id != id);
    if state.selected_object.as_deref() == Some(id) {
        state.selected_object = None;
    }
    state.dirty = true;
}

/// Sync room objects to the renderbud scene graph.
/// Updates transforms for existing objects and places new ones.
fn sync_objects_to_scene(objects: &mut [RoomObject], r: &mut renderbud::Renderer) {
    let mut id_to_scene: std::collections::HashMap<String, renderbud::ObjectId> = objects
        .iter()
        .filter_map(|obj| Some((obj.id.clone(), obj.scene_object_id?)))
        .collect();

    for obj in objects.iter_mut() {
        let transform = Transform {
            translation: obj.position,
            rotation: obj.rotation,
            scale: obj.scale,
        };

        if let Some(scene_id) = obj.scene_object_id {
            r.update_object_transform(scene_id, transform);
        } else if let Some(model) = obj.model_handle {
            let parent_scene_id = obj.location.as_ref().and_then(|loc| match loc {
                room_state::ObjectLocation::TopOf(target_id)
                | room_state::ObjectLocation::Near(target_id) => {
                    id_to_scene.get(target_id).copied()
                }
                _ => None,
            });

            let scene_id = if let Some(parent_id) = parent_scene_id {
                r.place_object_with_parent(model, transform, parent_id)
            } else {
                r.place_object(model, transform)
            };

            obj.scene_object_id = Some(scene_id);
            id_to_scene.insert(obj.id.clone(), scene_id);
        }
    }
}

/// Smoothly interpolate between two yaw angles, wrapping around TAU.
fn lerp_yaw(current: f32, target: f32, speed: f32) -> f32 {
    let mut diff = target - current;
    diff = (diff + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI;
    current + diff * speed.min(1.0)
}

/// Apply dead reckoning to remote users, smoothing their display positions.
fn update_remote_user_positions(users: &mut [RoomUser], dt: f32, now: f64) {
    for user in users.iter_mut() {
        if user.is_self {
            continue;
        }
        let time_since_update = (now - user.update_time).min(MAX_EXTRAPOLATION_TIME) as f32;
        let extrapolated = user.position + user.velocity * time_since_update;

        let offset = extrapolated - user.position;
        let target = if offset.length() > MAX_EXTRAPOLATION_DISTANCE {
            user.position + offset.normalize() * MAX_EXTRAPOLATION_DISTANCE
        } else {
            extrapolated
        };

        let t = (AVATAR_POS_LERP_SPEED * dt).min(1.0);
        user.display_position = user.display_position.lerp(target, t);
    }
}

/// Sync user avatars to the renderbud scene with proper transforms.
fn sync_users_to_scene(
    users: &mut [RoomUser],
    smooth_yaw: f32,
    avatar_y_offset: f32,
    r: &mut renderbud::Renderer,
) {
    for user in users.iter_mut() {
        let yaw = if user.is_self { smooth_yaw } else { 0.0 };

        let transform = Transform {
            translation: user.display_position + Vec3::new(0.0, avatar_y_offset, 0.0),
            rotation: glam::Quat::from_rotation_y(yaw),
            scale: Vec3::splat(AVATAR_SCALE),
        };

        if let Some(scene_id) = user.scene_object_id {
            r.update_object_transform(scene_id, transform);
        } else if let Some(model) = user.model_handle {
            user.scene_object_id = Some(r.place_object(model, transform));
        }
    }
}

/// Resolve semantic locations (top-of, near, floor) to concrete positions
/// using the provided AABB bounds map.
fn resolve_locations(
    objects: &mut [RoomObject],
    bounds_by_id: &std::collections::HashMap<String, renderbud::Aabb>,
) {
    let mut resolved: Vec<(usize, Vec3, Vec3)> = Vec::new();

    for (i, obj) in objects.iter().enumerate() {
        let Some(loc) = &obj.location else {
            continue;
        };

        let local_base = match loc {
            room_state::ObjectLocation::TopOf(target_id) => {
                let target_top = bounds_by_id.get(target_id).map(|b| b.max.y).unwrap_or(0.0);
                let self_half_h = bounds_by_id
                    .get(&obj.id)
                    .map(|b| (b.max.y - b.min.y) * 0.5)
                    .unwrap_or(0.0);
                Some(Vec3::new(0.0, target_top + self_half_h, 0.0))
            }
            room_state::ObjectLocation::Near(target_id) => {
                let offset = bounds_by_id
                    .get(target_id)
                    .map(|b| b.max.x - b.min.x)
                    .unwrap_or(1.0);
                Some(Vec3::new(offset, 0.0, 0.0))
            }
            room_state::ObjectLocation::Floor => {
                let self_half_h = bounds_by_id
                    .get(&obj.id)
                    .map(|b| (b.max.y - b.min.y) * 0.5)
                    .unwrap_or(0.0);
                Some(Vec3::new(0.0, self_half_h, 0.0))
            }
            _ => None,
        };

        if let Some(base) = local_base {
            resolved.push((i, base, base + obj.position));
        }
    }

    for (i, base, pos) in resolved {
        objects[i].location_base = Some(base);
        objects[i].position = pos;
    }
}

/// Collect AABB bounds for all objects that have a loaded model.
fn collect_bounds(
    renderer: Option<&renderbud::egui::EguiRenderer>,
    objects: &[RoomObject],
) -> std::collections::HashMap<String, renderbud::Aabb> {
    let mut bounds = std::collections::HashMap::new();
    let Some(renderer) = renderer else {
        return bounds;
    };
    let r = renderer.renderer.lock().unwrap();
    for obj in objects {
        if let Some(model) = obj.model_handle
            && let Some(b) = r.model_bounds(model)
        {
            bounds.insert(obj.id.clone(), b);
        }
    }
    bounds
}

/// Re-resolve semantic locations (top-of, near, floor) using current model bounds.
fn resolve_object_locations(
    renderer: Option<&renderbud::egui::EguiRenderer>,
    objects: &mut [RoomObject],
) {
    let bounds_by_id = collect_bounds(renderer, objects);
    resolve_locations(objects, &bounds_by_id);
}
