use crate::desktop::{DesktopSessionItem, DesktopSessionMessage};
use crate::lifecycle_store::LedgerEntry;
use serde::Deserialize;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use walkdir::WalkDir;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClaudeSessionsIndexFile {
    entries: Vec<ClaudeSessionIndexEntry>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClaudeSessionIndexEntry {
    session_id: String,
    full_path: String,
    summary: Option<String>,
    first_prompt: Option<String>,
    message_count: Option<usize>,
    created: Option<String>,
    modified: Option<String>,
    project_path: Option<String>,
}

#[derive(Debug, Deserialize)]
struct CodexSessionIndexEntry {
    id: String,
    thread_name: Option<String>,
    updated_at: String,
}

#[derive(Default)]
struct CodexSessionMeta {
    cwd: Option<String>,
    prompt_preview: Option<String>,
    updated_at: Option<String>,
}

pub struct ProviderSessionMessages {
    pub messages: Vec<DesktopSessionMessage>,
    pub total_messages: usize,
    pub has_more_messages: bool,
}

const SESSION_MESSAGE_MAX_CHARS: usize = 2400;
/// Maximum lines to read from a bare .jsonl file for metadata extraction.
const BARE_FILE_HEAD_LINES: usize = 20;

pub fn raw_session_id(session_id: &str) -> String {
    session_id
        .split_once(':')
        .map(|(_, raw)| raw.to_string())
        .unwrap_or_else(|| session_id.to_string())
}

pub fn build_memory_session_items(entries: &[LedgerEntry]) -> Vec<DesktopSessionItem> {
    #[derive(Default)]
    struct Aggregate {
        last_recorded_at: String,
        record_count: usize,
        pending_review_count: usize,
        wakeup_ready_count: usize,
        titles: BTreeSet<String>,
        memory_types: BTreeSet<String>,
    }

    let mut grouped: BTreeMap<String, Aggregate> = BTreeMap::new();
    for entry in entries {
        for session_id in entry_session_refs(entry) {
            let aggregate = grouped.entry(session_id).or_default();
            if entry.recorded_at > aggregate.last_recorded_at {
                aggregate.last_recorded_at = entry.recorded_at.clone();
            }
            aggregate.record_count += 1;
            if entry.record.requires_review() {
                aggregate.pending_review_count += 1;
            }
            if entry.record.can_be_returned_in_wakeup() {
                aggregate.wakeup_ready_count += 1;
            }
            aggregate.titles.insert(entry.record.title.clone());
            aggregate
                .memory_types
                .insert(entry.record.memory_type.clone());
        }
    }

    let mut sessions: Vec<DesktopSessionItem> = grouped
        .into_iter()
        .map(|(session_id, aggregate)| DesktopSessionItem {
            provider: "spool".to_string(),
            session_id: format!("spool:{session_id}"),
            title: aggregate
                .titles
                .iter()
                .next()
                .cloned()
                .unwrap_or_else(|| session_id.clone()),
            summary: Some("来自 spool memory 记录的会话聚合".to_string()),
            prompt_preview: aggregate.titles.iter().next().cloned(),
            cwd: None,
            source_path: None,
            project_path: None,
            updated_at: aggregate.last_recorded_at.clone(),
            record_count: aggregate.record_count,
            pending_review_count: aggregate.pending_review_count,
            wakeup_ready_count: aggregate.wakeup_ready_count,
            titles: aggregate.titles.into_iter().take(4).collect(),
            memory_types: aggregate.memory_types.into_iter().collect(),
        })
        .collect();

    sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
    sessions
}

/// Load provider sessions with an optional provider filter.
/// When `filter` is `Some("claude")`, only Claude sessions are loaded, etc.
/// When `filter` is `None`, all providers are loaded.
pub fn load_provider_sessions(filter: Option<&str>) -> anyhow::Result<Vec<DesktopSessionItem>> {
    let mut sessions = Vec::new();
    if filter.is_none() || filter == Some("claude") {
        sessions.extend(load_claude_sessions()?);
    }
    if filter.is_none() || filter == Some("codex") {
        sessions.extend(load_codex_sessions()?);
    }
    if filter.is_none() || filter == Some("gemini") {
        sessions.extend(load_gemini_sessions()?);
    }
    sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
    Ok(sessions)
}

/// Load messages for a provider session with pagination support.
/// `offset` is the number of messages to skip from the beginning.
/// `limit` is the maximum number of messages to return (0 means all).
pub fn load_provider_messages(
    session: &DesktopSessionItem,
    offset: usize,
    limit: usize,
) -> anyhow::Result<ProviderSessionMessages> {
    match session.provider.as_str() {
        "claude" => load_claude_messages(session, offset, limit),
        "codex" => load_codex_messages(session, offset, limit),
        _ => Ok(ProviderSessionMessages {
            messages: Vec::new(),
            total_messages: 0,
            has_more_messages: false,
        }),
    }
}

pub fn entry_session_refs(entry: &LedgerEntry) -> BTreeSet<String> {
    let mut refs = BTreeSet::new();
    collect_session_ref(entry.record.origin.source_ref.as_str(), &mut refs);
    for evidence in &entry.metadata.evidence_refs {
        collect_session_ref(evidence.as_str(), &mut refs);
    }
    refs
}

fn home_dir() -> Option<PathBuf> {
    crate::support::home_dir()
}

// ---- Claude Code sessions ----

fn load_claude_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
    let Some(home) = home_dir() else {
        return Ok(Vec::new());
    };
    let projects_root = home.join(".claude/projects");
    if !projects_root.exists() {
        return Ok(Vec::new());
    }

    let mut sessions = Vec::new();
    let mut indexed_paths: BTreeSet<String> = BTreeSet::new();

    // Phase 1: Load from sessions-index.json files (backward compatible)
    for entry in WalkDir::new(&projects_root).min_depth(1).max_depth(2) {
        let entry = match entry {
            Ok(entry) => entry,
            Err(_) => continue,
        };
        if entry.file_name() != "sessions-index.json" {
            continue;
        }
        let parsed: ClaudeSessionsIndexFile =
            match serde_json::from_str(&fs::read_to_string(entry.path())?) {
                Ok(value) => value,
                Err(_) => continue,
            };
        for item in parsed.entries {
            if fs::metadata(&item.full_path).is_err() {
                continue;
            }
            indexed_paths.insert(item.full_path.clone());
            let updated_at = item
                .modified
                .clone()
                .or(item.created.clone())
                .unwrap_or_else(|| "unknown".to_string());
            let title = item
                .summary
                .clone()
                .or(item.first_prompt.clone())
                .unwrap_or_else(|| item.session_id.clone());
            sessions.push(DesktopSessionItem {
                provider: "claude".to_string(),
                session_id: format!("claude:{}", item.session_id),
                title,
                summary: item.summary.clone().or(item.first_prompt.clone()),
                prompt_preview: item.first_prompt.clone(),
                cwd: item.project_path.clone(),
                source_path: Some(item.full_path.clone()),
                project_path: item.project_path,
                updated_at,
                record_count: item.message_count.unwrap_or(0),
                pending_review_count: 0,
                wakeup_ready_count: 0,
                titles: Vec::new(),
                memory_types: Vec::new(),
            });
        }
    }

    // Phase 2: Scan bare .jsonl files not covered by any index
    sessions.extend(load_claude_bare_sessions(&projects_root, &indexed_paths)?);

    Ok(sessions)
}

/// Scan `~/.claude/projects/*/` for bare .jsonl files not already in the index.
/// Only reads the first N lines of each file for metadata extraction.
fn load_claude_bare_sessions(
    projects_root: &std::path::Path,
    indexed_paths: &BTreeSet<String>,
) -> anyhow::Result<Vec<DesktopSessionItem>> {
    let mut sessions = Vec::new();

    for entry in WalkDir::new(projects_root)
        .min_depth(2)
        .max_depth(2)
        .into_iter()
        .filter_map(Result::ok)
    {
        if !entry.file_type().is_file() {
            continue;
        }
        let path = entry.path();
        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
            continue;
        }
        // Skip files already covered by sessions-index.json
        let path_str = path.display().to_string();
        if indexed_paths.contains(&path_str) {
            continue;
        }
        // Skip the index file itself
        if path
            .file_name()
            .map(|n| n == "sessions-index.json")
            .unwrap_or(false)
        {
            continue;
        }

        let session_id = path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("unknown")
            .to_string();

        // Derive project_path from parent directory name
        // e.g., "-Users-long-Work-spool" → "/Users/long/Work/spool"
        let project_path = path
            .parent()
            .and_then(|p| p.file_name())
            .and_then(|n| n.to_str())
            .map(decode_claude_project_slug);

        let meta = extract_claude_bare_meta(path);

        let title = meta.title.unwrap_or_else(|| {
            meta.first_prompt
                .clone()
                .unwrap_or_else(|| session_id.clone())
        });
        let updated_at = meta.last_timestamp.unwrap_or_else(|| {
            // Fallback to file mtime
            fs::metadata(path)
                .and_then(|m| m.modified())
                .ok()
                .and_then(|t| {
                    let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
                    Some(format_unix_timestamp(duration.as_secs()))
                })
                .unwrap_or_else(|| "unknown".to_string())
        });

        sessions.push(DesktopSessionItem {
            provider: "claude".to_string(),
            session_id: format!("claude:{}", session_id),
            title,
            summary: meta.first_prompt.clone(),
            prompt_preview: meta.first_prompt,
            cwd: meta.cwd.or(project_path.clone()),
            source_path: Some(path_str),
            project_path,
            updated_at,
            record_count: 0,
            pending_review_count: 0,
            wakeup_ready_count: 0,
            titles: Vec::new(),
            memory_types: Vec::new(),
        });
    }

    Ok(sessions)
}

/// Metadata extracted from the head of a bare Claude .jsonl file.
#[derive(Default)]
struct ClaudeBareFileMeta {
    title: Option<String>,
    first_prompt: Option<String>,
    cwd: Option<String>,
    last_timestamp: Option<String>,
}

/// Read the first N lines of a bare Claude .jsonl to extract metadata.
fn extract_claude_bare_meta(path: &std::path::Path) -> ClaudeBareFileMeta {
    let mut meta = ClaudeBareFileMeta::default();
    let file = match fs::File::open(path) {
        Ok(f) => f,
        Err(_) => return meta,
    };
    let reader = BufReader::new(file);

    for (idx, line) in reader.lines().enumerate() {
        if idx >= BARE_FILE_HEAD_LINES {
            break;
        }
        let line = match line {
            Ok(l) => l,
            Err(_) => break,
        };
        if line.trim().is_empty() {
            continue;
        }
        let value: Value = match serde_json::from_str(&line) {
            Ok(v) => v,
            Err(_) => continue,
        };

        // Track latest timestamp seen
        if let Some(ts) = value.get("timestamp").and_then(Value::as_str) {
            meta.last_timestamp = Some(ts.to_string());
        }

        match value.get("type").and_then(Value::as_str) {
            Some("ai-title") if meta.title.is_none() => {
                meta.title = value
                    .get("aiTitle")
                    .and_then(Value::as_str)
                    .map(truncate_for_preview);
            }
            Some("user") => {
                if meta.first_prompt.is_none() {
                    meta.first_prompt =
                        extract_content_text(&value).map(|s| truncate_for_preview(&s));
                }
                if meta.cwd.is_none() {
                    meta.cwd = value
                        .get("cwd")
                        .and_then(Value::as_str)
                        .map(ToString::to_string);
                }
            }
            _ => {}
        }

        if meta.title.is_some() && meta.first_prompt.is_some() && meta.cwd.is_some() {
            break;
        }
    }

    meta
}

/// Decode a Claude project slug back to a path.
/// e.g., "-Users-long-Work-spool" → "/Users/long/Work/spool"
fn decode_claude_project_slug(slug: &str) -> String {
    if slug.starts_with('-') {
        slug.replacen('-', "/", 1).replace('-', "/")
    } else {
        slug.replace('-', "/")
    }
}

// ---- Codex sessions ----

/// Load Codex sessions by directly scanning the file tree.
/// Falls back to session_index.jsonl metadata when available for enrichment.
fn load_codex_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
    let Some(home) = home_dir() else {
        return Ok(Vec::new());
    };
    let sessions_root = home.join(".codex/sessions");

    // Build a lookup from index for enrichment (thread_name, updated_at)
    let index_lookup = load_codex_index_lookup(&home);

    // Directly scan the file tree
    if !sessions_root.exists() {
        return Ok(Vec::new());
    }

    let mut sessions = Vec::new();
    for entry in WalkDir::new(&sessions_root)
        .into_iter()
        .filter_map(Result::ok)
    {
        if !entry.file_type().is_file() {
            continue;
        }
        let path = entry.path();
        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
            continue;
        }
        let Some(name) = path.file_stem().and_then(|name| name.to_str()) else {
            continue;
        };
        // Extract UUID from filename like "rollout-2026-05-08T15-58-31-019e0698-6647-7681-8fe3-bafa985c83df"
        // The UUID is the last 36 characters (8-4-4-4-12 format with hyphens)
        let id = extract_codex_uuid(name);
        let Some(id) = id else {
            continue;
        };

        let path_str = path.display().to_string();
        let meta = load_codex_session_meta(&path_str);

        // Enrich from index if available
        let index_entry = index_lookup.get(id);
        let thread_name = index_entry.and_then(|e| e.thread_name.clone());
        let index_updated_at = index_entry.map(|e| e.updated_at.clone());

        let updated_at = meta.updated_at.or(index_updated_at).unwrap_or_else(|| {
            fs::metadata(path)
                .and_then(|m| m.modified())
                .ok()
                .and_then(|t| {
                    let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
                    Some(format_unix_timestamp(duration.as_secs()))
                })
                .unwrap_or_else(|| "unknown".to_string())
        });

        let title = meta
            .prompt_preview
            .clone()
            .or(thread_name.clone())
            .unwrap_or_else(|| id.to_string());

        sessions.push(DesktopSessionItem {
            provider: "codex".to_string(),
            session_id: format!("codex:{}", id),
            title,
            summary: thread_name.or(meta.prompt_preview.clone()),
            prompt_preview: meta.prompt_preview,
            cwd: meta.cwd,
            source_path: Some(path_str),
            project_path: None,
            updated_at,
            record_count: 0,
            pending_review_count: 0,
            wakeup_ready_count: 0,
            titles: Vec::new(),
            memory_types: Vec::new(),
        });
    }

    Ok(sessions)
}

/// Extract UUID from a Codex session filename stem.
/// Filename pattern: "rollout-2026-05-08T15-58-31-019e0698-6647-7681-8fe3-bafa985c83df"
/// The UUID is the last 36 characters in 8-4-4-4-12 format.
fn extract_codex_uuid(name: &str) -> Option<&str> {
    if name.len() < 36 {
        return None;
    }
    let candidate = &name[name.len() - 36..];
    // Verify it looks like a UUID: 8-4-4-4-12 with exactly 4 hyphens
    let parts: Vec<&str> = candidate.split('-').collect();
    if parts.len() != 5 {
        return None;
    }
    let expected_lens = [8, 4, 4, 4, 12];
    for (part, &expected) in parts.iter().zip(&expected_lens) {
        if part.len() != expected || !part.chars().all(|c| c.is_ascii_hexdigit()) {
            return None;
        }
    }
    Some(candidate)
}

/// Load the codex session_index.jsonl as a lookup map for enrichment.
fn load_codex_index_lookup(home: &std::path::Path) -> BTreeMap<String, CodexSessionIndexEntry> {
    let index_path = home.join(".codex/session_index.jsonl");
    let mut lookup = BTreeMap::new();
    let content = match fs::read_to_string(index_path) {
        Ok(c) => c,
        Err(_) => return lookup,
    };
    for line in content.lines().filter(|l| !l.trim().is_empty()) {
        if let Ok(entry) = serde_json::from_str::<CodexSessionIndexEntry>(line) {
            lookup.insert(entry.id.clone(), entry);
        }
    }
    lookup
}

fn load_codex_session_meta(path: &str) -> CodexSessionMeta {
    let mut meta = CodexSessionMeta::default();
    let file = match fs::File::open(path) {
        Ok(f) => f,
        Err(_) => return meta,
    };
    let reader = BufReader::new(file);

    for line in reader.lines() {
        let line = match line {
            Ok(l) => l,
            Err(_) => break,
        };
        if line.trim().is_empty() {
            continue;
        }
        let value: Value = match serde_json::from_str(&line) {
            Ok(value) => value,
            Err(_) => continue,
        };

        // Track timestamp for updated_at
        if let Some(ts) = value.get("timestamp").and_then(Value::as_str) {
            meta.updated_at = Some(ts.to_string());
        }

        match value.get("type").and_then(Value::as_str) {
            Some("session_meta") => {
                meta.cwd = value
                    .get("payload")
                    .and_then(|payload| payload.get("cwd"))
                    .and_then(Value::as_str)
                    .map(ToString::to_string);
            }
            Some("response_item") => {
                let payload = value.get("payload");
                let role = payload
                    .and_then(|payload| payload.get("role"))
                    .and_then(Value::as_str);
                if role == Some("user") && meta.prompt_preview.is_none() {
                    meta.prompt_preview = payload
                        .and_then(|payload| payload.get("content"))
                        .and_then(Value::as_array)
                        .and_then(|items| {
                            items.iter().find_map(|item| {
                                item.get("text")
                                    .and_then(Value::as_str)
                                    .map(truncate_for_preview)
                            })
                        });
                }
            }
            _ => {}
        }

        if meta.cwd.is_some() && meta.prompt_preview.is_some() {
            break;
        }
    }

    meta
}

// ---- Gemini CLI sessions (skeleton) ----

fn load_gemini_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
    let Some(home) = home_dir() else {
        return Ok(Vec::new());
    };
    let history_root = home.join(".gemini/history");
    if !history_root.exists() {
        return Ok(Vec::new());
    }

    let mut sessions = Vec::new();
    for entry in WalkDir::new(&history_root)
        .min_depth(1)
        .max_depth(3)
        .into_iter()
        .filter_map(Result::ok)
    {
        if !entry.file_type().is_file() {
            continue;
        }
        let path = entry.path();
        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
        if ext != "json" && ext != "jsonl" {
            continue;
        }
        let session_id = path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("unknown")
            .to_string();

        let updated_at = fs::metadata(path)
            .and_then(|m| m.modified())
            .ok()
            .and_then(|t| {
                let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
                Some(format_unix_timestamp(duration.as_secs()))
            })
            .unwrap_or_else(|| "unknown".to_string());

        sessions.push(DesktopSessionItem {
            provider: "gemini".to_string(),
            session_id: format!("gemini:{}", session_id),
            title: session_id.clone(),
            summary: None,
            prompt_preview: None,
            cwd: None,
            source_path: Some(path.display().to_string()),
            project_path: None,
            updated_at,
            record_count: 0,
            pending_review_count: 0,
            wakeup_ready_count: 0,
            titles: Vec::new(),
            memory_types: Vec::new(),
        });
    }

    Ok(sessions)
}

// ---- Message loading with pagination ----

fn load_claude_messages(
    session: &DesktopSessionItem,
    offset: usize,
    limit: usize,
) -> anyhow::Result<ProviderSessionMessages> {
    let Some(path) = session.source_path.as_deref() else {
        return Ok(ProviderSessionMessages {
            messages: Vec::new(),
            total_messages: 0,
            has_more_messages: false,
        });
    };
    let mut messages = Vec::new();
    for line in fs::read_to_string(path)?
        .lines()
        .filter(|line| !line.trim().is_empty())
    {
        let value: Value = match serde_json::from_str(line) {
            Ok(value) => value,
            Err(_) => continue,
        };

        // New format: type-based messages
        let msg_type = value.get("type").and_then(Value::as_str);
        match msg_type {
            Some("user") | Some("assistant") => {
                let role = msg_type.unwrap();
                let content_text = extract_content_text(&value).unwrap_or_default();
                if content_text.is_empty() {
                    continue;
                }
                messages.push(DesktopSessionMessage {
                    role: role.to_string(),
                    timestamp: value
                        .get("timestamp")
                        .and_then(Value::as_str)
                        .unwrap_or("unknown")
                        .to_string(),
                    content: truncate_for_detail(&content_text),
                    truncated: is_content_truncated(&content_text),
                });
            }
            _ => {
                // Legacy format: message.role + message.content (string)
                let role = value
                    .get("message")
                    .and_then(|message| message.get("role"))
                    .and_then(Value::as_str);
                let content = value
                    .get("message")
                    .and_then(|message| message.get("content"))
                    .and_then(Value::as_str);
                if let (Some(role), Some(content)) = (role, content) {
                    messages.push(DesktopSessionMessage {
                        role: role.to_string(),
                        timestamp: value
                            .get("timestamp")
                            .and_then(Value::as_str)
                            .unwrap_or("unknown")
                            .to_string(),
                        content: truncate_for_detail(content),
                        truncated: is_content_truncated(content),
                    });
                }
            }
        }
    }
    Ok(paginate_messages(messages, offset, limit))
}

fn load_codex_messages(
    session: &DesktopSessionItem,
    offset: usize,
    limit: usize,
) -> anyhow::Result<ProviderSessionMessages> {
    let Some(path) = session.source_path.as_deref() else {
        return Ok(ProviderSessionMessages {
            messages: Vec::new(),
            total_messages: 0,
            has_more_messages: false,
        });
    };
    let mut messages = Vec::new();
    for line in fs::read_to_string(path)?
        .lines()
        .filter(|line| !line.trim().is_empty())
    {
        let value: Value = match serde_json::from_str(line) {
            Ok(value) => value,
            Err(_) => continue,
        };
        let message_payload = value
            .get("payload")
            .filter(|_| value.get("type").and_then(Value::as_str) == Some("response_item"));
        let Some(payload) = message_payload else {
            continue;
        };
        if payload.get("type").and_then(Value::as_str) != Some("message") {
            continue;
        }
        let Some(role) = payload.get("role").and_then(Value::as_str) else {
            continue;
        };
        let text = payload
            .get("content")
            .and_then(Value::as_array)
            .and_then(|items| {
                items.iter().find_map(|item| {
                    item.get("text")
                        .and_then(Value::as_str)
                        .map(ToString::to_string)
                })
            });
        if let Some(text) = text {
            messages.push(DesktopSessionMessage {
                role: role.to_string(),
                timestamp: value
                    .get("timestamp")
                    .and_then(Value::as_str)
                    .unwrap_or("unknown")
                    .to_string(),
                content: truncate_for_detail(&text),
                truncated: is_content_truncated(&text),
            });
        }
    }
    Ok(paginate_messages(messages, offset, limit))
}

// ---- Helpers ----

fn collect_session_ref(value: &str, refs: &mut BTreeSet<String>) {
    if value.starts_with("session:") {
        refs.insert(value.to_string());
    }
}

/// Extract text content from a new-format Claude message.
/// Handles both `message.content` as array of content blocks and as a plain string.
fn extract_content_text(value: &Value) -> Option<String> {
    let message = value.get("message")?;
    let content = message.get("content")?;

    // Array of content blocks: [{"type": "text", "text": "..."}, ...]
    if let Some(arr) = content.as_array() {
        let texts: Vec<&str> = arr
            .iter()
            .filter_map(|item| item.get("text").and_then(Value::as_str))
            .collect();
        if texts.is_empty() {
            return None;
        }
        return Some(texts.join("\n"));
    }

    // Plain string content (legacy)
    content.as_str().map(ToString::to_string)
}

fn truncate_for_preview(value: &str) -> String {
    let trimmed = value.trim();
    if trimmed.chars().count() <= 360 {
        return trimmed.to_string();
    }
    trimmed.chars().take(360).collect::<String>() + "..."
}

fn truncate_for_detail(value: &str) -> String {
    let trimmed = value.trim();
    if trimmed.chars().count() <= SESSION_MESSAGE_MAX_CHARS {
        return trimmed.to_string();
    }
    trimmed
        .chars()
        .take(SESSION_MESSAGE_MAX_CHARS)
        .collect::<String>()
        + "\n\n...[truncated]"
}

fn is_content_truncated(value: &str) -> bool {
    value.trim().chars().count() > SESSION_MESSAGE_MAX_CHARS
}

/// Apply offset/limit pagination to a collected message list.
fn paginate_messages(
    messages: Vec<DesktopSessionMessage>,
    offset: usize,
    limit: usize,
) -> ProviderSessionMessages {
    let total_messages = messages.len();
    let effective_limit = if limit == 0 { total_messages } else { limit };
    let start = offset.min(total_messages);
    let end = (start + effective_limit).min(total_messages);
    let paged: Vec<DesktopSessionMessage> =
        messages.into_iter().skip(start).take(end - start).collect();
    let has_more_messages = end < total_messages;
    ProviderSessionMessages {
        messages: paged,
        total_messages,
        has_more_messages,
    }
}

fn format_unix_timestamp(secs: u64) -> String {
    // Simple ISO-ish format without pulling in chrono
    let days_since_epoch = secs / 86400;
    let time_of_day = secs % 86400;
    let hours = time_of_day / 3600;
    let minutes = (time_of_day % 3600) / 60;
    let seconds = time_of_day % 60;

    // Approximate year/month/day from days since epoch (1970-01-01)
    let mut remaining_days = days_since_epoch as i64;
    let mut year = 1970i64;
    loop {
        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
        if remaining_days < days_in_year {
            break;
        }
        remaining_days -= days_in_year;
        year += 1;
    }
    let days_in_months: [i64; 12] = if is_leap_year(year) {
        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    } else {
        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    };
    let mut month = 1u32;
    for &days_in_month in &days_in_months {
        if remaining_days < days_in_month {
            break;
        }
        remaining_days -= days_in_month;
        month += 1;
    }
    let day = remaining_days + 1;

    format!(
        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
        year, month, day, hours, minutes, seconds
    )
}

fn is_leap_year(year: i64) -> bool {
    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}

// ---- Tests ----

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    fn test_session(provider: &str, path: &std::path::Path) -> DesktopSessionItem {
        DesktopSessionItem {
            provider: provider.to_string(),
            session_id: format!("{provider}:demo"),
            title: "demo".to_string(),
            summary: None,
            prompt_preview: None,
            cwd: Some("/tmp/demo".to_string()),
            source_path: Some(path.display().to_string()),
            project_path: Some("/tmp/demo".to_string()),
            updated_at: "2026-04-16T12:00:00Z".to_string(),
            record_count: 0,
            pending_review_count: 0,
            wakeup_ready_count: 0,
            titles: Vec::new(),
            memory_types: Vec::new(),
        }
    }

    #[test]
    fn claude_message_loader_should_paginate_with_offset_and_limit() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("claude-session.jsonl");
        let mut lines = Vec::new();
        for index in 0..30 {
            lines.push(format!(
                "{{\"timestamp\":\"2026-04-16T12:{index:02}:00Z\",\"message\":{{\"role\":\"user\",\"content\":\"message {index} {}\"}}}}",
                "x".repeat(32)
            ));
        }
        fs::write(&path, lines.join("\n")).unwrap();

        // Default: offset=0, limit=0 returns all
        let response = load_claude_messages(&test_session("claude", &path), 0, 0).unwrap();
        assert_eq!(response.total_messages, 30);
        assert!(!response.has_more_messages);
        assert_eq!(response.messages.len(), 30);

        // With limit
        let response = load_claude_messages(&test_session("claude", &path), 0, 10).unwrap();
        assert_eq!(response.total_messages, 30);
        assert!(response.has_more_messages);
        assert_eq!(response.messages.len(), 10);
        assert_eq!(
            response.messages.first().unwrap().content,
            format!("message 0 {}", "x".repeat(32))
        );

        // With offset + limit
        let response = load_claude_messages(&test_session("claude", &path), 25, 10).unwrap();
        assert_eq!(response.total_messages, 30);
        assert!(!response.has_more_messages);
        assert_eq!(response.messages.len(), 5);
        assert_eq!(
            response.messages.first().unwrap().content,
            format!("message 25 {}", "x".repeat(32))
        );
    }

    #[test]
    fn claude_message_loader_should_handle_new_format() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("claude-new.jsonl");
        let lines = [
            r#"{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"content":[{"type":"text","text":"hello world"}]},"cwd":"/tmp","sessionId":"abc123"}"#,
            r#"{"type":"assistant","timestamp":"2026-05-01T10:01:00Z","message":{"content":[{"type":"text","text":"hi there"}]},"sessionId":"abc123"}"#,
            r#"{"type":"ai-title","aiTitle":"Test Session","sessionId":"abc123"}"#,
        ];
        fs::write(&path, lines.join("\n")).unwrap();

        let response = load_claude_messages(&test_session("claude", &path), 0, 0).unwrap();
        assert_eq!(response.total_messages, 2);
        assert_eq!(response.messages[0].role, "user");
        assert_eq!(response.messages[0].content, "hello world");
        assert_eq!(response.messages[1].role, "assistant");
        assert_eq!(response.messages[1].content, "hi there");
    }

    #[test]
    fn codex_message_loader_should_mark_truncated_detail_content() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("codex-session.jsonl");
        let long_text = "y".repeat(3000);
        fs::write(
            &path,
            format!(
                "{{\"timestamp\":\"2026-04-16T12:00:00Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{{\"text\":\"{}\"}}]}}}}",
                long_text
            ),
        )
        .unwrap();

        let response = load_codex_messages(&test_session("codex", &path), 0, 0).unwrap();
        assert_eq!(response.total_messages, 1);
        assert!(!response.has_more_messages);
        assert_eq!(response.messages.len(), 1);
        assert!(response.messages[0].truncated);
        assert!(response.messages[0].content.ends_with("...[truncated]"));
    }

    #[test]
    fn extract_claude_bare_meta_should_parse_new_format() {
        let temp = tempdir().unwrap();
        let path = temp.path().join("session.jsonl");
        let lines = [
            r#"{"type":"ai-title","aiTitle":"My Session Title","sessionId":"abc"}"#,
            r#"{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"content":[{"type":"text","text":"first prompt here"}]},"cwd":"/home/user/project","sessionId":"abc"}"#,
        ];
        fs::write(&path, lines.join("\n")).unwrap();

        let meta = extract_claude_bare_meta(&path);
        assert_eq!(meta.title.as_deref(), Some("My Session Title"));
        assert_eq!(meta.first_prompt.as_deref(), Some("first prompt here"));
        assert_eq!(meta.cwd.as_deref(), Some("/home/user/project"));
        assert_eq!(meta.last_timestamp.as_deref(), Some("2026-05-01T10:00:00Z"));
    }

    #[test]
    fn decode_claude_project_slug_should_restore_path() {
        assert_eq!(
            decode_claude_project_slug("-Users-long-Work-spool"),
            "/Users/long/Work/spool"
        );
        assert_eq!(
            decode_claude_project_slug("home-user-project"),
            "home/user/project"
        );
    }

    #[test]
    fn paginate_messages_should_handle_edge_cases() {
        let msgs: Vec<DesktopSessionMessage> = (0..5)
            .map(|i| DesktopSessionMessage {
                role: "user".to_string(),
                timestamp: format!("t{i}"),
                content: format!("msg{i}"),
                truncated: false,
            })
            .collect();

        // offset beyond total
        let result = paginate_messages(msgs.clone(), 10, 5);
        assert_eq!(result.messages.len(), 0);
        assert!(!result.has_more_messages);
        assert_eq!(result.total_messages, 5);

        // limit=0 means all
        let result = paginate_messages(msgs.clone(), 0, 0);
        assert_eq!(result.messages.len(), 5);
        assert!(!result.has_more_messages);

        // partial page
        let result = paginate_messages(msgs, 3, 10);
        assert_eq!(result.messages.len(), 2);
        assert!(!result.has_more_messages);
    }

    #[test]
    fn codex_session_scan_should_find_files_without_index() {
        let temp = tempdir().unwrap();
        let sessions_dir = temp.path().join("sessions/2026/05/01");
        fs::create_dir_all(&sessions_dir).unwrap();

        let uuid = "019e0698-6647-7681-8fe3-bafa985c83df";
        let filename = format!("rollout-2026-05-01T10-00-00-{uuid}.jsonl");
        let session_path = sessions_dir.join(&filename);
        fs::write(
            &session_path,
            r#"{"type":"session_meta","payload":{"cwd":"/tmp/test"}}
{"type":"response_item","timestamp":"2026-05-01T10:00:00Z","payload":{"type":"message","role":"user","content":[{"text":"hello codex"}]}}"#,
        )
        .unwrap();

        let meta = load_codex_session_meta(&session_path.display().to_string());
        assert_eq!(meta.cwd.as_deref(), Some("/tmp/test"));
        assert_eq!(meta.prompt_preview.as_deref(), Some("hello codex"));
    }

    #[test]
    fn extract_codex_uuid_should_parse_real_filenames() {
        // Real filename pattern from ~/.codex/sessions/
        assert_eq!(
            extract_codex_uuid("rollout-2026-03-26T15-18-28-019d2902-48c6-71f1-a107-6cb56512de80"),
            Some("019d2902-48c6-71f1-a107-6cb56512de80")
        );
        assert_eq!(
            extract_codex_uuid("rollout-2026-05-08T15-58-31-019e0698-6647-7681-8fe3-bafa985c83df"),
            Some("019e0698-6647-7681-8fe3-bafa985c83df")
        );
        // Too short
        assert_eq!(extract_codex_uuid("short"), None);
        // Not a valid UUID at the end
        assert_eq!(
            extract_codex_uuid("rollout-2026-05-08T15-58-31-not-a-valid-uuid-at-all-here"),
            None
        );
    }
}
