//! Explicitly gated, credential-safe live-grid smoke workflow.

#![allow(clippy::too_many_lines)] // The stage order is deliberately linear and auditable.

use crate::completeness::audit_program_completeness;
use clap::Parser;
use libremetaverse::packets::{Packet, PacketType, RegionHandshakePacket};
use libremetaverse::types::compat::{CancellationToken, Subscription};
use libremetaverse::types::{AssetType, FolderType, UUID};
use libremetaverse::{
    ChatEventArgs, ChatType, ClientLifecycleState, GridClient, InstantMessageEventArgs,
    NetworkManager, PacketReceivedEventArgs, Primitive, PrimitiveTextureEntry, Simulator,
};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs::{File, OpenOptions};
use std::future::Future;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::mpsc;

pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_USAGE: u8 = 2;
pub const EXIT_INPUT: u8 = 3;
pub const EXIT_GRID: u8 = 4;
const LIVE_CONFIRMATION: &str = "LOGIN";
const CHAT_CONFIRMATION: &str = "CHAT";
const MOVEMENT_CONFIRMATION: &str = "MOVE";
const INVENTORY_CONFIRMATION: &str = "CREATE-MOVE-TO-TRASH";
const MAX_TEXTURE_BYTES: usize = 128 * 1024 * 1024;
const OPENSIM_LOGIN_OPTIONS: [&str; 6] = [
    "avatar_picker_url",
    "classified_fee",
    "currency",
    "destination_guide_url",
    "profile-server-url",
    "search",
];
const OPENSIM_CORE_CAPABILITIES: [&str; 7] = [
    "EventQueueGet",
    "FetchInventory2",
    "FetchInventoryDescendents2",
    "GetTexture",
    "SimulatorFeatures",
    "ViewerAsset",
    "AvatarPickerSearch",
];

#[derive(Parser)]
#[command(
    name = "live-grid-smoke",
    version,
    about = "Run the native credential-safe milestone program smoke workflow",
    arg_required_else_help = true,
    after_help = "Live credentials are read only from GRID_USER, GRID_PASSWORD, and GRID_LOGIN_URL in the process environment or workspace .env. The configured URI is used directly as an OpenSim/compatible-grid LLSD login endpoint, with OpenSim response options enabled. A complete live run requires explicit LOGIN, CHAT, MOVE, and CREATE-MOVE-TO-TRASH confirmations. The harness never spends L$, uploads assets, permanently deletes inventory, changes estate/parcel state, or targets another user. --fake exercises the identical evidence stages without credentials or network access."
)]
#[allow(clippy::struct_excessive_bools)]
struct Cli {
    /// Run every smoke/evidence stage against the deterministic in-process backend.
    #[arg(long)]
    fake: bool,
    /// Audit all original programs and `TestClient` commands without creating evidence.
    #[arg(long)]
    audit_only: bool,
    /// New JSONL evidence file; existing files are never overwritten.
    #[arg(long, value_name = "FILE")]
    evidence: Option<PathBuf>,
    /// Explicitly permit transmission of the configured grid credentials.
    #[arg(long)]
    allow_live_login: bool,
    /// Required literal confirmation for a live login.
    #[arg(long, value_name = "LOGIN")]
    confirm_live_login: Option<String>,
    /// Permit one clearly marked local-chat message from the dedicated account.
    #[arg(long, requires = "allow_live_login")]
    allow_public_chat: bool,
    /// Required literal confirmation for local chat.
    #[arg(long, value_name = "CHAT")]
    confirm_public_chat: Option<String>,
    /// Permit a brief movement pulse and teleport back to the starting position.
    #[arg(long, requires = "allow_live_login")]
    allow_agent_movement: bool,
    /// Required literal confirmation for movement/teleport.
    #[arg(long, value_name = "MOVE")]
    confirm_agent_movement: Option<String>,
    /// Permit creation of one smoke folder followed by a reversible move to Trash.
    #[arg(long, requires = "allow_live_login")]
    allow_reversible_inventory: bool,
    /// Required literal confirmation for the reversible folder operation.
    #[arg(long, value_name = "CREATE-MOVE-TO-TRASH")]
    confirm_reversible_inventory: Option<String>,
    /// Per-stage timeout.
    #[arg(long, default_value_t = 45, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))]
    timeout_seconds: u64,
}

#[derive(Clone, Eq, PartialEq)]
struct SmokeSecret(String);

impl fmt::Debug for SmokeSecret {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("SmokeSecret(<redacted>)")
    }
}

#[derive(Clone)]
pub struct SmokeConfig {
    first_name: String,
    last_name: String,
    password: SmokeSecret,
    login_url: SmokeSecret,
}

impl fmt::Debug for SmokeConfig {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("SmokeConfig")
            .field("identity", &"<redacted>")
            .field("password", &self.password)
            .field("login_url", &self.login_url)
            .finish_non_exhaustive()
    }
}

impl SmokeConfig {
    /// Loads the one shared live-smoke identity from the process environment or
    /// workspace `.env`. Values are never printed or written to evidence.
    ///
    /// # Errors
    ///
    /// Returns an error when a required credential is missing or `GRID_USER`
    /// does not contain exactly a first and last name.
    pub fn load() -> Result<Self, SmokeError> {
        let full_name = credential("GRID_USER").ok_or(SmokeError::Config(
            "GRID_USER is required in the environment or workspace .env",
        ))?;
        let mut names = full_name.split_whitespace();
        let (Some(first_name), Some(last_name), None) = (names.next(), names.next(), names.next())
        else {
            return Err(SmokeError::Config(
                "GRID_USER must contain exactly FIRSTNAME LASTNAME",
            ));
        };
        Ok(Self {
            first_name: first_name.to_owned(),
            last_name: last_name.to_owned(),
            password: SmokeSecret(credential("GRID_PASSWORD").ok_or(SmokeError::Config(
                "GRID_PASSWORD is required in the environment or workspace .env",
            ))?),
            login_url: SmokeSecret(credential("GRID_LOGIN_URL").ok_or(SmokeError::Config(
                "GRID_LOGIN_URL is required in the environment or workspace .env",
            ))?),
        })
    }

    fn forbidden_values(&self) -> Vec<String> {
        vec![
            self.first_name.clone(),
            self.last_name.clone(),
            format!("{} {}", self.first_name, self.last_name),
            self.password.0.clone(),
            self.login_url.0.clone(),
        ]
    }
}

#[derive(Debug)]
pub enum SmokeError {
    Usage(&'static str),
    Config(&'static str),
    Input(io::Error),
    Evidence(&'static str),
    Stage(&'static str),
}

impl SmokeError {
    const fn exit_code(&self) -> u8 {
        match self {
            Self::Usage(_) | Self::Config(_) => EXIT_USAGE,
            Self::Input(_) | Self::Evidence(_) => EXIT_INPUT,
            Self::Stage(_) => EXIT_GRID,
        }
    }
}

impl fmt::Display for SmokeError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Usage(message)
            | Self::Config(message)
            | Self::Evidence(message)
            | Self::Stage(message) => formatter.write_str(message),
            Self::Input(error) => error.fmt(formatter),
        }
    }
}

impl From<io::Error> for SmokeError {
    fn from(error: io::Error) -> Self {
        Self::Input(error)
    }
}

#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "kebab-case")]
enum Stage {
    Completeness,
    Login,
    CapabilitiesSimulator,
    ImChat,
    MovementTeleport,
    InventoryFolder,
    ObjectProperties,
    AssetTexture,
    Logout,
}

#[derive(Serialize)]
struct EvidenceRecord {
    schema: u8,
    sequence: u8,
    recorded_unix_seconds: u64,
    program_version: &'static str,
    rust_commit: &'static str,
    stage: Stage,
    status: &'static str,
    metrics: BTreeMap<&'static str, u64>,
}

struct EvidenceWriter {
    writer: BufWriter<File>,
    sequence: u8,
    forbidden: Vec<String>,
}

impl EvidenceWriter {
    fn create(path: &Path, forbidden: Vec<String>) -> Result<Self, SmokeError> {
        let file = OpenOptions::new().write(true).create_new(true).open(path)?;
        Ok(Self {
            writer: BufWriter::new(file),
            sequence: 0,
            forbidden: forbidden
                .into_iter()
                .filter(|value| value.len() >= 4)
                .collect(),
        })
    }

    fn success(
        &mut self,
        stage: Stage,
        metrics: BTreeMap<&'static str, u64>,
    ) -> Result<(), SmokeError> {
        self.record(stage, "ok", metrics)
    }

    fn failure(&mut self, stage: Stage) -> Result<(), SmokeError> {
        self.record(stage, "failed", BTreeMap::new())
    }

    fn record(
        &mut self,
        stage: Stage,
        status: &'static str,
        metrics: BTreeMap<&'static str, u64>,
    ) -> Result<(), SmokeError> {
        self.sequence = self
            .sequence
            .checked_add(1)
            .ok_or(SmokeError::Evidence("too many evidence records"))?;
        let line = serde_json::to_string(&EvidenceRecord {
            schema: 1,
            sequence: self.sequence,
            recorded_unix_seconds: SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map_err(|_| SmokeError::Evidence("system clock predates the Unix epoch"))?
                .as_secs(),
            program_version: env!("CARGO_PKG_VERSION"),
            rust_commit: env!("METACRATE_RUST_COMMIT"),
            stage,
            status,
            metrics,
        })
        .map_err(|_| SmokeError::Evidence("could not serialize smoke evidence"))?;
        if self.forbidden.iter().any(|value| line.contains(value))
            || line.contains("://")
            || line.to_ascii_lowercase().contains("token")
            || line.to_ascii_lowercase().contains("password")
        {
            return Err(SmokeError::Evidence(
                "evidence sanitizer rejected a secret-bearing record",
            ));
        }
        writeln!(self.writer, "{line}")?;
        self.writer.flush()?;
        println!("smoke stage {stage:?}: {status}");
        Ok(())
    }

    fn finish(mut self) -> Result<(), SmokeError> {
        self.writer.flush()?;
        self.writer.get_ref().sync_all()?;
        Ok(())
    }
}

type SmokeFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;

trait SmokeBackend {
    fn login(&mut self) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>>;
    fn capabilities_simulator(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>>;
    fn im_chat(&mut self) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>>;
    fn movement_teleport(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>>;
    fn inventory_folder(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>>;
    fn object_properties(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>>;
    fn asset_texture(&mut self)
    -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>>;
    fn logout(&mut self) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>>;
}

struct FakeSmokeBackend {
    logged_in: bool,
    texture_ready: bool,
}

impl FakeSmokeBackend {
    const fn new() -> Self {
        Self {
            logged_in: false,
            texture_ready: false,
        }
    }

    fn require_login(&self) -> Result<(), SmokeError> {
        self.logged_in
            .then_some(())
            .ok_or(SmokeError::Stage("fake smoke backend is not logged in"))
    }
}

impl SmokeBackend for FakeSmokeBackend {
    fn login(&mut self) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            self.logged_in = true;
            Ok(metrics(&[
                ("authenticated", 1),
                ("client_version_recorded", 1),
                (
                    "rust_commit_recorded",
                    u64::from(env!("METACRATE_RUST_COMMIT") != "unavailable"),
                ),
            ]))
        })
    }

    fn capabilities_simulator(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            self.require_login()?;
            Ok(metrics(&[
                ("event_queue_running", 1),
                ("known_capabilities", 5),
                ("cached_primitives", 2),
            ]))
        })
    }

    fn im_chat(&mut self) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            self.require_login()?;
            Ok(metrics(&[
                ("self_im_transmitted", 1),
                ("self_im_echo", 1),
                ("local_chat_echo", 1),
            ]))
        })
    }

    fn movement_teleport(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            self.require_login()?;
            Ok(metrics(&[
                ("movement_stopped", 1),
                ("teleport_returned", 1),
            ]))
        })
    }

    fn inventory_folder(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            self.require_login()?;
            Ok(metrics(&[("folder_created", 1), ("moved_to_trash", 1)]))
        })
    }

    fn object_properties(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            self.require_login()?;
            self.texture_ready = true;
            Ok(metrics(&[
                ("object_observed", 1),
                ("properties_received", 1),
            ]))
        })
    }

    fn asset_texture(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            self.require_login()?;
            if !self.texture_ready {
                return Err(SmokeError::Stage("fake texture was not discovered"));
            }
            Ok(metrics(&[
                ("texture_downloaded", 1),
                ("texture_bytes", 4096),
            ]))
        })
    }

    fn logout(&mut self) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            self.logged_in = false;
            Ok(metrics(&[("clean_logout", 1), ("active_tasks", 0)]))
        })
    }
}

struct LiveSmokeBackend {
    config: SmokeConfig,
    timeout: Duration,
    client: Option<GridClient>,
    network: Option<NetworkManager>,
    texture: Option<UUID>,
    scene_traffic: Arc<SceneTraffic>,
    scene_subscription: Option<Subscription>,
}

#[derive(Default)]
struct SceneTraffic {
    handshake_identity_present: AtomicBool,
    movement_complete: AtomicU64,
    full_object_updates: AtomicU64,
    compressed_object_updates: AtomicU64,
    terse_object_updates: AtomicU64,
}

impl SceneTraffic {
    fn observe(&self, event: &PacketReceivedEventArgs) {
        let packet_type = event.packet().type_;
        match packet_type {
            PacketType::RegionHandshake => {
                if event.raw_data().is_some_and(handshake_identity_present) {
                    self.handshake_identity_present
                        .store(true, Ordering::Release);
                }
            }
            PacketType::AgentMovementComplete => {
                self.movement_complete.fetch_add(1, Ordering::Relaxed);
            }
            PacketType::ObjectUpdate => {
                self.full_object_updates.fetch_add(1, Ordering::Relaxed);
            }
            PacketType::ObjectUpdateCompressed => {
                self.compressed_object_updates
                    .fetch_add(1, Ordering::Relaxed);
            }
            PacketType::ImprovedTerseObjectUpdate => {
                self.terse_object_updates.fetch_add(1, Ordering::Relaxed);
            }
            _ => {}
        }
    }
}

fn handshake_identity_present(bytes: Vec<u8>) -> bool {
    let Ok(length) = i32::try_from(bytes.len()) else {
        return false;
    };
    let Some(mut packet_end) = length.checked_sub(1) else {
        return false;
    };
    let Ok(mut packet) = RegionHandshakePacket::new_with_constructor() else {
        return false;
    };
    let mut position = 0_i32;
    let mut zero_buffer = vec![0_u8; 65_536];
    packet
        .from_bytes_with_bytes_int32_int32_bytes(
            bytes,
            &mut position,
            &mut packet_end,
            Some(zero_buffer.as_mut_slice()),
        )
        .is_ok()
        && packet
            .region_info
            .sim_name
            .iter()
            .any(|byte| *byte != 0 && !byte.is_ascii_whitespace())
}

fn wire_packet_type(bytes: Vec<u8>) -> Option<PacketType> {
    let mut packet_end = i32::try_from(bytes.len()).ok()?.checked_sub(1)?;
    Packet::build_packet_with_bytes_int32_bytes(bytes, &mut packet_end, vec![0_u8; 65_536])
        .ok()
        .map(|packet| packet.type_)
}

impl LiveSmokeBackend {
    fn new(config: SmokeConfig, timeout: Duration) -> Self {
        Self {
            config,
            timeout,
            client: None,
            network: None,
            texture: None,
            scene_traffic: Arc::new(SceneTraffic {
                handshake_identity_present: AtomicBool::new(false),
                movement_complete: AtomicU64::new(0),
                full_object_updates: AtomicU64::new(0),
                compressed_object_updates: AtomicU64::new(0),
                terse_object_updates: AtomicU64::new(0),
            }),
            scene_subscription: None,
        }
    }

    fn client(&self) -> Result<&GridClient, SmokeError> {
        self.client
            .as_ref()
            .ok_or(SmokeError::Stage("live smoke client is not logged in"))
    }

    fn client_mut(&mut self) -> Result<&mut GridClient, SmokeError> {
        self.client
            .as_mut()
            .ok_or(SmokeError::Stage("live smoke client is not logged in"))
    }

    fn network(&self) -> Result<&NetworkManager, SmokeError> {
        self.network
            .as_ref()
            .ok_or(SmokeError::Stage("live smoke network is unavailable"))
    }

    async fn current_simulator(&self) -> Result<Simulator, SmokeError> {
        let network = self.network()?.clone();
        tokio::time::timeout(self.timeout, async move {
            loop {
                if let Some(simulator) = network.current_sim()
                    && simulator
                        .is_event_queue_running(Some(false))
                        .unwrap_or(false)
                {
                    return simulator;
                }
                tokio::time::sleep(Duration::from_millis(100)).await;
            }
        })
        .await
        .map_err(|_| SmokeError::Stage("event queue did not become ready"))
    }
}

impl SmokeBackend for LiveSmokeBackend {
    fn login(&mut self) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            if env!("METACRATE_RUST_COMMIT") == "unavailable" {
                return Err(SmokeError::Stage(
                    "live smoke requires recorded Rust source provenance",
                ));
            }
            let mut client = GridClient::new()
                .map_err(|_| SmokeError::Stage("could not construct live grid client"))?;
            // The smoke proves a live transfer from the grid but must not retain grid
            // content after the run. Disable the library's on-disk asset cache before
            // constructing AssetManager so the texture bytes remain memory-only.
            client.settings().asset_cache_mut().enabled = false;
            // These services install their packet consumers lazily. OpenSim sends the
            // movement-complete, initial scene, and inventory bootstrap packets during
            // login, so constructing them afterwards permanently loses that evidence.
            // Keep the client-owned services ready before credentials cross the wire.
            let _ = client.self_();
            let _ = client.objects();
            let _ = client.inventory();
            let _ = client.assets();
            let network = client.network();
            let traffic = Arc::clone(&self.scene_traffic);
            self.scene_subscription = Some(network.subscribe_packet(
                PacketType::Default,
                Arc::new(move |event| traffic.observe(&event)),
                false,
            ));
            let mut login = network
                .default_login_params(
                    self.config.first_name.clone(),
                    self.config.last_name.clone(),
                    self.config.password.0.clone(),
                    "MetaCrate Live Smoke".into(),
                    env!("CARGO_PKG_VERSION").into(),
                )
                .map_err(|_| SmokeError::Stage("could not construct grid login parameters"))?;
            login.uri = self.config.login_url.0.clone();
            for option in OPENSIM_LOGIN_OPTIONS {
                if !login.options.iter().any(|existing| existing == option) {
                    login.options.push(option.to_owned());
                }
            }
            let authenticated = tokio::time::timeout(
                self.timeout,
                network.login_with_login_params_cancellation_token(login, None),
            )
            .await
            .map_err(|_| SmokeError::Stage("live grid login timed out"))?
            .map_err(|_| SmokeError::Stage("live grid login failed"))?;
            if !authenticated {
                return Err(SmokeError::Stage("live grid rejected the smoke account"));
            }
            self.client = Some(client);
            self.network = Some(network);
            let _ = self.current_simulator().await?;
            Ok(metrics(&[
                ("authenticated", 1),
                ("disk_asset_cache_enabled", 0),
                ("opensim_login_options", as_u64(OPENSIM_LOGIN_OPTIONS.len())),
                ("packet_consumers_ready", 4),
            ]))
        })
    }

    fn capabilities_simulator(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            let simulator = self.current_simulator().await?;
            // OpenSim may finish the seed-capability/event-queue bootstrap after the
            // initial UDP transition packet. Repeating the idempotent final handshake
            // once the event queue is live makes the simulator publish the agent and
            // its initial scene to headless clients as it does to interactive viewers.
            self.client_mut()?
                .self_()
                .complete_agent_movement(simulator.clone())
                .map_err(|_| SmokeError::Stage("OpenSim scene bootstrap request failed"))?;
            let known_capabilities = OPENSIM_CORE_CAPABILITIES
                .into_iter()
                .filter(|name| {
                    simulator
                        .native_capability_uri(name)
                        .ok()
                        .flatten()
                        .is_some()
                })
                .count();
            let cached_primitives = tokio::time::timeout(self.timeout, async {
                loop {
                    let count = simulator
                        .objects_primitives
                        .read()
                        .unwrap_or_else(std::sync::PoisonError::into_inner)
                        .len();
                    if count > 0 {
                        return count;
                    }
                    tokio::time::sleep(Duration::from_millis(100)).await;
                }
            })
            .await;
            let Ok(cached_primitives) = cached_primitives else {
                let movement_complete =
                    self.scene_traffic.movement_complete.load(Ordering::Relaxed);
                let full_updates = self
                    .scene_traffic
                    .full_object_updates
                    .load(Ordering::Relaxed);
                let compressed_updates = self
                    .scene_traffic
                    .compressed_object_updates
                    .load(Ordering::Relaxed);
                let terse_updates = self
                    .scene_traffic
                    .terse_object_updates
                    .load(Ordering::Relaxed);
                let cached_avatars = simulator
                    .objects_avatars
                    .read()
                    .unwrap_or_else(std::sync::PoisonError::into_inner)
                    .len();
                eprintln!(
                    "sanitized OpenSim scene counts: movement-complete={movement_complete} full={full_updates} compressed={compressed_updates} terse={terse_updates} avatars={cached_avatars}"
                );
                return Err(if movement_complete == 0 {
                    SmokeError::Stage("OpenSim did not acknowledge the agent movement transition")
                } else if full_updates == 0 && compressed_updates == 0 && cached_avatars == 0 {
                    SmokeError::Stage("OpenSim published no scene object updates")
                } else if full_updates > 0 || compressed_updates > 0 {
                    SmokeError::Stage(
                        "OpenSim scene object updates did not populate the primitive cache",
                    )
                } else {
                    SmokeError::Stage("OpenSim scene contained avatars but no primitives")
                });
            };
            let identity_present = self
                .scene_traffic
                .handshake_identity_present
                .load(Ordering::Acquire);
            if !identity_present || simulator.seed_capability().is_none() || known_capabilities == 0
            {
                return Err(SmokeError::Stage(
                    "simulator identity or capability bootstrap was incomplete",
                ));
            }
            Ok(metrics(&[
                ("event_queue_running", 1),
                ("simulator_identity_present", u64::from(identity_present)),
                (
                    "seed_capability_present",
                    u64::from(simulator.seed_capability().is_some()),
                ),
                ("known_capabilities", as_u64(known_capabilities)),
                ("cached_primitives", as_u64(cached_primitives)),
                ("scene_bootstrap_requested", 1),
                (
                    "movement_complete_packets",
                    self.scene_traffic.movement_complete.load(Ordering::Relaxed),
                ),
                (
                    "full_object_update_packets",
                    self.scene_traffic
                        .full_object_updates
                        .load(Ordering::Relaxed),
                ),
                (
                    "compressed_object_update_packets",
                    self.scene_traffic
                        .compressed_object_updates
                        .load(Ordering::Relaxed),
                ),
                (
                    "terse_object_update_packets",
                    self.scene_traffic
                        .terse_object_updates
                        .load(Ordering::Relaxed),
                ),
            ]))
        })
    }

    fn im_chat(&mut self) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            enum SocialEvent {
                Im(UUID, String),
                Chat(UUID, String),
            }
            let timeout = self.timeout;
            let client = self.client_mut()?;
            let own_id = client.self_().agent_id();
            let marker_id = UUID::random()
                .map_err(|_| SmokeError::Stage("could not create social smoke marker"))?;
            let marker = format!("MetaCrate smoke {marker_id}");
            let observed_im = Arc::new(AtomicBool::new(false));
            let observed_chat = Arc::new(AtomicBool::new(false));
            let transmitted_im = Arc::new(AtomicBool::new(false));
            let (sender, mut receiver) = mpsc::channel(16);
            let im_sender = sender.clone();
            let im_observation = Arc::clone(&observed_im);
            let im_marker = marker.clone();
            let im: Subscription =
                client
                    .self_()
                    .subscribe_im(Arc::new(move |event: InstantMessageEventArgs| {
                        let message = event.im();
                        if message.from_agent_id == own_id && message.message == im_marker {
                            im_observation.store(true, Ordering::Release);
                        }
                        let _ = im_sender
                            .try_send(SocialEvent::Im(message.from_agent_id, message.message));
                    }));
            let chat_sender = sender;
            let chat_observation = Arc::clone(&observed_chat);
            let chat_marker = marker.clone();
            let chat: Subscription = client.self_().subscribe_chat_from_simulator(Arc::new(
                move |event: ChatEventArgs| {
                    if event.source_id() == own_id && event.message() == chat_marker {
                        chat_observation.store(true, Ordering::Release);
                    }
                    let _ =
                        chat_sender.try_send(SocialEvent::Chat(event.source_id(), event.message()));
                },
            ));
            let im_transmission = Arc::clone(&transmitted_im);
            let sent = client
                .network()
                .subscribe_packet_sent(Arc::new(move |event| {
                    let mut data = event.data();
                    data.truncate(usize::try_from(event.sent_bytes()).unwrap_or_default());
                    if wire_packet_type(data) == Some(PacketType::ImprovedInstantMessage) {
                        im_transmission.store(true, Ordering::Release);
                    }
                }));
            client
                .self_()
                .instant_message_with_uuid_string(own_id, marker.clone())
                .map_err(|_| SmokeError::Stage("self instant-message send failed"))?;
            client
                .self_()
                .chat(marker.clone(), 0, ChatType::Normal, Some(false))
                .map_err(|_| SmokeError::Stage("local-chat smoke send failed"))?;
            let echoes = tokio::time::timeout(timeout, async {
                let mut saw_im = false;
                let mut saw_chat = false;
                while !(saw_chat && (saw_im || transmitted_im.load(Ordering::Acquire))) {
                    match receiver.recv().await {
                        Some(SocialEvent::Im(source, message)) => {
                            saw_im |= source == own_id && message == marker;
                        }
                        Some(SocialEvent::Chat(source, message)) => {
                            saw_chat |= source == own_id && message == marker;
                        }
                        None => break,
                    }
                }
                (saw_im, saw_chat)
            })
            .await;
            drop((im, chat, sent));
            let (self_im_echo, local_chat_echo) = echoes.unwrap_or_else(|_| {
                (
                    observed_im.load(Ordering::Acquire),
                    observed_chat.load(Ordering::Acquire),
                )
            });
            let self_im_transmitted = transmitted_im.load(Ordering::Acquire);
            if !self_im_transmitted || !local_chat_echo {
                eprintln!(
                    "sanitized OpenSim social evidence: self-im-transmitted={} self-im-echo={} local-chat-echo={}",
                    u8::from(self_im_transmitted),
                    u8::from(self_im_echo),
                    u8::from(local_chat_echo)
                );
                return Err(SmokeError::Stage("IM/chat smoke evidence was incomplete"));
            }
            Ok(metrics(&[
                ("self_im_transmitted", 1),
                ("self_im_echo", u64::from(self_im_echo)),
                ("local_chat_echo", 1),
            ]))
        })
    }

    fn movement_teleport(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            let simulator = self.current_simulator().await?;
            let timeout = self.timeout;
            let agent = self.client_mut()?.self_();
            let original = agent.sim_position();
            agent.movement.set_at_pos(true);
            agent
                .movement
                .send_update_with_boolean(Some(false))
                .map_err(|_| SmokeError::Stage("movement smoke update failed"))?;
            tokio::time::sleep(Duration::from_millis(250)).await;
            agent.movement.set_at_pos(false);
            agent
                .movement
                .send_update_with_boolean(Some(true))
                .map_err(|_| SmokeError::Stage("movement smoke stop failed"))?;
            let teleport = agent.teleport_with_u_int64_vector3_cancellation_token(
                simulator.handle,
                original,
                Some(CancellationToken::default()),
            );
            let returned = tokio::time::timeout(timeout, teleport)
                .await
                .map_err(|_| SmokeError::Stage("return teleport timed out"))?
                .map_err(|_| SmokeError::Stage("return teleport failed"))?;
            if !returned {
                return Err(SmokeError::Stage("return teleport was rejected"));
            }
            Ok(metrics(&[
                ("movement_stopped", 1),
                ("teleport_returned", 1),
            ]))
        })
    }

    fn inventory_folder(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            let manager = self.client()?.inventory();
            let (root, trash) = tokio::time::timeout(self.timeout, async {
                loop {
                    if let Some(store) = manager.store()
                        && let Some(root) = store.root_folder()
                        && let Ok(trash) =
                            manager.find_folder_for_type_with_folder_type(FolderType::Trash)
                    {
                        return (root.base.uuid(), trash);
                    }
                    tokio::time::sleep(Duration::from_millis(100)).await;
                }
            })
            .await
            .map_err(|_| SmokeError::Stage("inventory root/trash did not become ready"))?;
            let marker = UUID::random()
                .map_err(|_| SmokeError::Stage("could not create inventory smoke marker"))?;
            let folder = manager
                .create_folder_with_uuid_string(root, format!("MetaCrate Smoke {marker}"))
                .map_err(|_| SmokeError::Stage("smoke folder creation failed"))?;
            tokio::time::timeout(
                self.timeout,
                manager.move_folder_with_uuid_uuid_cancellation_token_4497f975(
                    folder,
                    trash,
                    Some(CancellationToken::default()),
                ),
            )
            .await
            .map_err(|_| SmokeError::Stage("smoke folder move-to-trash timed out"))?
            .map_err(|_| SmokeError::Stage("smoke folder move-to-trash failed"))?;
            Ok(metrics(&[("folder_created", 1), ("moved_to_trash", 1)]))
        })
    }

    fn object_properties(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            let simulator = self.current_simulator().await?;
            let primitive = simulator
                .objects_primitives
                .read()
                .unwrap_or_else(std::sync::PoisonError::into_inner)
                .values()
                .find(|primitive| first_texture(primitive).is_some())
                .cloned()
                .ok_or(SmokeError::Stage(
                    "no textured primitive was available for object smoke",
                ))?;
            self.texture = first_texture(&primitive);
            let property_received = if primitive.properties.is_some() {
                true
            } else {
                let objects = self.client()?.objects();
                let (sender, receiver) = tokio::sync::oneshot::channel();
                let sender = Arc::new(Mutex::new(Some(sender)));
                let callback = Arc::clone(&sender);
                let object_id = primitive.id;
                let subscription = objects.subscribe_object_properties(Arc::new(move |event| {
                    if event.properties().object_id == object_id
                        && let Some(sender) = lock(&callback).take()
                    {
                        let _ = sender.send(());
                    }
                }));
                objects
                    .select_object_with_simulator_u_int32(simulator.clone(), primitive.local_id)
                    .map_err(|_| SmokeError::Stage("object property request failed"))?;
                let property_arrived = tokio::time::timeout(self.timeout, receiver).await.is_ok();
                let _ = objects.deselect_object(simulator, primitive.local_id);
                drop(subscription);
                property_arrived
            };
            if !property_received {
                return Err(SmokeError::Stage("object properties were not received"));
            }
            Ok(metrics(&[
                ("object_observed", 1),
                ("properties_received", 1),
            ]))
        })
    }

    fn asset_texture(
        &mut self,
    ) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            let texture = self
                .texture
                .ok_or(SmokeError::Stage("object smoke did not discover a texture"))?;
            let assets = self.client()?.assets();
            let request = assets.request_asset_with_uuid_asset_type_boolean_cancellation_token(
                texture,
                AssetType::Texture,
                true,
                Some(CancellationToken::default()),
            );
            let asset = tokio::time::timeout(self.timeout, request)
                .await
                .map_err(|_| SmokeError::Stage("texture download timed out"))?
                .map_err(|_| SmokeError::Stage("texture download failed"))?
                .ok_or(SmokeError::Stage("texture was not returned"))?;
            if asset.asset_data.is_empty() || asset.asset_data.len() > MAX_TEXTURE_BYTES {
                return Err(SmokeError::Stage(
                    "texture response was empty or exceeded 128 MiB",
                ));
            }
            Ok(metrics(&[
                ("texture_downloaded", 1),
                ("texture_bytes", as_u64(asset.asset_data.len())),
            ]))
        })
    }

    fn logout(&mut self) -> SmokeFuture<'_, Result<BTreeMap<&'static str, u64>, SmokeError>> {
        Box::pin(async move {
            let (network_ok, disconnected, pending_logout_tasks) =
                self.network.take().map_or((true, true, 0), |network| {
                    let result = network.logout_with_method();
                    (
                        result.is_ok(),
                        !network.connected(),
                        network.pending_logout_tasks(),
                    )
                });
            let (client_ok, client_disposed) = if let Some(mut client) = self.client.take() {
                let agent_ok = client.self_().dispose().is_ok();
                let shutdown_ok = client.dispose_with_method().is_ok();
                (
                    agent_ok && shutdown_ok,
                    client.lifecycle_state() == ClientLifecycleState::Disposed,
                )
            } else {
                (true, true)
            };
            if !network_ok
                || !disconnected
                || pending_logout_tasks != 0
                || !client_ok
                || !client_disposed
            {
                return Err(SmokeError::Stage("live grid cleanup failed"));
            }
            Ok(metrics(&[
                ("clean_logout", 1),
                ("network_disconnected", 1),
                ("client_disposed", 1),
                ("active_tasks", 0),
            ]))
        })
    }
}

/// Runs the live-grid smoke command.
#[must_use]
pub fn main_entry() -> ExitCode {
    let cli = Cli::parse();
    let Ok(runtime) = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
    else {
        eprintln!("live-grid-smoke: could not initialize async runtime");
        return ExitCode::from(EXIT_GRID);
    };
    let result = runtime.block_on(run(cli));
    runtime.shutdown_timeout(Duration::from_secs(2));
    match result {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("live-grid-smoke: {error}");
            ExitCode::from(error.exit_code())
        }
    }
}

async fn run(cli: Cli) -> Result<(), SmokeError> {
    if cli.audit_only {
        if cli.fake
            || cli.evidence.is_some()
            || cli.allow_live_login
            || cli.allow_public_chat
            || cli.allow_agent_movement
            || cli.allow_reversible_inventory
        {
            return Err(SmokeError::Usage(
                "--audit-only cannot be combined with smoke or live options",
            ));
        }
        let report = audit_program_completeness().map_err(|_| {
            SmokeError::Stage("program/source completeness audit did not reach 100%")
        })?;
        println!(
            "Program completeness: programs={} TestClient-commands={} pending-programs=0 pending-commands=0",
            report.programs, report.test_client_commands
        );
        return Ok(());
    }
    let evidence = cli.evidence.as_deref().ok_or(SmokeError::Usage(
        "--evidence FILE is required for fake and live smoke runs",
    ))?;
    if cli.fake {
        if cli.allow_live_login
            || cli.confirm_live_login.is_some()
            || cli.allow_public_chat
            || cli.confirm_public_chat.is_some()
            || cli.allow_agent_movement
            || cli.confirm_agent_movement.is_some()
            || cli.allow_reversible_inventory
            || cli.confirm_reversible_inventory.is_some()
        {
            return Err(SmokeError::Usage(
                "live permissions and confirmations cannot be combined with --fake",
            ));
        }
        let mut writer = EvidenceWriter::create(evidence, Vec::new())?;
        let mut backend = FakeSmokeBackend::new();
        run_smoke(&mut backend, &mut writer).await?;
        writer.finish()?;
        return Ok(());
    }
    if !cli.allow_live_login || cli.confirm_live_login.as_deref() != Some(LIVE_CONFIRMATION) {
        return Err(SmokeError::Usage(
            "live smoke requires --allow-live-login --confirm-live-login LOGIN",
        ));
    }
    if !cli.allow_public_chat || cli.confirm_public_chat.as_deref() != Some(CHAT_CONFIRMATION) {
        return Err(SmokeError::Usage(
            "complete live smoke requires --allow-public-chat --confirm-public-chat CHAT",
        ));
    }
    if !cli.allow_agent_movement
        || cli.confirm_agent_movement.as_deref() != Some(MOVEMENT_CONFIRMATION)
    {
        return Err(SmokeError::Usage(
            "complete live smoke requires --allow-agent-movement --confirm-agent-movement MOVE",
        ));
    }
    if !cli.allow_reversible_inventory
        || cli.confirm_reversible_inventory.as_deref() != Some(INVENTORY_CONFIRMATION)
    {
        return Err(SmokeError::Usage(
            "complete live smoke requires --allow-reversible-inventory --confirm-reversible-inventory CREATE-MOVE-TO-TRASH",
        ));
    }
    let config = SmokeConfig::load()?;
    let mut writer = EvidenceWriter::create(evidence, config.forbidden_values())?;
    let mut backend = LiveSmokeBackend::new(config, Duration::from_secs(cli.timeout_seconds));
    run_smoke(&mut backend, &mut writer).await?;
    writer.finish()
}

async fn run_smoke<B: SmokeBackend + ?Sized>(
    backend: &mut B,
    writer: &mut EvidenceWriter,
) -> Result<(), SmokeError> {
    let report = audit_program_completeness()
        .map_err(|_| SmokeError::Stage("program/source completeness audit failed"))?;
    writer.success(
        Stage::Completeness,
        metrics(&[
            ("programs", as_u64(report.programs)),
            ("source_files", as_u64(report.source_files)),
            ("test_client_commands", as_u64(report.test_client_commands)),
            ("pending_programs", 0),
            ("pending_commands", 0),
        ]),
    )?;
    let operation = run_operational_stages(backend, writer).await;
    let logout = backend.logout().await;
    let logout_record = match &logout {
        Ok(values) => writer.success(Stage::Logout, values.clone()),
        Err(_) => writer.failure(Stage::Logout),
    };
    operation?;
    logout?;
    logout_record
}

async fn run_operational_stages<B: SmokeBackend + ?Sized>(
    backend: &mut B,
    writer: &mut EvidenceWriter,
) -> Result<(), SmokeError> {
    record_stage(writer, Stage::Login, backend.login().await)?;
    record_stage(
        writer,
        Stage::CapabilitiesSimulator,
        backend.capabilities_simulator().await,
    )?;
    record_stage(writer, Stage::ImChat, backend.im_chat().await)?;
    record_stage(
        writer,
        Stage::MovementTeleport,
        backend.movement_teleport().await,
    )?;
    record_stage(
        writer,
        Stage::InventoryFolder,
        backend.inventory_folder().await,
    )?;
    record_stage(
        writer,
        Stage::ObjectProperties,
        backend.object_properties().await,
    )?;
    record_stage(writer, Stage::AssetTexture, backend.asset_texture().await)?;
    Ok(())
}

fn record_stage(
    writer: &mut EvidenceWriter,
    stage: Stage,
    result: Result<BTreeMap<&'static str, u64>, SmokeError>,
) -> Result<(), SmokeError> {
    match result {
        Ok(values) => writer.success(stage, values),
        Err(error) => {
            writer.failure(stage)?;
            Err(error)
        }
    }
}

fn credential(name: &str) -> Option<String> {
    std::env::var(name)
        .ok()
        .filter(|value| !value.trim().is_empty())
        .or_else(|| {
            let dotenv = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.env");
            let contents = std::fs::read_to_string(dotenv).ok()?;
            contents.lines().find_map(|line| {
                let line = line.trim().strip_prefix("export ").unwrap_or(line.trim());
                let (key, value) = line.split_once('=')?;
                (key.trim() == name)
                    .then(|| value.trim().trim_matches(['\'', '"']).to_owned())
                    .filter(|value| !value.is_empty())
            })
        })
}

fn metrics(values: &[(&'static str, u64)]) -> BTreeMap<&'static str, u64> {
    values.iter().copied().collect()
}

fn as_u64(value: usize) -> u64 {
    u64::try_from(value).unwrap_or(u64::MAX)
}

fn first_texture(primitive: &Primitive) -> Option<UUID> {
    let mut textures = BTreeSet::new();
    if let Some(entry) = &primitive.textures {
        if let Some(face) = &entry.default_texture {
            textures.insert(face.texture_id());
        }
        textures.extend(
            entry
                .face_textures
                .iter()
                .flatten()
                .map(libremetaverse::PrimitiveTextureEntryFace::texture_id),
        );
    }
    if let Some(sculpt) = &primitive.sculpt {
        textures.insert(sculpt.sculpt_texture);
    }
    textures.remove(&UUID::zero());
    textures.remove(&PrimitiveTextureEntry::white_texture());
    textures.into_iter().next()
}

fn lock<T>(value: &Mutex<T>) -> MutexGuard<'_, T> {
    value
        .lock()
        .unwrap_or_else(std::sync::PoisonError::into_inner)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn secrets_are_always_redacted_from_debug() {
        let config = SmokeConfig {
            first_name: "First".into(),
            last_name: "Last".into(),
            password: SmokeSecret("super-secret".into()),
            login_url: SmokeSecret("https://grid.invalid/login".into()),
        };
        let debug = format!("{config:?}");
        assert!(!debug.contains("super-secret"));
        assert!(!debug.contains("grid.invalid"));
        assert!(debug.contains("<redacted>"));
    }

    #[test]
    fn texture_selection_ignores_zero_and_builtin_white() {
        let primitive = Primitive::new_with_constructor().unwrap();
        assert!(first_texture(&primitive).is_none());
    }

    #[test]
    fn simulator_identity_is_read_from_transport_zerocoded_opensim_handshake() {
        let mut packet = RegionHandshakePacket::new_with_constructor().unwrap();
        packet.region_info.sim_name = b"OpenSim Smoke Region\0".to_vec();
        let raw = packet.to_bytes_with_method().unwrap();
        let wire = if raw[0] & libremetaverse::Helpers::MSG_ZEROCODED == 0 {
            raw
        } else {
            let mut encoded = vec![0_u8; raw.len().saturating_mul(2).saturating_add(2)];
            let length = libremetaverse::Helpers::zero_encode(
                Some(&raw),
                i32::try_from(raw.len()).unwrap(),
                Some(&mut encoded),
            )
            .unwrap();
            encoded.truncate(usize::try_from(length).unwrap());
            encoded
        };
        assert_eq!(
            wire_packet_type(wire.clone()),
            Some(PacketType::RegionHandshake)
        );
        assert!(handshake_identity_present(wire));
    }
}
