use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};

use anyhow::{Result, anyhow, bail};
use serde::Serialize;
use tokio::sync::Mutex;
use uuid::Uuid;

use super::action::BrowserAction;
use super::runner::AgentBrowserRunner;
use super::security::validate_navigation;
use crate::bus::MediaRef;
use crate::config::{BrowserConfig, expand_path};
use crate::tools::{ToolArtifact, ToolOutput, ToolResult};

struct BrowserSession {
    agent_browser_id: String,
    profile_dir: Option<PathBuf>,
    profile_label: std::sync::Mutex<Option<String>>,
    gate: Mutex<()>,
    retired: AtomicBool,
    last_used: std::sync::Mutex<Instant>,
}

const PROFILE_ID_PREFIX: &str = "picobot-profile-";
const PROFILE_LABEL_FILE: &str = ".picobot-label";
const MAX_PROFILE_LABEL_CHARS: usize = 80;

#[derive(Serialize)]
struct PersistentProfileEntry {
    id: String,
    label: Option<String>,
    path: String,
    active: bool,
}

#[derive(Serialize)]
struct PersistentProfileList {
    profiles: Vec<PersistentProfileEntry>,
}

#[derive(Serialize)]
struct PersistentProfileInfo {
    id: String,
    label: Option<String>,
    path: String,
}

pub(super) struct BrowserManager {
    runner: AgentBrowserRunner,
    sessions: Mutex<HashMap<String, Arc<BrowserSession>>>,
    persistent_sessions: Mutex<HashMap<String, Arc<BrowserSession>>>,
    profile_root: PathBuf,
    max_sessions: usize,
    idle_timeout: Duration,
    artifact_dir: PathBuf,
    allow_private_hosts: bool,
    allowed_domains: Vec<String>,
}

impl BrowserManager {
    pub(super) fn new(config: &BrowserConfig, workspace_dir: PathBuf) -> Result<Self> {
        if config.max_sessions == 0 {
            bail!("browser.max_sessions must be greater than zero");
        }
        if config.idle_timeout_secs == 0 {
            bail!("browser.idle_timeout_secs must be greater than zero");
        }
        if config.command.trim().is_empty() {
            bail!("browser.command cannot be empty");
        }
        if config.persistence.profile_dir.trim().is_empty() {
            bail!("browser.persistence.profile_dir cannot be empty");
        }
        let artifact_dir = expand_path(&config.artifact_dir);
        let artifact_dir = if artifact_dir.is_absolute() {
            artifact_dir
        } else {
            workspace_dir.join(&artifact_dir)
        };
        let profile_root = expand_path(&config.persistence.profile_dir);
        let profile_root = if profile_root.is_absolute() {
            profile_root
        } else {
            workspace_dir.join(&profile_root)
        };
        Ok(Self {
            runner: AgentBrowserRunner::new(config, workspace_dir),
            sessions: Mutex::new(HashMap::new()),
            persistent_sessions: Mutex::new(HashMap::new()),
            profile_root,
            max_sessions: config.max_sessions,
            idle_timeout: Duration::from_secs(config.idle_timeout_secs),
            artifact_dir,
            allow_private_hosts: config.allow_private_hosts,
            allowed_domains: config.allowed_domains.clone(),
        })
    }

    pub(super) async fn execute(
        &self,
        picobot_session_id: &str,
        persistent_id: Option<&str>,
        action: BrowserAction,
    ) -> Result<ToolOutput> {
        if let BrowserAction::Open { url } = &action {
            validate_navigation(url, self.allow_private_hosts, &self.allowed_domains)
                .await
                .map_err(anyhow::Error::msg)?;
        }

        if action.is_close() {
            return self.close(picobot_session_id, persistent_id).await;
        }

        let screenshot_path = match action.screenshot_filename() {
            Some(filename) => Some(self.prepare_screenshot_path(filename).await?),
            None => None,
        };
        let (session, stale) = self.session_for(picobot_session_id, persistent_id).await?;
        for stale_session in stale {
            let _ = self
                .runner
                .run(&stale_session, None, &["close".to_string()])
                .await;
        }

        let _gate = session.gate.lock().await;
        if session.retired.load(Ordering::Acquire) {
            bail!(
                "browser session '{}' was closed or its persistent profile was deleted while this action was waiting; retry the action or select another persistent_id",
                session.agent_browser_id
            );
        }
        let path_string = screenshot_path
            .as_ref()
            .map(|path| path.to_string_lossy().into_owned());
        let commands = action.commands(path_string.as_deref());
        let mut last_response = None;
        for command in commands {
            last_response = Some(
                self.runner
                    .run(
                        &session.agent_browser_id,
                        session.profile_dir.as_deref(),
                        &command,
                    )
                    .await?,
            );
        }
        *session
            .last_used
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Instant::now();

        let response =
            last_response.ok_or_else(|| anyhow!("browser action produced no command"))?;
        let mut output = self.runner.render_response(&response);
        if session.profile_dir.is_some() {
            output = format!("{}\n{output}", render_persistent_identity(&session));
        }
        let mut artifacts = Vec::new();
        if let Some(path) = screenshot_path {
            let metadata = tokio::fs::metadata(&path)
                .await
                .map_err(|error| anyhow!("agent-browser did not create screenshot: {error}"))?;
            if !metadata.is_file() || metadata.len() == 0 {
                bail!("agent-browser created an empty screenshot");
            }
            let canonical = tokio::fs::canonicalize(&path).await.unwrap_or(path);
            let canonical = canonical.to_string_lossy().into_owned();
            output = format!("Screenshot saved: {canonical}\n{output}");
            let media_ref = MediaRef {
                path: canonical,
                media_type: "image".to_string(),
            };
            artifacts.push(if action.screenshot_present_to_user() {
                ToolArtifact::model_and_user(media_ref)
            } else {
                ToolArtifact::model_only(media_ref)
            });
        }
        Ok(ToolOutput {
            result: ToolResult {
                success: true,
                output,
                error: None,
            },
            artifacts,
        })
    }

    async fn session_for(
        &self,
        picobot_session_id: &str,
        persistent_id: Option<&str>,
    ) -> Result<(Arc<BrowserSession>, Vec<String>)> {
        if let Some(profile_id) = persistent_id {
            self.ensure_persistent_browser_allowed()?;
            let session = self.persistent_session(profile_id).await?;
            return Ok((session, Vec::new()));
        }

        let now = Instant::now();
        let mut sessions = self.sessions.lock().await;
        if let Some(session) = sessions.get(picobot_session_id) {
            *session
                .last_used
                .lock()
                .unwrap_or_else(|poisoned| poisoned.into_inner()) = now;
            return Ok((session.clone(), Vec::new()));
        }

        let mut stale_ids = Vec::new();
        sessions.retain(|_, session| {
            let last_used = *session
                .last_used
                .lock()
                .unwrap_or_else(|poisoned| poisoned.into_inner());
            let idle = now.duration_since(last_used) >= self.idle_timeout;
            let keep = !idle || session.gate.try_lock().is_err();
            if !keep {
                stale_ids.push(session.agent_browser_id.clone());
            }
            keep
        });
        if sessions.len() >= self.max_sessions {
            bail!(
                "browser session limit reached ({}); close another dialog browser or wait for idle cleanup",
                self.max_sessions
            );
        }
        let session = Arc::new(BrowserSession {
            agent_browser_id: format!("picobot-{}", Uuid::new_v4().simple()),
            profile_dir: None,
            profile_label: std::sync::Mutex::new(None),
            gate: Mutex::new(()),
            retired: AtomicBool::new(false),
            last_used: std::sync::Mutex::new(now),
        });
        sessions.insert(picobot_session_id.to_string(), session.clone());
        Ok((session, stale_ids))
    }

    async fn close(
        &self,
        picobot_session_id: &str,
        persistent_id: Option<&str>,
    ) -> Result<ToolOutput> {
        if let Some(profile_id) = persistent_id {
            let mut sessions = self.persistent_sessions.lock().await;
            let profile_dir = validate_existing_profile(&self.profile_root, profile_id).await?;
            let session = if let Some(session) = sessions.remove(profile_id) {
                session
            } else {
                Arc::new(BrowserSession {
                    agent_browser_id: profile_id.to_string(),
                    profile_label: std::sync::Mutex::new(
                        load_profile_label(&profile_dir, profile_id).await,
                    ),
                    profile_dir: Some(profile_dir),
                    gate: Mutex::new(()),
                    retired: AtomicBool::new(false),
                    last_used: std::sync::Mutex::new(Instant::now()),
                })
            };
            let _gate = session.gate.lock().await;
            let response = match self
                .runner
                .run(&session.agent_browser_id, None, &["close".to_string()])
                .await
            {
                Ok(response) => response,
                Err(error) => {
                    sessions.insert(profile_id.to_string(), session.clone());
                    return Err(error);
                }
            };
            session.retired.store(true, Ordering::Release);
            return Ok(ToolResult {
                success: true,
                output: format!(
                    "{}\n{}",
                    render_persistent_identity(&session),
                    self.runner.render_response(&response)
                ),
                error: None,
            }
            .into());
        }

        let session = self.sessions.lock().await.remove(picobot_session_id);
        let Some(session) = session else {
            return Ok(ToolResult {
                success: true,
                output: "Browser session is already closed.".to_string(),
                error: None,
            }
            .into());
        };
        let _gate = session.gate.lock().await;
        let response = self
            .runner
            .run(&session.agent_browser_id, None, &["close".to_string()])
            .await?;
        session.retired.store(true, Ordering::Release);
        Ok(ToolResult {
            success: true,
            output: self.runner.render_response(&response),
            error: None,
        }
        .into())
    }

    async fn persistent_session(&self, profile_id: &str) -> Result<Arc<BrowserSession>> {
        let mut sessions = self.persistent_sessions.lock().await;
        let profile_dir = validate_existing_profile(&self.profile_root, profile_id).await?;
        if let Some(session) = sessions.get(profile_id) {
            return Ok(session.clone());
        }
        ensure_private_dir(&profile_dir).await?;
        let session = Arc::new(BrowserSession {
            agent_browser_id: profile_id.to_string(),
            profile_dir: Some(profile_dir),
            profile_label: std::sync::Mutex::new(
                load_profile_label(&self.profile_root.join(profile_id), profile_id).await,
            ),
            gate: Mutex::new(()),
            retired: AtomicBool::new(false),
            last_used: std::sync::Mutex::new(Instant::now()),
        });
        sessions.insert(profile_id.to_string(), session.clone());
        Ok(session)
    }

    pub(super) async fn list_persistent_profiles(&self) -> Result<ToolResult> {
        let sessions = self.persistent_sessions.lock().await;
        let mut profiles = Vec::new();
        for id in list_profile_ids(&self.profile_root).await? {
            let profile_dir = self.profile_root.join(&id);
            profiles.push(PersistentProfileEntry {
                label: load_profile_label(&profile_dir, &id).await,
                path: profile_dir.to_string_lossy().into_owned(),
                active: sessions.contains_key(&id),
                id,
            });
        }
        let output = serde_json::to_string_pretty(&PersistentProfileList { profiles })?;
        Ok(ToolResult {
            success: true,
            output,
            error: None,
        })
    }

    pub(super) async fn create_persistent_profile(
        &self,
        label: Option<&str>,
    ) -> Result<ToolResult> {
        self.ensure_persistent_browser_allowed()?;
        let label = label.map(normalize_profile_label).transpose()?;
        let _sessions = self.persistent_sessions.lock().await;
        let (profile_id, profile_dir) = create_profile_directory(&self.profile_root).await?;
        if let Some(label) = &label
            && let Err(error) = write_profile_label(&profile_dir, label).await
        {
            let _ = tokio::fs::remove_dir_all(&profile_dir).await;
            return Err(error);
        }
        let output = serde_json::to_string_pretty(&PersistentProfileInfo {
            id: profile_id,
            label,
            path: profile_dir.to_string_lossy().into_owned(),
        })?;
        Ok(ToolResult {
            success: true,
            output,
            error: None,
        })
    }

    pub(super) async fn set_persistent_profile_label(
        &self,
        profile_id: &str,
        label: &str,
    ) -> Result<ToolResult> {
        let label = normalize_profile_label(label)?;
        let sessions = self.persistent_sessions.lock().await;
        let profile_dir = validate_existing_profile(&self.profile_root, profile_id).await?;
        write_profile_label(&profile_dir, &label).await?;
        if let Some(session) = sessions.get(profile_id) {
            *session
                .profile_label
                .lock()
                .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(label.clone());
        }
        let output = serde_json::to_string_pretty(&PersistentProfileInfo {
            id: profile_id.to_string(),
            label: Some(label),
            path: profile_dir.to_string_lossy().into_owned(),
        })?;
        Ok(ToolResult {
            success: true,
            output,
            error: None,
        })
    }

    pub(super) async fn delete_persistent_profile(&self, profile_id: &str) -> Result<ToolResult> {
        let mut sessions = self.persistent_sessions.lock().await;
        let profile_dir = validate_existing_profile(&self.profile_root, profile_id).await?;
        let profile_label = load_profile_label(&profile_dir, profile_id).await;
        let session = sessions.remove(profile_id);

        if let Some(session) = session {
            let gate = session.gate.lock().await;
            if let Err(error) = self
                .runner
                .run(&session.agent_browser_id, None, &["close".to_string()])
                .await
            {
                tracing::warn!(
                    profile_id,
                    error = %error,
                    "Failed to close persistent browser before deleting its profile"
                );
            }
            if let Err(error) = tokio::fs::remove_dir_all(&profile_dir).await {
                drop(gate);
                sessions.insert(profile_id.to_string(), session);
                return Err(error.into());
            }
            session.retired.store(true, Ordering::Release);
        } else {
            tokio::fs::remove_dir_all(&profile_dir).await?;
        }

        Ok(ToolResult {
            success: true,
            output: match profile_label {
                Some(label) => format!(
                    "Deleted persistent browser profile '{label}' ({profile_id}) and directory '{}'.",
                    profile_dir.display()
                ),
                None => format!(
                    "Deleted persistent browser profile '{profile_id}' and directory '{}'.",
                    profile_dir.display()
                ),
            },
            error: None,
        })
    }

    fn ensure_persistent_browser_allowed(&self) -> Result<()> {
        if !self.allowed_domains.is_empty() {
            bail!(
                "persistent browser profiles are unavailable while browser.allowed_domains is configured because agent-browser cannot combine profile reuse with domain containment; omit persistent_id for a transient browser or clear allowed_domains"
            );
        }
        Ok(())
    }

    async fn prepare_screenshot_path(&self, requested: Option<&str>) -> Result<PathBuf> {
        tokio::fs::create_dir_all(&self.artifact_dir).await?;
        let filename = match requested {
            Some(requested) => {
                let path = Path::new(requested);
                if path.is_absolute()
                    || path
                        .components()
                        .any(|component| !matches!(component, Component::Normal(_)))
                {
                    bail!("screenshot path must be a filename without directory components");
                }
                let filename = path
                    .file_name()
                    .and_then(|name| name.to_str())
                    .ok_or_else(|| anyhow!("invalid screenshot filename"))?;
                if !filename.to_ascii_lowercase().ends_with(".png") {
                    bail!("screenshot filename must end in .png");
                }
                filename.to_string()
            }
            None => format!(
                "picobot-screenshot-{}-{}.png",
                chrono::Utc::now().format("%Y%m%dT%H%M%S"),
                &Uuid::new_v4().simple().to_string()[..8]
            ),
        };
        Ok(self.artifact_dir.join(filename))
    }
}

fn generate_profile_id() -> String {
    format!("{PROFILE_ID_PREFIX}{}", Uuid::new_v4().simple())
}

fn valid_profile_id(profile_id: &str) -> bool {
    profile_id
        .strip_prefix(PROFILE_ID_PREFIX)
        .is_some_and(|suffix| {
            suffix.len() == 32 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
        })
}

fn normalize_profile_label(label: &str) -> Result<String> {
    let label = label.trim();
    if label.is_empty() {
        bail!("persistent browser profile label cannot be empty");
    }
    if label.chars().count() > MAX_PROFILE_LABEL_CHARS {
        bail!(
            "persistent browser profile label cannot exceed {MAX_PROFILE_LABEL_CHARS} characters"
        );
    }
    if label.chars().any(char::is_control) {
        bail!("persistent browser profile label cannot contain control characters");
    }
    Ok(label.to_string())
}

fn render_persistent_identity(session: &BrowserSession) -> String {
    let label = session
        .profile_label
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    match label.as_deref() {
        Some(label) => format!(
            "Persistent browser label: {label}\nPersistent browser ID: {}",
            session.agent_browser_id
        ),
        None => format!("Persistent browser ID: {}", session.agent_browser_id),
    }
}

async fn create_profile_directory(profile_root: &Path) -> Result<(String, PathBuf)> {
    ensure_private_dir(profile_root).await?;
    loop {
        let profile_id = generate_profile_id();
        let profile_dir = profile_root.join(&profile_id);
        match tokio::fs::create_dir(&profile_dir).await {
            Ok(()) => {
                #[cfg(unix)]
                tokio::fs::set_permissions(
                    &profile_dir,
                    std::os::unix::fs::PermissionsExt::from_mode(0o700),
                )
                .await?;
                return Ok((profile_id, profile_dir));
            }
            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
            Err(error) => return Err(error.into()),
        }
    }
}

async fn validate_existing_profile(profile_root: &Path, profile_id: &str) -> Result<PathBuf> {
    if !valid_profile_id(profile_id) {
        bail!("invalid persistent browser profile id");
    }
    let profile_dir = profile_root.join(profile_id);
    let metadata = match tokio::fs::symlink_metadata(&profile_dir).await {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            bail!("persistent browser profile '{profile_id}' does not exist")
        }
        Err(error) => return Err(error.into()),
    };
    if !metadata.is_dir() || metadata.file_type().is_symlink() {
        bail!("persistent browser profile path is not a regular directory");
    }
    Ok(profile_dir)
}

async fn list_profile_ids(profile_root: &Path) -> Result<Vec<String>> {
    let mut profile_ids = Vec::new();
    let mut entries = match tokio::fs::read_dir(profile_root).await {
        Ok(entries) => entries,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(profile_ids),
        Err(error) => return Err(error.into()),
    };
    while let Some(entry) = entries.next_entry().await? {
        let profile_id = entry.file_name().to_string_lossy().into_owned();
        if valid_profile_id(&profile_id) && entry.file_type().await?.is_dir() {
            profile_ids.push(profile_id);
        }
    }
    profile_ids.sort();
    Ok(profile_ids)
}

async fn read_profile_label(profile_dir: &Path) -> Result<Option<String>> {
    let label_path = profile_dir.join(PROFILE_LABEL_FILE);
    let metadata = match tokio::fs::symlink_metadata(&label_path).await {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(error.into()),
    };
    if !metadata.is_file() || metadata.file_type().is_symlink() {
        bail!("persistent browser profile label path is not a regular file");
    }
    if metadata.len() > 1024 {
        bail!("persistent browser profile label file is too large");
    }
    let label = tokio::fs::read_to_string(&label_path).await?;
    normalize_profile_label(&label).map(Some)
}

async fn load_profile_label(profile_dir: &Path, profile_id: &str) -> Option<String> {
    match read_profile_label(profile_dir).await {
        Ok(label) => label,
        Err(error) => {
            tracing::warn!(
                profile_id,
                error = %error,
                "Ignoring invalid persistent browser profile label metadata"
            );
            None
        }
    }
}

async fn write_profile_label(profile_dir: &Path, label: &str) -> Result<()> {
    let label = normalize_profile_label(label)?;
    let label_path = profile_dir.join(PROFILE_LABEL_FILE);
    let temporary = profile_dir.join(format!(".picobot-label-{}.tmp", Uuid::new_v4().simple()));
    let result = async {
        tokio::fs::write(&temporary, label.as_bytes()).await?;
        #[cfg(unix)]
        tokio::fs::set_permissions(
            &temporary,
            std::os::unix::fs::PermissionsExt::from_mode(0o600),
        )
        .await?;
        #[cfg(windows)]
        match tokio::fs::remove_file(&label_path).await {
            Ok(()) => {}
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => return Err(error),
        }
        tokio::fs::rename(&temporary, &label_path).await
    }
    .await;
    if result.is_err() {
        let _ = tokio::fs::remove_file(&temporary).await;
    }
    result.map_err(Into::into)
}

async fn ensure_private_dir(path: &Path) -> Result<()> {
    tokio::fs::create_dir_all(path).await?;
    #[cfg(unix)]
    tokio::fs::set_permissions(path, std::os::unix::fs::PermissionsExt::from_mode(0o700)).await?;
    Ok(())
}

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

    fn persistent_config(profile_root: &Path) -> BrowserConfig {
        BrowserConfig {
            persistence: crate::config::BrowserPersistenceConfig {
                profile_dir: profile_root.to_string_lossy().into_owned(),
            },
            ..BrowserConfig::default()
        }
    }

    #[test]
    fn zero_transient_idle_timeout_is_rejected() {
        let temp = tempfile::tempdir().unwrap();
        let config = BrowserConfig {
            idle_timeout_secs: 0,
            ..persistent_config(temp.path())
        };
        let error = BrowserManager::new(&config, temp.path().to_path_buf())
            .err()
            .unwrap()
            .to_string();
        assert!(error.contains("browser.idle_timeout_secs must be greater than zero"));
    }

    #[tokio::test]
    async fn missing_id_uses_transient_dialog_sessions() {
        let temp = tempfile::tempdir().unwrap();
        let config = persistent_config(temp.path());
        let manager = BrowserManager::new(&config, temp.path().to_path_buf()).unwrap();
        let first = manager.session_for("dialog", None).await.unwrap().0;
        let reused = manager.session_for("dialog", None).await.unwrap().0;
        let other = manager.session_for("another-dialog", None).await.unwrap().0;

        assert!(Arc::ptr_eq(&first, &reused));
        assert!(!Arc::ptr_eq(&first, &other));
        assert!(first.profile_dir.is_none());
        assert!(other.profile_dir.is_none());
        assert!(list_profile_ids(temp.path()).await.unwrap().is_empty());
    }

    #[tokio::test]
    async fn persistent_profile_is_shared_by_id_and_stable_across_managers() {
        let temp = tempfile::tempdir().unwrap();
        let config = persistent_config(temp.path());
        let (profile_id, profile_dir) = create_profile_directory(temp.path()).await.unwrap();
        write_profile_label(&profile_dir, "工作账号").await.unwrap();
        let first = BrowserManager::new(&config, temp.path().to_path_buf()).unwrap();
        let a = first
            .session_for("dialog-a", Some(&profile_id))
            .await
            .unwrap()
            .0;
        let b = first
            .session_for("dialog-b", Some(&profile_id))
            .await
            .unwrap()
            .0;
        assert_eq!(a.agent_browser_id, b.agent_browser_id);
        assert_eq!(a.profile_dir, b.profile_dir);
        assert!(Arc::ptr_eq(&a, &b));
        assert_eq!(a.profile_label.lock().unwrap().as_deref(), Some("工作账号"));

        let second = BrowserManager::new(&config, temp.path().to_path_buf()).unwrap();
        let restored = second
            .session_for("another-dialog", Some(&profile_id))
            .await
            .unwrap()
            .0;
        assert_eq!(a.agent_browser_id, restored.agent_browser_id);
        assert_eq!(a.profile_dir, restored.profile_dir);
        assert_eq!(
            restored.profile_label.lock().unwrap().as_deref(),
            Some("工作账号")
        );
    }

    #[tokio::test]
    async fn explicit_profiles_are_independent_and_shared_by_id() {
        let temp = tempfile::tempdir().unwrap();
        let config = persistent_config(temp.path());
        let manager = BrowserManager::new(&config, temp.path().to_path_buf()).unwrap();
        let (first_id, _) = create_profile_directory(temp.path()).await.unwrap();
        let (second_id, second_dir) = create_profile_directory(temp.path()).await.unwrap();
        let first = manager
            .session_for("dialog-a", Some(&first_id))
            .await
            .unwrap()
            .0;

        let selected_a = manager
            .session_for("dialog-a", Some(&second_id))
            .await
            .unwrap()
            .0;
        let selected_b = manager
            .session_for("dialog-b", Some(&second_id))
            .await
            .unwrap()
            .0;

        assert_ne!(first.agent_browser_id, selected_a.agent_browser_id);
        assert_eq!(
            selected_a.profile_dir.as_deref(),
            Some(second_dir.as_path())
        );
        assert!(Arc::ptr_eq(&selected_a, &selected_b));
        assert!(!Arc::ptr_eq(&first, &selected_a));
    }

    #[tokio::test]
    async fn profile_listing_reports_labels_directories_and_active_status() {
        let temp = tempfile::tempdir().unwrap();
        let config = persistent_config(temp.path());
        let manager = BrowserManager::new(&config, temp.path().to_path_buf()).unwrap();
        let (profile_id, profile_dir) = create_profile_directory(temp.path()).await.unwrap();
        write_profile_label(&profile_dir, "采购账号").await.unwrap();
        manager
            .session_for("dialog", Some(&profile_id))
            .await
            .unwrap();
        let result = manager.list_persistent_profiles().await.unwrap();
        let list: serde_json::Value = serde_json::from_str(&result.output).unwrap();

        assert!(list.get("enabled").is_none());
        assert_eq!(list["profiles"][0]["id"], profile_id);
        assert_eq!(list["profiles"][0]["label"], "采购账号");
        assert_eq!(list["profiles"][0]["active"], true);
        assert_eq!(
            list["profiles"][0]["path"],
            profile_dir.to_string_lossy().as_ref()
        );
    }

    #[tokio::test]
    async fn invalid_label_metadata_does_not_block_profile_use_or_management() {
        let temp = tempfile::tempdir().unwrap();
        let config = persistent_config(temp.path());
        let manager = BrowserManager::new(&config, temp.path().to_path_buf()).unwrap();
        let (profile_id, profile_dir) = create_profile_directory(temp.path()).await.unwrap();
        tokio::fs::write(profile_dir.join(PROFILE_LABEL_FILE), "\n")
            .await
            .unwrap();

        let session = manager
            .session_for("dialog", Some(&profile_id))
            .await
            .unwrap()
            .0;
        assert!(session.profile_label.lock().unwrap().is_none());
        let listed = manager.list_persistent_profiles().await.unwrap();
        let list: serde_json::Value = serde_json::from_str(&listed.output).unwrap();
        assert!(list["profiles"][0]["label"].is_null());
    }

    #[tokio::test]
    async fn create_and_set_label_persist_semantic_metadata() {
        let temp = tempfile::tempdir().unwrap();
        let config = persistent_config(temp.path());
        let manager = BrowserManager::new(&config, temp.path().to_path_buf()).unwrap();
        let created: serde_json::Value = serde_json::from_str(
            &manager
                .create_persistent_profile(Some("  公司后台  "))
                .await
                .unwrap()
                .output,
        )
        .unwrap();
        assert_eq!(created["label"], "公司后台");
        assert!(created.get("default").is_none());
        let profile_id = created["id"].as_str().unwrap();
        let session = manager
            .session_for("dialog", Some(profile_id))
            .await
            .unwrap()
            .0;
        manager
            .set_persistent_profile_label(profile_id, "个人账号")
            .await
            .unwrap();
        assert_eq!(
            read_profile_label(session.profile_dir.as_ref().unwrap())
                .await
                .unwrap(),
            Some("个人账号".to_string())
        );
        assert_eq!(
            session.profile_label.lock().unwrap().as_deref(),
            Some("个人账号")
        );
        assert!(render_persistent_identity(&session).contains("个人账号"));
        assert!(!temp.path().join("default").exists());
    }

    #[tokio::test]
    async fn deleting_labeled_profile_removes_its_directory() {
        let temp = tempfile::tempdir().unwrap();
        let config = persistent_config(temp.path());
        let manager = BrowserManager::new(&config, temp.path().to_path_buf()).unwrap();
        let (profile_id, profile_dir) = create_profile_directory(temp.path()).await.unwrap();
        write_profile_label(&profile_dir, "临时采购").await.unwrap();

        let deleted = manager
            .delete_persistent_profile(&profile_id)
            .await
            .unwrap();

        assert!(deleted.output.contains("临时采购"));
        assert!(!profile_dir.exists());
        let listed = manager.list_persistent_profiles().await.unwrap();
        let list: serde_json::Value = serde_json::from_str(&listed.output).unwrap();
        assert!(list["profiles"].as_array().unwrap().is_empty());
    }

    #[tokio::test]
    async fn explicit_id_selects_persistent_profile_while_missing_id_stays_transient() {
        let temp = tempfile::tempdir().unwrap();
        let config = persistent_config(temp.path());
        let manager = BrowserManager::new(&config, temp.path().to_path_buf()).unwrap();
        let (profile_id, profile_dir) = create_profile_directory(temp.path()).await.unwrap();

        let transient = manager.session_for("dialog", None).await.unwrap().0;
        let persistent = manager
            .session_for("dialog", Some(&profile_id))
            .await
            .unwrap()
            .0;

        assert!(transient.profile_dir.is_none());
        assert_eq!(
            persistent.profile_dir.as_deref(),
            Some(profile_dir.as_path())
        );
        assert_ne!(transient.agent_browser_id, persistent.agent_browser_id);
    }

    #[test]
    fn profile_ids_reject_path_traversal() {
        assert!(!valid_profile_id("../picobot-profile-deadbeef"));
        assert!(!valid_profile_id("picobot-profile-deadbeef"));
        assert!(valid_profile_id(
            "picobot-profile-0123456789abcdef0123456789abcdef"
        ));
    }

    #[test]
    fn profile_labels_are_trimmed_bounded_and_safe_for_text_output() {
        assert_eq!(normalize_profile_label("  工作账号  ").unwrap(), "工作账号");
        assert!(normalize_profile_label(" ").is_err());
        assert!(normalize_profile_label("line\nbreak").is_err());
        assert!(normalize_profile_label(&"a".repeat(81)).is_err());
    }

    #[tokio::test]
    async fn domain_containment_keeps_transient_browser_but_rejects_profile_use() {
        let temp = tempfile::tempdir().unwrap();
        let mut config = persistent_config(temp.path());
        config.allowed_domains = vec!["example.com".to_string()];
        let manager = BrowserManager::new(&config, temp.path().to_path_buf()).unwrap();

        let transient = manager.session_for("dialog", None).await.unwrap().0;
        assert!(transient.profile_dir.is_none());

        let (profile_id, _) = create_profile_directory(temp.path()).await.unwrap();
        let error = manager
            .session_for("dialog", Some(&profile_id))
            .await
            .err()
            .unwrap()
            .to_string();
        assert!(error.contains("unavailable while browser.allowed_domains is configured"));

        let create_error = manager
            .create_persistent_profile(Some("长期工作"))
            .await
            .err()
            .unwrap()
            .to_string();
        assert!(create_error.contains("unavailable while browser.allowed_domains is configured"));
    }
}
