//! Connector for ChatGPT desktop app conversation history.
//!
//! ChatGPT stores conversations in:
//! - macOS: ~/Library/Application Support/com.openai.chat/
//!
//! ## Conversation storage versions:
//! - v1 (legacy): Plain JSON files in `conversations-{uuid}/` (unencrypted)
//! - v2/v3: Encrypted files in `conversations-v2-{uuid}/` or `conversations-v3-{uuid}/`
//!
//! ## Encryption Details (v2/v3):
//! ChatGPT desktop encrypts conversations using AES-256-GCM with a key stored in the
//! macOS Keychain under access group `2DC432GLL2.com.openai.shared`.
//!
//! **Important**: The encryption key is protected by Apple's Keychain Access Groups
//! mechanism, which requires the accessing app to be signed with OpenAI's Team ID
//! (2DC432GLL2). This means third-party apps cannot directly access the key.
//!
//! ## Decryption Options:
//! To decrypt v2/v3 conversations, you can:
//! 1. Set the `CHATGPT_ENCRYPTION_KEY` environment variable to the base64-encoded key
//! 2. Create a key file at `~/.config/cass/chatgpt_key.bin` containing the raw 32-byte key
//!
//! The key can potentially be extracted by:
//! - Using Keychain Access.app to export the key (requires user authorization)
//! - Running a helper tool signed with appropriate entitlements
//!
//! ## File Format:
//! Encrypted files appear to use AES-256-GCM with:
//! - 12-byte nonce at the start
//! - Encrypted JSON data
//! - 16-byte authentication tag at the end

#![allow(
    clippy::cast_possible_truncation,
    clippy::cast_possible_wrap,
    clippy::doc_markdown,
    clippy::map_unwrap_or,
    clippy::missing_const_for_fn,
    clippy::must_use_candidate,
    clippy::redundant_closure_for_method_calls,
    clippy::too_many_lines,
    clippy::uninlined_format_args,
    clippy::unreadable_literal
)]

use std::fs;
use std::path::{Path, PathBuf};

use aes_gcm::{
    Aes256Gcm, Nonce,
    aead::{Aead, KeyInit},
};
use anyhow::{Context, Result};
use base64::prelude::*;
use serde_json::Value;
use walkdir::WalkDir;

use super::scan::{DiscoveredSourceFile, DiscoveredSourceRole, ScanContext, ScanRoot};
use super::{Connector, file_modified_since, franken_detection_for_connector, parse_timestamp};
use crate::types::{DetectionResult, NormalizedConversation, NormalizedMessage};

/// Nonce size for AES-GCM (12 bytes)
const NONCE_SIZE: usize = 12;
/// Authentication tag size for AES-GCM (16 bytes)
const TAG_SIZE: usize = 16;
/// AES-256 key size (32 bytes)
const KEY_SIZE: usize = 32;

pub struct ChatGptConnector {
    /// Optional encryption key for v2/v3 conversations
    encryption_key: Option<[u8; KEY_SIZE]>,
}

impl Default for ChatGptConnector {
    fn default() -> Self {
        Self::new()
    }
}

impl ChatGptConnector {
    pub fn new() -> Self {
        let encryption_key = Self::load_encryption_key();
        if encryption_key.is_some() {
            tracing::info!(
                "chatgpt encryption key loaded, encrypted conversations will be decrypted"
            );
        }
        Self { encryption_key }
    }

    /// Load encryption key from environment variable or key file
    fn load_encryption_key() -> Option<[u8; KEY_SIZE]> {
        // Try environment variable first (base64-encoded)
        if let Ok(key_b64) = dotenvy::var("CHATGPT_ENCRYPTION_KEY") {
            if let Ok(key_bytes) = BASE64_STANDARD.decode(key_b64.trim()) {
                if key_bytes.len() == KEY_SIZE {
                    let mut key = [0u8; KEY_SIZE];
                    key.copy_from_slice(&key_bytes);
                    tracing::debug!(
                        "chatgpt encryption key loaded from CHATGPT_ENCRYPTION_KEY env var"
                    );
                    return Some(key);
                }
                tracing::warn!(
                    "CHATGPT_ENCRYPTION_KEY has wrong length: {} (expected {})",
                    key_bytes.len(),
                    KEY_SIZE
                );
            } else {
                tracing::warn!("CHATGPT_ENCRYPTION_KEY is not valid base64");
            }
        }

        // Try key file
        let key_file_paths = [
            dirs::config_dir().map(|p| p.join("cass/chatgpt_key.bin")),
            dirs::home_dir().map(|p| p.join(".config/cass/chatgpt_key.bin")),
            dirs::home_dir().map(|p| p.join(".cass/chatgpt_key.bin")),
        ];

        for path_opt in key_file_paths.iter().flatten() {
            if path_opt.exists() {
                // [coding_agent_session_search-srzwm] Refuse symlinks
                // and refuse mode > 0600. The AES-256 key in this file
                // decrypts the operator's ChatGPT conversation history;
                // an OS-shared dev box where another user has read
                // access to ~/.config/cass/ would expose every
                // ChatGPT conversation. SSH refuses to use a private
                // key with mode > 0600 for this exact reason — apply
                // the same discipline here.
                if !Self::chatgpt_key_file_mode_is_safe(path_opt) {
                    continue;
                }
                match fs::read(path_opt) {
                    Ok(key_bytes) if key_bytes.len() == KEY_SIZE => {
                        let mut key = [0u8; KEY_SIZE];
                        key.copy_from_slice(&key_bytes);
                        tracing::debug!(path = %path_opt.display(), "chatgpt encryption key loaded from file");
                        return Some(key);
                    }
                    Ok(key_bytes) => {
                        tracing::warn!(
                            path = %path_opt.display(),
                            "chatgpt key file has wrong size: {} (expected {})",
                            key_bytes.len(),
                            KEY_SIZE
                        );
                    }
                    Err(e) => {
                        tracing::warn!(
                            path = %path_opt.display(),
                            error = %e,
                            "failed to read chatgpt key file"
                        );
                    }
                }
            }
        }

        None
    }

    /// `coding_agent_session_search-srzwm`: gate the chatgpt key file
    /// by the same permission rules SSH applies to private keys.
    /// Refuses symlinks (a symlink to /etc/passwd or /dev/null would
    /// silently load garbage as a "key"), and refuses any mode where
    /// group/other have read/write/exec bits set (mode & 0o077 != 0).
    /// Returns false on Windows / non-Unix where mode bits are not
    /// meaningful in the same way; the caller will then proceed to
    /// fs::read on those platforms (cass desktop encryption is
    /// macOS-only anyway, so this is mostly defensive).
    #[cfg(unix)]
    fn chatgpt_key_file_mode_is_safe(path: &Path) -> bool {
        use std::os::unix::fs::MetadataExt;
        let symlink_meta = match fs::symlink_metadata(path) {
            Ok(m) => m,
            Err(e) => {
                tracing::warn!(
                    path = %path.display(),
                    error = %e,
                    "failed to stat chatgpt key file"
                );
                return false;
            }
        };
        if symlink_meta.file_type().is_symlink() {
            tracing::warn!(
                path = %path.display(),
                "refusing to load chatgpt key file: path is a symlink — \
                 the key file MUST be a regular file owned by the operator"
            );
            return false;
        }
        let mode = symlink_meta.mode() & 0o777;
        if mode & 0o077 != 0 {
            tracing::warn!(
                path = %path.display(),
                mode = format!("{mode:#o}"),
                "refusing to load chatgpt key file: mode {mode:#o} grants \
                 group/other access. Set permissions to 0600 (chmod 600 {})",
                path.display()
            );
            return false;
        }
        true
    }

    #[cfg(not(unix))]
    fn chatgpt_key_file_mode_is_safe(_path: &Path) -> bool {
        // Windows: mode bits don't carry the same meaning. Fall through
        // to the read; ChatGPT desktop encryption is macOS-only
        // currently, so this branch is largely unreachable.
        true
    }

    /// Get the ChatGPT app support directory
    pub fn app_support_dir() -> Option<PathBuf> {
        #[cfg(target_os = "macos")]
        {
            dirs::home_dir().map(|h| h.join("Library/Application Support/com.openai.chat"))
        }
        #[cfg(not(target_os = "macos"))]
        {
            // ChatGPT desktop is currently macOS only
            None
        }
    }

    /// Find conversation directories (both encrypted and unencrypted)
    fn find_conversation_dirs(base: &PathBuf) -> Vec<(PathBuf, bool)> {
        let mut dirs = Vec::new();

        if !base.exists() {
            return dirs;
        }

        // 1. Check if base itself is a conversation dir
        if base.is_dir()
            && let Some(name) = base.file_name().and_then(|n| n.to_str())
            && name.starts_with("conversations-")
        {
            let is_encrypted = name.contains("-v2-") || name.contains("-v3-");
            dirs.push((base.clone(), is_encrypted));
            return dirs;
        }

        // 2. Check children (standard behavior)
        for entry in fs::read_dir(base).into_iter().flatten().flatten() {
            let path = entry.path();
            if !path.is_dir() {
                continue;
            }

            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");

            // Check for conversation directories
            if name.starts_with("conversations-") {
                // v1 (unencrypted) or v2/v3 (encrypted)
                let is_encrypted = name.contains("-v2-") || name.contains("-v3-");
                dirs.push((path, is_encrypted));
            }
        }

        // Keep connector traversal deterministic across filesystems/runs.
        dirs.sort_by(|a, b| a.0.cmp(&b.0));
        dirs
    }

    fn looks_like_base(path: &Path) -> bool {
        if !path.is_dir() {
            return false;
        }

        if path
            .file_name()
            .and_then(|n| n.to_str())
            .is_some_and(|name| name.starts_with("conversations-"))
        {
            return true;
        }

        fs::read_dir(path)
            .map(|entries| {
                entries.flatten().any(|entry| {
                    entry.file_name().to_str().is_some_and(|name| {
                        name.starts_with("conversations-") && entry.path().is_dir()
                    })
                })
            })
            .unwrap_or(false)
    }

    fn append_explicit_roots(roots: &mut Vec<PathBuf>, base: &Path) {
        if Self::looks_like_base(base) {
            roots.push(base.to_path_buf());
        }

        let candidates = [
            base.join("com.openai.chat"),
            base.join("Library/Application Support/com.openai.chat"),
            base.join("AppData/Roaming/com.openai.chat"),
        ];

        for candidate in candidates {
            if Self::looks_like_base(&candidate) {
                roots.push(candidate);
            }
        }
    }

    fn conversation_files(dir_path: &Path) -> Vec<PathBuf> {
        let mut files = Vec::new();
        for entry in WalkDir::new(dir_path).max_depth(1).into_iter().flatten() {
            if !entry.file_type().is_file() {
                continue;
            }
            let path = entry.path();
            let ext = path.extension().and_then(|s| s.to_str());
            if ext == Some("json") || ext == Some("data") {
                files.push(path.to_path_buf());
            }
        }
        // Keep connector traversal deterministic across filesystems/runs.
        files.sort();
        files
    }

    fn source_roots(ctx: &ScanContext) -> Vec<ScanRoot> {
        let mut roots: Vec<ScanRoot> = if ctx.use_default_detection() {
            if Self::looks_like_base(&ctx.data_dir) {
                vec![ScanRoot::local(ctx.data_dir.clone())]
            } else if let Some(default_base) = Self::app_support_dir() {
                vec![ScanRoot::local(default_base)]
            } else {
                Vec::new()
            }
        } else {
            let mut explicit = Vec::new();
            for root in &ctx.scan_roots {
                let mut candidates = Vec::new();
                Self::append_explicit_roots(&mut candidates, &root.path);
                explicit.extend(candidates.into_iter().map(|path| root.with_path(path)));
            }
            explicit
        };

        roots.sort_by(|a, b| a.path.cmp(&b.path));
        roots.dedup_by(|a, b| a.path == b.path);
        roots
    }

    fn discover_sources(&self, ctx: &ScanContext) -> Vec<DiscoveredSourceFile> {
        let mut out = Vec::new();
        for root in Self::source_roots(ctx) {
            if !root.path.exists() {
                continue;
            }
            let conv_dirs = Self::find_conversation_dirs(&root.path);
            for (dir_path, is_encrypted) in conv_dirs {
                if is_encrypted && self.encryption_key.is_none() {
                    continue;
                }
                for path in Self::conversation_files(&dir_path) {
                    if !file_modified_since(&path, ctx.since_ts) {
                        continue;
                    }
                    out.push(
                        DiscoveredSourceFile::new(
                            "chatgpt",
                            &root,
                            path,
                            DiscoveredSourceRole::PrimarySessionLog,
                            true,
                        )
                        .with_fs_metadata(),
                    );
                }
            }
        }
        out
    }

    /// Decrypt an encrypted conversation file
    fn decrypt_file(&self, data: &[u8]) -> Result<Vec<u8>> {
        let key = self.encryption_key.ok_or_else(|| {
            anyhow::anyhow!(
                "No encryption key available. Set CHATGPT_ENCRYPTION_KEY env var or create key file."
            )
        })?;

        if data.len() < NONCE_SIZE + TAG_SIZE {
            anyhow::bail!("Encrypted data too short: {} bytes", data.len());
        }

        // Extract nonce from the beginning
        let nonce = Nonce::from_slice(&data[..NONCE_SIZE]);

        // The rest is ciphertext + tag
        let ciphertext = &data[NONCE_SIZE..];

        // Create cipher and decrypt
        let cipher = Aes256Gcm::new_from_slice(&key)
            .map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?;

        let plaintext = cipher
            .decrypt(nonce, ciphertext)
            .map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))?;

        Ok(plaintext)
    }

    /// Parse a conversation file (JSON or encrypted data format)
    fn parse_conversation_file(
        &self,
        path: &PathBuf,
        _since_ts: Option<i64>,
        is_encrypted: bool,
    ) -> Result<Option<NormalizedConversation>> {
        // Safety check: Don't read files larger than 100MB to avoid OOM
        if let Ok(metadata) = fs::metadata(path)
            && metadata.len() > 100 * 1024 * 1024
        {
            tracing::warn!(
                path = %path.display(),
                size_bytes = metadata.len(),
                "skipping large file (>100MB)"
            );
            return Ok(None);
        }

        let content_bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;

        // Decrypt if necessary
        let content = if is_encrypted {
            let decrypted = self.decrypt_file(&content_bytes)?;
            String::from_utf8(decrypted).with_context(|| {
                format!(
                    "decrypted content is not valid UTF-8 from {}",
                    path.display()
                )
            })?
        } else {
            String::from_utf8(content_bytes)
                .with_context(|| format!("content is not valid UTF-8 from {}", path.display()))?
        };

        let val: Value = serde_json::from_str(&content)
            .with_context(|| format!("parse JSON from {}", path.display()))?;

        let mut messages = Vec::new();
        let mut started_at: Option<i64> = None;
        let mut ended_at: Option<i64> = None;

        // Extract conversation ID
        let conv_id = val
            .get("id")
            .or_else(|| val.get("conversation_id"))
            .and_then(|v| v.as_str())
            .or_else(|| path.file_stem().and_then(|s| s.to_str()))
            .map(String::from);

        // Extract title
        let title = val.get("title").and_then(|v| v.as_str()).map(String::from);

        // Parse messages from mapping structure (ChatGPT format)
        if let Some(mapping) = val.get("mapping").and_then(|v| v.as_object()) {
            // Collect messages with their parent info for ordering
            let mut msg_nodes: Vec<(Option<String>, String, &Value)> = Vec::new();

            for (node_id, node) in mapping {
                if let Some(msg) = node.get("message") {
                    let parent = node
                        .get("parent")
                        .and_then(|v| v.as_str())
                        .map(String::from);
                    msg_nodes.push((parent, node_id.clone(), msg));
                }
            }

            // Simple ordering: sort by create_time if available
            msg_nodes.sort_by(|a, b| {
                let ts_a = a.2.get("create_time").and_then(|v| v.as_f64());
                let ts_b = b.2.get("create_time").and_then(|v| v.as_f64());
                match (ts_a, ts_b) {
                    (Some(a_ts), Some(b_ts)) => a_ts
                        .partial_cmp(&b_ts)
                        .unwrap_or(std::cmp::Ordering::Equal)
                        .then_with(|| a.1.cmp(&b.1)),
                    (Some(_), None) => std::cmp::Ordering::Less,
                    (None, Some(_)) => std::cmp::Ordering::Greater,
                    (None, None) => a.1.cmp(&b.1),
                }
            });

            for (_, _, msg) in msg_nodes {
                // Get role
                let role = msg
                    .get("author")
                    .and_then(|a| a.get("role"))
                    .and_then(|v| v.as_str())
                    .unwrap_or("assistant");

                // Skip system messages
                if role == "system" {
                    continue;
                }

                // Get content
                let content_val = msg.get("content");
                let content_str = if let Some(parts) = content_val
                    .and_then(|c| c.get("parts"))
                    .and_then(|p| p.as_array())
                {
                    parts
                        .iter()
                        .filter_map(|p| p.as_str())
                        .collect::<Vec<_>>()
                        .join("\n")
                } else if let Some(text) = content_val
                    .and_then(|c| c.get("text"))
                    .and_then(|t| t.as_str())
                {
                    text.to_string()
                } else {
                    continue;
                };

                if content_str.trim().is_empty() {
                    continue;
                }

                // Get timestamp (ChatGPT uses float seconds)
                let created_at = msg
                    .get("create_time")
                    .and_then(|v| v.as_f64())
                    .map(|ts| (ts * 1000.0).round() as i64);

                // NOTE: Do NOT filter individual messages by timestamp here!
                // The file-level check in file_modified_since() is sufficient.
                // Filtering messages would cause older messages to be lost when
                // the file is re-indexed after new messages are added.

                started_at = match (started_at, created_at) {
                    (Some(curr), Some(ts)) => Some(curr.min(ts)),
                    (None, Some(ts)) => Some(ts),
                    (other, None) => other,
                };
                ended_at = match (ended_at, created_at) {
                    (Some(curr), Some(ts)) => Some(curr.max(ts)),
                    (None, Some(ts)) => Some(ts),
                    (Some(curr), None) => Some(curr),
                    (None, None) => None,
                };

                // Get model info
                let model = msg
                    .get("metadata")
                    .and_then(|m| m.get("model_slug"))
                    .and_then(|v| v.as_str())
                    .map(String::from);

                messages.push(NormalizedMessage {
                    idx: messages.len() as i64,
                    role: role.to_string(),
                    author: model,
                    created_at,
                    content: content_str,
                    extra: msg.clone(),
                    invocations: Vec::new(),
                    snippets: Vec::new(),
                });
            }
        }

        // Also try simple messages array format
        if messages.is_empty()
            && let Some(msgs) = val.get("messages").and_then(|v| v.as_array())
        {
            for item in msgs {
                let role = item
                    .get("role")
                    .and_then(|v| v.as_str())
                    .unwrap_or("assistant");

                if role == "system" {
                    continue;
                }

                let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("");

                if content.trim().is_empty() {
                    continue;
                }

                let created_at = item
                    .get("timestamp")
                    .or_else(|| item.get("create_time"))
                    .and_then(parse_timestamp);

                // NOTE: Do NOT filter individual messages by timestamp here!
                // File-level check is sufficient for incremental indexing.

                started_at = match (started_at, created_at) {
                    (Some(curr), Some(ts)) => Some(curr.min(ts)),
                    (None, Some(ts)) => Some(ts),
                    (other, None) => other,
                };
                ended_at = match (ended_at, created_at) {
                    (Some(curr), Some(ts)) => Some(curr.max(ts)),
                    (None, Some(ts)) => Some(ts),
                    (Some(curr), None) => Some(curr),
                    (None, None) => None,
                };

                messages.push(NormalizedMessage {
                    idx: messages.len() as i64,
                    role: role.to_string(),
                    author: None,
                    created_at,
                    content: content.to_string(),
                    extra: item.clone(),
                    invocations: Vec::new(),
                    snippets: Vec::new(),
                });
            }
        }

        if messages.is_empty() {
            return Ok(None);
        }

        Ok(Some(NormalizedConversation {
            agent_slug: "chatgpt".to_string(),
            external_id: conv_id,
            title,
            workspace: None, // ChatGPT doesn't have workspace concept
            source_path: path.clone(),
            started_at,
            ended_at,
            metadata: serde_json::json!({
                "source": if is_encrypted { "chatgpt_desktop_encrypted" } else { "chatgpt_desktop" },
                "model": val.get("model").and_then(|v| v.as_str()),
                "encrypted": is_encrypted,
            }),
            messages,
        }))
    }
}

impl Connector for ChatGptConnector {
    fn detect(&self) -> DetectionResult {
        franken_detection_for_connector("chatgpt").unwrap_or_else(DetectionResult::not_found)
    }

    fn scan(&self, ctx: &ScanContext) -> Result<Vec<NormalizedConversation>> {
        let roots: Vec<PathBuf> = Self::source_roots(ctx)
            .into_iter()
            .map(|root| root.path)
            .collect();

        let mut all_convs = Vec::new();

        for base in roots {
            if !base.exists() {
                continue;
            }

            let conv_dirs = Self::find_conversation_dirs(&base);

            for (dir_path, is_encrypted) in conv_dirs {
                // Skip encrypted directories if we don't have a key
                if is_encrypted && self.encryption_key.is_none() {
                    tracing::debug!(
                        path = %dir_path.display(),
                        "chatgpt skipping encrypted directory (no decryption key)"
                    );
                    continue;
                }

                // Walk through conversation files
                for path in Self::conversation_files(&dir_path) {
                    // Skip files not modified since last scan
                    if !file_modified_since(&path, ctx.since_ts) {
                        continue;
                    }

                    match self.parse_conversation_file(&path, ctx.since_ts, is_encrypted) {
                        Ok(Some(conv)) => {
                            tracing::debug!(
                                path = %path.display(),
                                messages = conv.messages.len(),
                                encrypted = is_encrypted,
                                "chatgpt extracted conversation"
                            );
                            all_convs.push(conv);
                        }
                        Ok(None) => {
                            tracing::debug!(
                                path = %path.display(),
                                "chatgpt no messages in conversation"
                            );
                        }
                        Err(e) => {
                            if is_encrypted {
                                tracing::warn!(
                                    path = %path.display(),
                                    error = %e,
                                    "chatgpt failed to decrypt/parse conversation (key might be wrong)"
                                );
                            } else {
                                tracing::warn!(
                                    path = %path.display(),
                                    error = %e,
                                    "chatgpt failed to parse conversation"
                                );
                            }
                        }
                    }
                }
            }
        }

        Ok(all_convs)
    }

    fn discover_source_files(&self, ctx: &ScanContext) -> Result<Vec<DiscoveredSourceFile>> {
        Ok(self.discover_sources(ctx))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::connectors::scan::ScanRoot;
    use serde_json::json;
    use std::fs;
    use tempfile::TempDir;

    // =========================================================================
    // Constructor tests
    // =========================================================================

    #[test]
    fn connector_with_key_stores_key() {
        let key_bytes = [42u8; KEY_SIZE];
        let connector = ChatGptConnector {
            encryption_key: Some(key_bytes),
        };
        assert!(connector.encryption_key.is_some());
        assert_eq!(connector.encryption_key.unwrap(), key_bytes);
    }

    /// `coding_agent_session_search-srzwm`: pin the SSH-style key-file
    /// permission discipline. Mode 0644 / 0664 / any group-or-other
    /// permission MUST be refused; 0600 (and stricter) MUST pass.
    #[test]
    #[cfg(unix)]
    fn chatgpt_key_file_mode_is_safe_refuses_permissive_modes() {
        use std::os::unix::fs::PermissionsExt;
        let dir = TempDir::new().unwrap();
        let key_path = dir.path().join("chatgpt_key.bin");
        fs::write(&key_path, [0u8; KEY_SIZE]).unwrap();

        // 0600 — strict, must pass.
        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o600)).unwrap();
        assert!(
            ChatGptConnector::chatgpt_key_file_mode_is_safe(&key_path),
            "0600 mode must be accepted"
        );

        // 0644 — world-readable, must be refused.
        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o644)).unwrap();
        assert!(
            !ChatGptConnector::chatgpt_key_file_mode_is_safe(&key_path),
            "0644 mode (world-readable) MUST be refused — leaks AES-256 key to other users"
        );

        // 0660 — group-readable, must be refused.
        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o660)).unwrap();
        assert!(
            !ChatGptConnector::chatgpt_key_file_mode_is_safe(&key_path),
            "0660 mode (group-readable) MUST be refused"
        );

        // 0400 — read-only owner, must pass.
        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o400)).unwrap();
        assert!(
            ChatGptConnector::chatgpt_key_file_mode_is_safe(&key_path),
            "0400 mode must be accepted"
        );
    }

    /// `coding_agent_session_search-srzwm`: symlinks at the key file
    /// path MUST be refused. A symlink to /dev/null or /etc/passwd
    /// would silently load the wrong bytes as a "key" — bad UX (the
    /// decrypt fails further downstream with a confusing error)
    /// AND a TOCTOU surface (the symlink target can change between
    /// stat and read).
    #[test]
    #[cfg(unix)]
    fn chatgpt_key_file_mode_is_safe_refuses_symlinks() {
        let dir = TempDir::new().unwrap();
        let real_key = dir.path().join("real_key.bin");
        fs::write(&real_key, [0u8; KEY_SIZE]).unwrap();
        let link_path = dir.path().join("symlinked_key.bin");
        std::os::unix::fs::symlink(&real_key, &link_path).unwrap();
        assert!(
            !ChatGptConnector::chatgpt_key_file_mode_is_safe(&link_path),
            "symlink at the chatgpt key file path MUST be refused"
        );
    }

    #[test]
    fn connector_without_key_stores_none() {
        let connector = ChatGptConnector {
            encryption_key: None,
        };
        assert!(connector.encryption_key.is_none());
    }

    // =========================================================================
    // find_conversation_dirs tests
    // =========================================================================

    #[test]
    fn find_conversation_dirs_empty_for_nonexistent() {
        let dir = TempDir::new().unwrap();
        let base = dir.path().join("nonexistent");

        let dirs = ChatGptConnector::find_conversation_dirs(&base);
        assert!(dirs.is_empty());
    }

    #[test]
    fn find_conversation_dirs_detects_v1_unencrypted() {
        let dir = TempDir::new().unwrap();
        let conv_dir = dir.path().join("conversations-abc123");
        fs::create_dir_all(&conv_dir).unwrap();

        let dirs = ChatGptConnector::find_conversation_dirs(&dir.path().to_path_buf());

        assert_eq!(dirs.len(), 1);
        assert!(!dirs[0].1); // Not encrypted
    }

    #[test]
    fn find_conversation_dirs_detects_v2_encrypted() {
        let dir = TempDir::new().unwrap();
        let conv_dir = dir.path().join("conversations-v2-abc123");
        fs::create_dir_all(&conv_dir).unwrap();

        let dirs = ChatGptConnector::find_conversation_dirs(&dir.path().to_path_buf());

        assert_eq!(dirs.len(), 1);
        assert!(dirs[0].1); // Encrypted
    }

    #[test]
    fn find_conversation_dirs_detects_v3_encrypted() {
        let dir = TempDir::new().unwrap();
        let conv_dir = dir.path().join("conversations-v3-abc123");
        fs::create_dir_all(&conv_dir).unwrap();

        let dirs = ChatGptConnector::find_conversation_dirs(&dir.path().to_path_buf());

        assert_eq!(dirs.len(), 1);
        assert!(dirs[0].1); // Encrypted
    }

    #[test]
    fn find_conversation_dirs_mixed_versions() {
        let dir = TempDir::new().unwrap();
        fs::create_dir_all(dir.path().join("conversations-old")).unwrap();
        fs::create_dir_all(dir.path().join("conversations-v2-new")).unwrap();
        fs::create_dir_all(dir.path().join("other-folder")).unwrap();

        let dirs = ChatGptConnector::find_conversation_dirs(&dir.path().to_path_buf());

        assert_eq!(dirs.len(), 2);
        // One encrypted, one not
        let encrypted_count = dirs.iter().filter(|(_, enc)| *enc).count();
        let unencrypted_count = dirs.iter().filter(|(_, enc)| !*enc).count();
        assert_eq!(encrypted_count, 1);
        assert_eq!(unencrypted_count, 1);
    }

    #[test]
    fn find_conversation_dirs_returns_sorted_order() {
        let dir = TempDir::new().unwrap();
        fs::create_dir_all(dir.path().join("conversations-zed")).unwrap();
        fs::create_dir_all(dir.path().join("conversations-alpha")).unwrap();

        let dirs = ChatGptConnector::find_conversation_dirs(&dir.path().to_path_buf());
        assert_eq!(dirs.len(), 2);

        let names: Vec<_> = dirs
            .iter()
            .map(|(path, _)| {
                path.file_name()
                    .and_then(|s| s.to_str())
                    .unwrap_or("")
                    .to_string()
            })
            .collect();
        assert_eq!(names, vec!["conversations-alpha", "conversations-zed"]);
    }

    #[test]
    fn conversation_files_returns_sorted_order() {
        let dir = TempDir::new().unwrap();
        fs::write(dir.path().join("z.json"), "{}").unwrap();
        fs::write(dir.path().join("a.json"), "{}").unwrap();
        fs::write(dir.path().join("ignore.txt"), "x").unwrap();

        let files = ChatGptConnector::conversation_files(dir.path());
        assert_eq!(files.len(), 2);

        let names: Vec<_> = files
            .iter()
            .map(|path| {
                path.file_name()
                    .and_then(|s| s.to_str())
                    .unwrap_or("")
                    .to_string()
            })
            .collect();
        assert_eq!(names, vec!["a.json", "z.json"]);
    }

    // =========================================================================
    // decrypt_file tests
    // =========================================================================

    #[test]
    fn decrypt_file_fails_without_key() {
        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let data = vec![0u8; 100];

        let result = connector.decrypt_file(&data);
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("No encryption key")
        );
    }

    #[test]
    fn decrypt_file_fails_for_too_short_data() {
        let connector = ChatGptConnector {
            encryption_key: Some([0u8; KEY_SIZE]),
        };
        // Less than NONCE_SIZE + TAG_SIZE
        let data = vec![0u8; 10];

        let result = connector.decrypt_file(&data);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("too short"));
    }

    #[test]
    fn decrypt_file_fails_with_wrong_key() {
        let connector = ChatGptConnector {
            encryption_key: Some([0u8; KEY_SIZE]),
        };
        // Valid-length but garbage data
        let data = vec![0u8; NONCE_SIZE + 50 + TAG_SIZE];

        let result = connector.decrypt_file(&data);
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("Decryption failed")
        );
    }

    // =========================================================================
    // parse_conversation_file tests (mapping format)
    // =========================================================================

    #[test]
    fn parse_mapping_format_conversation() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("conv1.json");

        let conv_json = json!({
            "id": "conv-123",
            "title": "Test Conversation",
            "mapping": {
                "node1": {
                    "parent": null,
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Hello, ChatGPT!"]},
                        "create_time": 1700000000.123
                    }
                },
                "node2": {
                    "parent": "node1",
                    "message": {
                        "author": {"role": "assistant"},
                        "content": {"parts": ["Hello! How can I help you?"]},
                        "create_time": 1700000001.456,
                        "metadata": {"model_slug": "gpt-4"}
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        assert!(result.is_ok());
        let conv = result.unwrap().unwrap();

        assert_eq!(conv.agent_slug, "chatgpt");
        assert_eq!(conv.external_id, Some("conv-123".to_string()));
        assert_eq!(conv.title, Some("Test Conversation".to_string()));
        assert_eq!(conv.messages.len(), 2);

        // Check first message (user)
        assert_eq!(conv.messages[0].role, "user");
        assert_eq!(conv.messages[0].content, "Hello, ChatGPT!");

        // Check second message (assistant with model)
        assert_eq!(conv.messages[1].role, "assistant");
        assert!(conv.messages[1].content.contains("How can I help"));
        assert_eq!(conv.messages[1].author, Some("gpt-4".to_string()));
    }

    #[test]
    fn parse_mapping_preserves_ended_at_with_missing_timestamp() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("conv_missing_ts.json");

        let conv_json = json!({
            "id": "conv-missing-ts",
            "mapping": {
                "node1": {
                    "parent": null,
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["First message"]},
                        "create_time": 1700000000.0
                    }
                },
                "node2": {
                    "parent": "node1",
                    "message": {
                        "author": {"role": "assistant"},
                        "content": {"parts": ["Second message without timestamp"]}
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        assert_eq!(conv.messages.len(), 2);
        assert_eq!(conv.messages[0].content, "First message");
        assert_eq!(conv.messages[1].content, "Second message without timestamp");
        assert_eq!(conv.started_at, Some(1_700_000_000_000));
        assert_eq!(conv.ended_at, Some(1_700_000_000_000));
    }

    #[test]
    fn parse_skips_system_messages() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("conv.json");

        let conv_json = json!({
            "id": "conv-sys",
            "mapping": {
                "sys": {
                    "parent": null,
                    "message": {
                        "author": {"role": "system"},
                        "content": {"parts": ["You are a helpful assistant."]},
                        "create_time": 1700000000.0
                    }
                },
                "user": {
                    "parent": "sys",
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Hi!"]},
                        "create_time": 1700000001.0
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        // System message should be skipped
        assert_eq!(conv.messages.len(), 1);
        assert_eq!(conv.messages[0].role, "user");
    }

    #[test]
    fn parse_skips_empty_content() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("conv.json");

        let conv_json = json!({
            "id": "conv-empty",
            "mapping": {
                "empty": {
                    "parent": null,
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": [""]},
                        "create_time": 1700000000.0
                    }
                },
                "whitespace": {
                    "parent": "empty",
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["   \n\t  "]},
                        "create_time": 1700000001.0
                    }
                },
                "valid": {
                    "parent": "whitespace",
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Valid message"]},
                        "create_time": 1700000002.0
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        // Empty/whitespace messages should be skipped
        assert_eq!(conv.messages.len(), 1);
        assert_eq!(conv.messages[0].content, "Valid message");
    }

    // =========================================================================
    // parse_conversation_file tests (simple messages array format)
    // =========================================================================

    #[test]
    fn parse_simple_messages_array_format() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("conv.json");

        let conv_json = json!({
            "id": "simple-conv",
            "title": "Simple Format",
            "messages": [
                {
                    "role": "user",
                    "content": "Question?",
                    "timestamp": 1700000000000i64
                },
                {
                    "role": "assistant",
                    "content": "Answer!",
                    "timestamp": 1700000001000i64
                }
            ]
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        assert_eq!(conv.messages.len(), 2);
        assert_eq!(conv.messages[0].role, "user");
        assert_eq!(conv.messages[0].content, "Question?");
        assert_eq!(conv.messages[1].role, "assistant");
        assert_eq!(conv.messages[1].content, "Answer!");
    }

    #[test]
    fn parse_content_with_text_field() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("conv.json");

        let conv_json = json!({
            "id": "text-content",
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"text": "Using text field instead of parts"},
                        "create_time": 1700000000.0
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        assert_eq!(conv.messages.len(), 1);
        assert!(conv.messages[0].content.contains("text field"));
    }

    // =========================================================================
    // Edge case tests
    // =========================================================================

    #[test]
    fn parse_returns_none_for_empty_conversation() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("empty.json");

        let conv_json = json!({
            "id": "empty-conv",
            "mapping": {}
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        assert!(result.is_ok());
        assert!(result.unwrap().is_none());
    }

    #[test]
    fn parse_handles_missing_optional_fields() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("minimal.json");

        // Minimal conversation - no id, no title, no timestamps
        let conv_json = json!({
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Minimal message"]}
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        assert_eq!(conv.messages.len(), 1);
        // external_id falls back to filename stem
        assert!(conv.external_id.is_some());
        assert!(conv.title.is_none());
        assert!(conv.started_at.is_none());
    }

    #[test]
    fn parse_extracts_id_from_conversation_id_field() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("conv.json");

        let conv_json = json!({
            "conversation_id": "alt-id-123",
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Test"]}
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        assert_eq!(conv.external_id, Some("alt-id-123".to_string()));
    }

    #[test]
    fn parse_fails_gracefully_for_invalid_json() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("invalid.json");

        fs::write(&conv_file, "not valid json {{{").unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        assert!(result.is_err());
    }

    #[test]
    fn parse_multipart_content_joins_with_newlines() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("multipart.json");

        let conv_json = json!({
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Part 1", "Part 2", "Part 3"]},
                        "create_time": 1700000000.0
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        assert_eq!(conv.messages[0].content, "Part 1\nPart 2\nPart 3");
    }

    #[test]
    fn parse_sets_metadata_correctly() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("meta.json");

        let conv_json = json!({
            "model": "gpt-4-turbo",
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Test"]}
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        assert_eq!(conv.metadata["source"], "chatgpt_desktop");
        assert_eq!(conv.metadata["model"], "gpt-4-turbo");
        assert_eq!(conv.metadata["encrypted"], false);
    }

    #[test]
    fn parse_encrypted_flag_in_metadata() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("enc.json");

        let conv_json = json!({
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Test"]}
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        // Parse as unencrypted first to test flag
        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);
        let conv = result.unwrap().unwrap();
        assert_eq!(conv.metadata["encrypted"], false);
    }

    // =========================================================================
    // Timestamps and ordering tests
    // =========================================================================

    #[test]
    fn parse_orders_messages_by_create_time() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("ordered.json");

        // Messages in reverse order in JSON
        let conv_json = json!({
            "mapping": {
                "late": {
                    "message": {
                        "author": {"role": "assistant"},
                        "content": {"parts": ["Second"]},
                        "create_time": 1700000002.0
                    }
                },
                "early": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["First"]},
                        "create_time": 1700000001.0
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        // Should be ordered by create_time
        assert_eq!(conv.messages[0].content, "First");
        assert_eq!(conv.messages[1].content, "Second");
    }

    #[test]
    fn parse_converts_float_timestamp_to_millis() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("ts.json");

        let conv_json = json!({
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Test"]},
                        "create_time": 1700000000.5  // .5 seconds
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        // Should be converted to milliseconds
        assert_eq!(conv.messages[0].created_at, Some(1700000000500));
    }

    // =========================================================================
    // Detection tests
    // =========================================================================

    #[test]
    fn detect_not_found_without_app_dir() {
        let connector = ChatGptConnector {
            encryption_key: None,
        };

        let result = connector.detect();
        let _ = result.detected;
    }

    // =========================================================================
    // Scan tests
    // =========================================================================

    #[test]
    fn scan_empty_directory_returns_empty() {
        let dir = TempDir::new().unwrap();
        let connector = ChatGptConnector {
            encryption_key: None,
        };

        let ctx = ScanContext::local_default(dir.path().to_path_buf(), None);
        let result = connector.scan(&ctx);

        assert!(result.is_ok());
        assert!(result.unwrap().is_empty());
    }

    #[test]
    fn scan_skips_encrypted_without_key() {
        let dir = TempDir::new().unwrap();

        // Create encrypted directory structure
        let conv_dir = dir.path().join("conversations-v2-abc123");
        fs::create_dir_all(&conv_dir).unwrap();

        // Create a conversation file (will be skipped as encrypted)
        let conv_json = json!({
            "id": "test",
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Test"]}
                    }
                }
            }
        });
        fs::write(conv_dir.join("conv.json"), conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };

        // Use a context that will make it look for conversations in our dir
        let ctx = ScanContext::local_default(dir.path().to_path_buf(), None);
        let result = connector.scan(&ctx);

        assert!(result.is_ok());
        // Encrypted dirs are skipped without key
        assert!(result.unwrap().is_empty());
    }

    #[test]
    fn scan_processes_unencrypted_conversations() {
        let dir = TempDir::new().unwrap();

        // Create a directory structure that mimics com.openai.chat
        let openai_dir = dir.path().join("com.openai.chat");
        fs::create_dir_all(&openai_dir).unwrap();

        // Create unencrypted directory structure
        let conv_dir = openai_dir.join("conversations-uuid123");
        fs::create_dir_all(&conv_dir).unwrap();

        let conv_json = json!({
            "id": "test-conv",
            "title": "Test Title",
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Hello!"]},
                        "create_time": 1700000000.0
                    }
                }
            }
        });
        fs::write(conv_dir.join("conv.json"), conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };

        // Pass the openai directory so scan recognizes it
        let ctx = ScanContext::local_default(openai_dir.clone(), None);
        let result = connector.scan(&ctx);

        assert!(result.is_ok());
        let convs = result.unwrap();
        assert_eq!(convs.len(), 1);
        assert_eq!(convs[0].title, Some("Test Title".to_string()));
    }

    #[test]
    fn scan_accepts_base_with_conversations_dir() {
        let dir = TempDir::new().unwrap();

        let conv_dir = dir.path().join("conversations-uuid123");
        fs::create_dir_all(&conv_dir).unwrap();

        let conv_json = json!({
            "id": "test-conv",
            "title": "Direct Base",
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Hello!"]},
                        "create_time": 1700000000.0
                    }
                }
            }
        });
        fs::write(conv_dir.join("conv.json"), conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };

        let ctx = ScanContext::local_default(dir.path().to_path_buf(), None);
        let result = connector.scan(&ctx);

        assert!(result.is_ok());
        let convs = result.unwrap();
        assert_eq!(convs.len(), 1);
        assert_eq!(convs[0].title, Some("Direct Base".to_string()));
    }

    #[test]
    fn scan_with_application_support_scan_root() {
        let dir = TempDir::new().unwrap();
        let app_support = dir.path().join("Library").join("Application Support");
        let openai_dir = app_support.join("com.openai.chat");
        let conv_dir = openai_dir.join("conversations-uuid123");
        fs::create_dir_all(&conv_dir).unwrap();

        let conv_json = json!({
            "id": "test-conv",
            "title": "App Support",
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Hello!"]},
                        "create_time": 1700000000.0
                    }
                }
            }
        });
        fs::write(conv_dir.join("conv.json"), conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };

        let ctx = ScanContext::with_roots(PathBuf::new(), vec![ScanRoot::local(app_support)], None);
        let result = connector.scan(&ctx);

        assert!(result.is_ok());
        let convs = result.unwrap();
        assert_eq!(convs.len(), 1);
        assert_eq!(convs[0].title, Some("App Support".to_string()));
    }

    // =========================================================================
    // Edge case tests — malformed input robustness
    // =========================================================================

    #[test]
    fn parse_truncated_json_returns_error() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("truncated.json");
        fs::write(
            &conv_file,
            r#"{"id": "trunc", "mapping": {"node1": {"message":"#,
        )
        .unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);
        assert!(result.is_err(), "truncated JSON should return an error");
    }

    #[test]
    fn parse_type_mismatch_in_mapping_nodes_handled() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("mismatch.json");

        let conv_json = json!({
            "mapping": {
                // Node with string instead of object for message
                "bad1": {
                    "message": "not an object"
                },
                // Node with null message
                "bad2": {
                    "message": null
                },
                // Node without message key
                "bad3": {
                    "parent": "bad2"
                },
                // Node with number instead of object
                "bad4": {
                    "message": 42
                },
                // Valid node
                "good": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Valid"]},
                        "create_time": 1700000000.0
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        assert!(result.is_ok());
        let conv = result.unwrap().unwrap();
        assert_eq!(conv.messages.len(), 1);
        assert_eq!(conv.messages[0].content, "Valid");
    }

    #[test]
    fn parse_deeply_nested_json_does_not_stack_overflow() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("deep.json");

        // Build deeply nested JSON (200 levels)
        let mut nested = String::new();
        for _ in 0..200 {
            nested.push_str("{\"a\":");
        }
        nested.push('1');
        for _ in 0..200 {
            nested.push('}');
        }
        fs::write(&conv_file, &nested).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        // Should not panic; may error due to recursion limit
        let result = connector.parse_conversation_file(&conv_file, None, false);
        if let Ok(conv) = result {
            assert!(
                conv.is_none(),
                "deep JSON without mapping should return None"
            );
        }
    }

    #[test]
    fn parse_large_message_body_handled() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("large.json");

        let large_content = "x".repeat(1_000_000);
        let conv_json = json!({
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": [large_content]},
                        "create_time": 1700000000.0
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        assert_eq!(conv.messages[0].content.len(), 1_000_000);
    }

    #[test]
    fn parse_null_bytes_in_content_handled() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("nullbytes.json");

        let conv_json = json!({
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["before\u{0000}after"]},
                        "create_time": 1700000000.0
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);
        assert!(result.is_ok(), "null bytes should not cause errors");
    }

    #[test]
    fn parse_nonexistent_file_returns_error() {
        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(
            &PathBuf::from("/nonexistent/file.json"),
            None,
            false,
        );
        assert!(result.is_err());
    }

    #[test]
    fn parse_mapping_nodes_without_content_parts_or_text_skipped() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("no_content.json");

        let conv_json = json!({
            "mapping": {
                // Message with content that has neither parts nor text
                "bad": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"type": "unknown"},
                        "create_time": 1700000000.0
                    }
                },
                // Message with parts containing non-string values
                "bad2": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": [42, null, true]},
                        "create_time": 1700000001.0
                    }
                },
                // Valid message
                "good": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Valid"]},
                        "create_time": 1700000002.0
                    }
                }
            }
        });

        fs::write(&conv_file, conv_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);

        let conv = result.unwrap().unwrap();
        assert_eq!(conv.messages.len(), 1);
        assert_eq!(conv.messages[0].content, "Valid");
    }

    #[test]
    fn parse_invalid_utf8_file_returns_error() {
        let dir = TempDir::new().unwrap();
        let conv_file = dir.path().join("bad_utf8.json");
        fs::write(&conv_file, b"\xFF\xFE invalid utf8 bytes").unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let result = connector.parse_conversation_file(&conv_file, None, false);
        assert!(result.is_err(), "invalid UTF-8 should return an error");
    }

    #[test]
    fn scan_mixed_valid_and_invalid_conversation_files() {
        let dir = TempDir::new().unwrap();
        let conv_dir = dir.path().join("conversations-uuid123");
        fs::create_dir_all(&conv_dir).unwrap();

        // Invalid JSON file
        fs::write(conv_dir.join("bad.json"), "not json").unwrap();

        // Valid conversation
        let good_json = json!({
            "id": "good-conv",
            "mapping": {
                "node1": {
                    "message": {
                        "author": {"role": "user"},
                        "content": {"parts": ["Valid conversation"]},
                        "create_time": 1700000000.0
                    }
                }
            }
        });
        fs::write(conv_dir.join("good.json"), good_json.to_string()).unwrap();

        // Empty conversation
        let empty_json = json!({"id": "empty", "mapping": {}});
        fs::write(conv_dir.join("empty.json"), empty_json.to_string()).unwrap();

        let connector = ChatGptConnector {
            encryption_key: None,
        };
        let ctx = ScanContext::local_default(dir.path().to_path_buf(), None);
        let convs = connector.scan(&ctx).unwrap();

        assert_eq!(convs.len(), 1, "should extract only the valid conversation");
        assert_eq!(convs[0].messages[0].content, "Valid conversation");
        crate::connectors::assert_discovery_covers_scan_sources(&connector, &ctx);
    }
}
