//! Claude Code session importer.
//!
//! Uses dirigent_anth to discover and parse Claude Code's local JSONL sessions,
//! then converts them into archivist MessageRecords for import.

use std::collections::HashMap;

use camino::Utf8PathBuf;
use chrono::{DateTime, Utc};
use uuid::Uuid;

use dirigent_anth::types::{
    Content, ContentBlock, RawAssistantMessage, RawMessage, RawUserMessage, SessionRef,
};
use dirigent_anth::{classify_noise, discover_projects, load_session, parse_timestamp};

use async_trait::async_trait;

use super::super::progress::ImportProgressSink;
use super::super::trait_def::{
    ConfigField, ConfigFieldKind, ImportConfig, ImportConfigShape, ImportError, ImportTarget,
    Importer,
};
use super::super::{
    import_sessions, DiscoveredSession, ImportDiscovery, ImportProject, ImportStats,
};
use crate::coordinator::Archivist;
use crate::error::{ArchivistError, Result};
use crate::types::{MessageRecord, RegisterConnectorRequest};

/// Connector type string used for imported Claude Code sessions.
pub const CLAUDE_CONNECTOR_TYPE: &str = "ClaudeCode";

/// Fingerprint prefix for locally-imported Claude Code sessions.
pub const CLAUDE_FINGERPRINT_PREFIX: &str = "import/local:claude-code";

/// Namespace UUID for deterministic fallback message_ids when Claude's source
/// `uuid` field is missing. Arbitrary, stable constant.
const CLAUDE_MESSAGE_NS: uuid::Uuid = uuid::Uuid::from_bytes([
    0x43, 0x4c, 0x41, 0x55, 0x44, 0x45, 0x2d, 0x4d, 0x53, 0x47, 0x2d, 0x4e, 0x53, 0x2d, 0x56, 0x35,
]);

/// Derive a stable `message_id` for a Claude message record.
///
/// Priority:
/// 1. Parse the `source_uuid` as a UUID (Claude's JSONL carries per-message
///    UUIDs natively).
/// 2. Fall back to `Uuid::new_v5(CLAUDE_MESSAGE_NS, "<native_session>:<ts>:<role>:<content_md>")`.
fn derive_message_id(
    source_uuid: Option<&str>,
    native_session_id: &str,
    ts: &chrono::DateTime<chrono::Utc>,
    role: &str,
    content_md: &str,
) -> uuid::Uuid {
    if let Some(raw) = source_uuid {
        if let Ok(parsed) = uuid::Uuid::parse_str(raw) {
            return parsed;
        }
    }
    let key = format!("{}:{}:{}:{}", native_session_id, ts.to_rfc3339(), role, content_md);
    uuid::Uuid::new_v5(&CLAUDE_MESSAGE_NS, key.as_bytes())
}

// ---------------------------------------------------------------------------
// Discovery
// ---------------------------------------------------------------------------

/// Discover Claude Code sessions available for import.
///
/// If `claude_home` is `Some`, uses it as the Claude home directory;
/// otherwise auto-detects via `dirigent_anth::discover_claude_home()`.
///
/// Returns the resolved home path and a summary of discovered projects/sessions.
pub fn discover_claude_import(
    claude_home: Option<&str>,
) -> std::result::Result<(Utf8PathBuf, ImportDiscovery), String> {
    let home = match claude_home {
        Some(p) => Utf8PathBuf::from(p),
        None => dirigent_anth::discover_claude_home().map_err(|e| e.to_string())?,
    };

    let projects = discover_projects(&home).map_err(|e| e.to_string())?;

    let mut import_projects = Vec::new();
    let mut total_sessions: usize = 0;
    let mut total_estimated_messages: usize = 0;

    for project in &projects {
        let session_count = project.sessions.len();
        total_sessions += session_count;

        for session in &project.sessions {
            if let Some(ref idx) = session.index_entry {
                total_estimated_messages += idx.message_count.unwrap_or(0) as usize;
            }
        }

        import_projects.push(ImportProject {
            name: project.original_path.clone(),
            session_count,
        });
    }

    let discovery = ImportDiscovery {
        source_name: "Claude Code".to_string(),
        source_path: home.to_string(),
        projects: import_projects,
        total_sessions,
        total_estimated_messages,
    };

    Ok((home, discovery))
}

// ---------------------------------------------------------------------------
// Import orchestration
// ---------------------------------------------------------------------------

/// Import all Claude Code sessions from `claude_home` into the archivist.
///
/// This discovers all projects and sessions, registers a connector, and imports
/// each session by parsing its JSONL and converting messages to `MessageRecord`s.
///
/// Pass `&ImportProgressSink::noop()` when progress reporting is not needed.
pub async fn import_claude_sessions(
    archivist: &crate::coordinator::Archivist,
    claude_home: &Utf8PathBuf,
    archive: Option<String>,
    progress: &ImportProgressSink,
    project_map: &HashMap<String, String>,
) -> Result<ImportStats> {
    let projects =
        discover_projects(claude_home).map_err(|e| ArchivistError::InvalidRequest(e.to_string()))?;

    // Build discovered sessions and a lookup map.
    let mut discovered: Vec<DiscoveredSession> = Vec::new();
    let mut session_lookup: HashMap<String, SessionRef> = HashMap::new();

    for project in &projects {
        for session_ref in &project.sessions {
            let idx = session_ref.index_entry.as_ref();

            let mut title = idx
                .and_then(|i| i.summary.clone())
                .or_else(|| idx.and_then(|i| i.first_prompt.clone()));

            // Fallback: derive title from first user message content.
            // NOTE: This parses the JSONL file, adding I/O cost during discovery.
            // The same session will be parsed again during message conversion.
            // Acceptable for a background import task; could be optimized later
            // by restructuring to parse once and extract title + messages together.
            if title.is_none() {
                if let Ok(parsed) = load_session(session_ref) {
                    for msg in &parsed.messages {
                        // Skip noise (queue ops, meta, warmup, etc.)
                        if classify_noise(msg).is_some() {
                            continue;
                        }
                        if let RawMessage::User(user) = msg {
                            let text = match &user.message.content {
                                Content::Text(s) => s.clone(),
                                Content::Blocks(blocks) => blocks
                                    .iter()
                                    .filter_map(|b| match b {
                                        ContentBlock::Text { text } => Some(text.as_str()),
                                        _ => None,
                                    })
                                    .collect::<Vec<_>>()
                                    .join(" "),
                            };
                            let trimmed = text.trim();
                            // Skip system-injected XML content (commands, caveats, etc.)
                            if trimmed.is_empty()
                                || trimmed.starts_with('<')
                                || is_pure_tool_result_message(user)
                            {
                                continue;
                            }
                            title = Some(derive_title_from_content(trimmed));
                            break;
                        }
                    }
                }
            }

            let created_at = idx
                .and_then(|i| i.created.as_ref())
                .and_then(parse_timestamp);

            let updated_at = idx
                .and_then(|i| i.modified.as_ref())
                .and_then(parse_timestamp);

            let message_count = idx.and_then(|i| i.message_count).unwrap_or(0) as usize;

            let metadata = serde_json::json!({
                "project_path": project.original_path,
                "git_branch": idx.and_then(|i| i.git_branch.clone()),
            });

            // Get file size from the JSONL file for fingerprint-based change detection.
            let file_size = std::fs::metadata(session_ref.jsonl_path.as_std_path())
                .ok()
                .map(|m| m.len());

            discovered.push(DiscoveredSession {
                native_session_id: session_ref.id.clone(),
                title,
                created_at,
                updated_at,
                message_count,
                metadata,
                project_path: Some(project.original_path.clone()),
                file_size,
            });

            session_lookup.insert(session_ref.id.clone(), session_ref.clone());
        }
    }

    let fingerprint = format!("{}:{}", CLAUDE_FINGERPRINT_PREFIX, claude_home);

    let connector_req = RegisterConnectorRequest {
        r#type: CLAUDE_CONNECTOR_TYPE.to_string(),
        title: format!("Claude Code ({})", claude_home),
        client_native_id: fingerprint.clone(),
        custom_uid: None,
        metadata: serde_json::json!({}),
        fingerprint: Some(fingerprint),
    };

    let convert = |native_id: &str| -> Result<Vec<MessageRecord>> {
        let session_ref = session_lookup.get(native_id).ok_or_else(|| {
            ArchivistError::InvalidRequest(format!(
                "Session ref not found for native_id: {}",
                native_id
            ))
        })?;
        convert_session_to_records(session_ref)
    };

    let mut stats = import_sessions(
        archivist,
        connector_req,
        discovered,
        convert,
        archive.clone(),
        progress,
        false,
        project_map,
    )
    .await?;

    // Phase 2: Import subagent sessions
    import_subagents(archivist, &session_lookup, archive, &mut stats).await?;

    Ok(stats)
}

/// Import subagent sessions for all parent sessions that have them.
///
/// For each session in the lookup, loads and parses it, then for each subagent:
/// 1. Registers a new session with `is_subagent=true` and `parent_scroll_id`
/// 2. Converts subagent messages to MessageRecords
/// 3. Writes a DAG edge linking parent to child
async fn import_subagents(
    archivist: &crate::coordinator::Archivist,
    session_lookup: &HashMap<String, SessionRef>,
    archive: Option<String>,
    stats: &mut ImportStats,
) -> Result<()> {
    use crate::types::{
        Continuation, DagEdge, RegisterSessionRequest, RegisterStatus, SessionCompleteness,
    };

    for (native_id, session_ref) in session_lookup {
        // Load the full parsed session (includes subagents with linkage)
        let parsed = match load_session(session_ref) {
            Ok(p) => p,
            Err(e) => {
                tracing::debug!(native_id = %native_id, error = %e, "Failed to load session for subagent import");
                continue;
            }
        };

        if parsed.subagents.is_empty() {
            continue;
        }

        // Resolve the parent's scroll_id from the archive
        let parent_scroll_id = match archivist
            .find_session_owner(native_id, archive.clone())
            .await
        {
            Ok(Some((_connector_uid, scroll_id))) => scroll_id,
            _ => {
                tracing::debug!(native_id = %native_id, "Parent session not found in archive, skipping subagent import");
                continue;
            }
        };

        // Get parent's connector_uid from metadata
        let parent_meta = match archivist.get_session_metadata(parent_scroll_id, archive.clone()).await {
            Ok(m) => m,
            Err(_) => continue,
        };

        for subagent in &parsed.subagents {
            // Use composite native ID for idempotent dedup
            let subagent_native_id = format!("{}:agent:{}", native_id, subagent.agent_id);

            // Check if already imported (idempotent)
            if archivist
                .resolve_session(parent_meta.connector_uid, &subagent_native_id, archive.clone())
                .await
                .is_ok()
            {
                tracing::debug!(
                    agent_id = %subagent.agent_id,
                    parent = %native_id,
                    "Subagent already imported, skipping"
                );
                continue;
            }

            // Derive a title from the Agent tool call description if available
            let title = parsed.tool_exchanges.iter()
                .find(|ex| {
                    ex.call.name == dirigent_anth::types::ToolName::Agent
                        && subagent.parent_tool_call_id.as_deref() == Some(&ex.call.id)
                })
                .and_then(|ex| ex.call.input.get("description").and_then(|v| v.as_str()))
                .map(|s| format!("[{}] {}", subagent.meta.agent_type.as_deref().unwrap_or("Agent"), s));

            let register_req = RegisterSessionRequest {
                connector_uid: parent_meta.connector_uid,
                native_session_id: subagent_native_id.clone(),
                title,
                custom_scroll_id: None,
                metadata: serde_json::json!({
                    "parent_native_session_id": native_id,
                }),
                completeness: SessionCompleteness::Complete,
                parent_scroll_id: Some(parent_scroll_id),
                is_subagent: true,
                continuation: Some(Continuation::Subagent),
                agent_id: Some(subagent.agent_id.clone()),
                subagent_type: subagent.meta.agent_type.clone(),
                spawning_tool_use_id: subagent.parent_tool_call_id.clone(),
            };

            let child_scroll_id = match archivist
                .register_session(register_req, archive.clone())
                .await
            {
                Ok(resp) => match resp.status {
                    RegisterStatus::Accepted => resp.scroll_id,
                    RegisterStatus::Aliased => {
                        tracing::debug!(agent_id = %subagent.agent_id, "Subagent session aliased");
                        continue;
                    }
                    RegisterStatus::Rejected => {
                        stats.errors.push(format!(
                            "Subagent registration rejected for agent_id {}",
                            subagent.agent_id
                        ));
                        continue;
                    }
                },
                Err(e) => {
                    stats.errors.push(format!(
                        "Failed to register subagent {}: {}",
                        subagent.agent_id, e
                    ));
                    continue;
                }
            };

            // Convert subagent messages to records
            let mut records: Vec<crate::types::MessageRecord> = subagent
                .messages
                .iter()
                .filter_map(|msg| {
                    convert_ant_message_with_exchanges(
                        msg,
                        child_scroll_id,
                        &[],
                        &subagent_native_id,
                    )
                })
                .collect();

            // Patch session field
            for record in &mut records {
                if record.session == Uuid::nil() {
                    record.session = child_scroll_id;
                }
            }

            let msg_count = records.len();

            if let Err(e) = archivist
                .append_messages(child_scroll_id, records, archive.clone())
                .await
            {
                stats.errors.push(format!(
                    "Failed to append subagent messages for {}: {}",
                    subagent.agent_id, e
                ));
                continue;
            }

            // Write DAG edge
            let edge = DagEdge {
                parent: parent_scroll_id,
                child: child_scroll_id,
                agent_id: subagent.agent_id.clone(),
                subagent_type: subagent.meta.agent_type.clone(),
                tool_use_id: subagent.parent_tool_call_id.clone(),
                ts: Some(chrono::Utc::now()),
            };

            if let Err(e) = archivist.append_dag_edge(edge, archive.clone()).await {
                tracing::warn!(
                    agent_id = %subagent.agent_id,
                    error = %e,
                    "Failed to write DAG edge (subagent still imported)"
                );
            }

            stats.sessions_imported += 1;
            stats.messages_written += msg_count;

            tracing::info!(
                agent_id = %subagent.agent_id,
                parent_scroll_id = %parent_scroll_id,
                child_scroll_id = %child_scroll_id,
                messages = msg_count,
                "Subagent session imported"
            );
        }
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Session → MessageRecord conversion
// ---------------------------------------------------------------------------

/// Convert a single Claude Code session (referenced by `SessionRef`) into a
/// vector of `MessageRecord`s.
///
/// Uses `dirigent_anth::load_session` to parse, dedup, and correlate the JSONL,
/// then converts each non-noise message. Tool calls are correlated with their
/// results using `ParsedSession.tool_exchanges`, and user messages that contain
/// only tool results are suppressed (their content is merged into the assistant's
/// `MessagePart::Tool` parts). The `session` field on each record is set to
/// `Uuid::nil()` — the import orchestrator patches it to the real scroll_id.
pub fn convert_session_to_records(session_ref: &SessionRef) -> Result<Vec<MessageRecord>> {
    let parsed = load_session(session_ref)
        .map_err(|e| ArchivistError::InvalidRequest(format!("Failed to load session: {}", e)))?;

    let placeholder = Uuid::nil();
    let native_session_id = session_ref.id.as_str();
    let records: Vec<MessageRecord> = parsed
        .messages
        .iter()
        .filter_map(|msg| {
            convert_ant_message_with_exchanges(
                msg,
                placeholder,
                &parsed.tool_exchanges,
                native_session_id,
            )
        })
        .collect();

    Ok(records)
}

/// Convert a single `RawMessage` from dirigent_anth into a `MessageRecord`.
///
/// Returns `None` for noise messages (queue operations, meta, warmup, etc.)
/// and for message types that don't carry user-visible content (Progress, System).
///
/// This is a convenience wrapper that delegates to [`convert_ant_message_with_exchanges`]
/// with an empty exchange list (tool results won't be correlated).
///
/// Note: callers using this public API do not benefit from stable fallback
/// message_ids (native_session_id is empty). Prefer
/// [`convert_ant_message_with_exchanges`] with a real `native_session_id` for
/// idempotent imports.
pub fn convert_ant_message(msg: &RawMessage, scroll_id: Uuid) -> Option<MessageRecord> {
    convert_ant_message_with_exchanges(msg, scroll_id, &[], "")
}

/// Convert a single `RawMessage` with tool exchange correlation.
///
/// User messages that contain ONLY tool results are suppressed (their content
/// is merged into the assistant's `MessagePart::Tool` parts via exchanges).
///
/// `native_session_id` is used as part of the deterministic fallback
/// `message_id` when Claude's native per-message `uuid` field is absent or
/// unparseable. Pass the native session id (`session_ref.id`) for idempotent
/// imports; an empty string is acceptable for one-off conversions that do not
/// need stable ids.
pub fn convert_ant_message_with_exchanges(
    msg: &RawMessage,
    scroll_id: Uuid,
    tool_exchanges: &[dirigent_anth::types::ToolExchange],
    native_session_id: &str,
) -> Option<MessageRecord> {
    if classify_noise(msg).is_some() {
        return None;
    }

    match msg {
        RawMessage::User(user) => {
            // Suppress user messages that are purely tool results
            if is_pure_tool_result_message(user) {
                return None;
            }
            convert_user_message(user, scroll_id, native_session_id)
        }
        RawMessage::Assistant(assistant) => convert_assistant_message_with_exchanges(
            assistant,
            scroll_id,
            tool_exchanges,
            native_session_id,
        ),
        RawMessage::Progress(_)
        | RawMessage::System(_)
        | RawMessage::QueueOperation(_)
        | RawMessage::FileHistorySnapshot(_)
        | RawMessage::LastPrompt(_) => None,
    }
}

/// Check if a user message contains only ToolResult blocks (no actual user text).
fn is_pure_tool_result_message(user: &RawUserMessage) -> bool {
    match &user.message.content {
        Content::Text(s) => s.trim().is_empty(),
        Content::Blocks(blocks) => {
            blocks
                .iter()
                .all(|b| matches!(b, ContentBlock::ToolResult { .. }))
        }
    }
}

// ---------------------------------------------------------------------------
// User message conversion
// ---------------------------------------------------------------------------

fn convert_user_message(
    user: &RawUserMessage,
    scroll_id: Uuid,
    native_session_id: &str,
) -> Option<MessageRecord> {
    let ts = user
        .timestamp
        .as_ref()
        .and_then(|s| parse_timestamp_value(&serde_json::Value::String(s.clone())))
        .unwrap_or_else(Utc::now);

    let content_md = match &user.message.content {
        Content::Text(s) => s.clone(),
        Content::Blocks(blocks) => blocks_to_markdown_user(blocks),
    };

    if content_md.trim().is_empty() {
        return None;
    }

    // Build content_parts as Vec<MessagePart> for proper UI rendering
    let content_parts = serde_json::to_value(vec![dirigent_protocol::MessagePart::Text {
        text: content_md.clone(),
    }])
    .ok();

    let mut meta = serde_json::Map::new();
    if let Some(ref cwd) = user.cwd {
        meta.insert("cwd".to_string(), serde_json::Value::String(cwd.clone()));
    }
    if let Some(ref branch) = user.git_branch {
        meta.insert(
            "git_branch".to_string(),
            serde_json::Value::String(branch.clone()),
        );
    }
    if let Some(ref version) = user.version {
        meta.insert(
            "claude_version".to_string(),
            serde_json::Value::String(version.clone()),
        );
    }

    Some(MessageRecord {
        version: 1,
        message_id: derive_message_id(
            user.uuid.as_deref(),
            native_session_id,
            &ts,
            "user",
            &content_md,
        ),
        session: scroll_id,
        parent_id: None,
        ts,
        role: "user".to_string(),
        author: None,
        content_md,
        content_parts,
        attachments: Vec::new(),
        metadata: serde_json::Value::Object(meta),
    })
}

// ---------------------------------------------------------------------------
// Assistant message conversion
// ---------------------------------------------------------------------------

fn convert_assistant_message_with_exchanges(
    assistant: &RawAssistantMessage,
    scroll_id: Uuid,
    tool_exchanges: &[dirigent_anth::types::ToolExchange],
    native_session_id: &str,
) -> Option<MessageRecord> {
    let ts = assistant
        .timestamp
        .as_ref()
        .and_then(|s| parse_timestamp_value(&serde_json::Value::String(s.clone())))
        .unwrap_or_else(Utc::now);

    let parts = assistant_blocks_to_message_parts(&assistant.message.content, tool_exchanges);

    if parts.is_empty() {
        return None;
    }

    // Build content_md as fallback from parts
    let content_md = parts
        .iter()
        .map(|p| match p {
            dirigent_protocol::MessagePart::Text { text } => text.clone(),
            dirigent_protocol::MessagePart::Thinking { text } => {
                format!("<thinking>\n{}\n</thinking>", text)
            }
            dirigent_protocol::MessagePart::Tool { tool, .. } => format!("[Tool: {}]", tool),
            dirigent_protocol::MessagePart::Code { language, code } => {
                format!("```{}\n{}\n```", language, code)
            }
            dirigent_protocol::MessagePart::File { path, .. } => format!("[File: {}]", path),
        })
        .collect::<Vec<_>>()
        .join("\n\n");

    let content_parts = serde_json::to_value(&parts).ok();

    let model = assistant.message.model.clone();

    let mut meta = serde_json::Map::new();
    if let Some(ref m) = model {
        meta.insert("model".to_string(), serde_json::Value::String(m.clone()));
    }
    if let Some(ref usage) = assistant.message.usage {
        meta.insert("usage".to_string(), usage.clone());
    }
    if let Some(ref stop_reason) = assistant.message.stop_reason {
        meta.insert(
            "stop_reason".to_string(),
            serde_json::Value::String(stop_reason.clone()),
        );
    }

    Some(MessageRecord {
        version: 1,
        message_id: derive_message_id(
            assistant.uuid.as_deref(),
            native_session_id,
            &ts,
            "assistant",
            &content_md,
        ),
        session: scroll_id,
        parent_id: None,
        ts,
        role: "assistant".to_string(),
        author: model,
        content_md,
        content_parts,
        attachments: Vec::new(),
        metadata: serde_json::Value::Object(meta),
    })
}

// ---------------------------------------------------------------------------
// MessagePart conversion (structured content for UI rendering)
// ---------------------------------------------------------------------------

/// Convert assistant content blocks to `Vec<MessagePart>`, using tool exchanges
/// for correlating tool_use with their results.
///
/// Each `ToolUse` block becomes a `MessagePart::Tool` with the correlated result
/// (if found in `tool_exchanges`). `Thinking` and `Text` blocks map directly.
fn assistant_blocks_to_message_parts(
    blocks: &[ContentBlock],
    tool_exchanges: &[dirigent_anth::types::ToolExchange],
) -> Vec<dirigent_protocol::MessagePart> {
    // Build lookup: tool_use_id → &ToolExchange
    let exchange_map: HashMap<&str, &dirigent_anth::types::ToolExchange> = tool_exchanges
        .iter()
        .map(|e| (e.call.id.as_str(), e))
        .collect();

    let mut parts = Vec::new();

    for block in blocks {
        match block {
            ContentBlock::Text { text } if !text.is_empty() => {
                parts.push(dirigent_protocol::MessagePart::Text { text: text.clone() });
            }
            ContentBlock::Thinking { thinking } if !thinking.is_empty() => {
                parts.push(dirigent_protocol::MessagePart::Thinking {
                    text: thinking.clone(),
                });
            }
            ContentBlock::ToolUse { id, name, input, .. } => {
                let output = exchange_map.get(id.as_str()).and_then(|ex| {
                    ex.result.as_ref().map(|r| {
                        if r.is_error {
                            serde_json::json!({ "error": r.content.as_deref().unwrap_or("Unknown error") })
                        } else {
                            serde_json::json!({ "result": r.content.as_deref().unwrap_or("") })
                        }
                    })
                });

                parts.push(dirigent_protocol::MessagePart::Tool {
                    tool: name.clone(),
                    tool_call_id: Some(id.clone()),
                    input: input.clone(),
                    output,
                });
            }
            ContentBlock::Image { .. } => {
                parts.push(dirigent_protocol::MessagePart::Text {
                    text: "[Image]".to_string(),
                });
            }
            _ => {} // Empty text/thinking, ToolResult on assistant side (shouldn't happen)
        }
    }

    parts
}

// ---------------------------------------------------------------------------
// Markdown conversion helpers (legacy, used for markdown-only fallback)
// ---------------------------------------------------------------------------

/// Convert assistant content blocks to a single markdown string.
/// Retained for tests; production path uses `assistant_blocks_to_message_parts`.
#[cfg(test)]
fn assistant_blocks_to_markdown(blocks: &[ContentBlock]) -> String {
    let parts: Vec<String> = blocks
        .iter()
        .filter_map(|block| match block {
            ContentBlock::Text { text } => {
                if text.is_empty() {
                    None
                } else {
                    Some(text.clone())
                }
            }
            ContentBlock::Thinking { thinking } => {
                if thinking.is_empty() {
                    None
                } else {
                    Some(format!("<thinking>\n{}\n</thinking>", thinking))
                }
            }
            ContentBlock::ToolUse { name, input, .. } => {
                let input_pretty =
                    serde_json::to_string_pretty(input).unwrap_or_else(|_| input.to_string());
                Some(format!("**Tool: {}**\n\n```json\n{}\n```", name, input_pretty))
            }
            ContentBlock::ToolResult {
                content, is_error, ..
            } => {
                let label = if *is_error {
                    "**Tool Error:**"
                } else {
                    "**Tool Result:**"
                };
                let body = match content {
                    Some(Content::Text(s)) => format!("\n\n```\n{}\n```", s),
                    Some(Content::Blocks(inner)) => {
                        let text = inner
                            .iter()
                            .filter_map(|b| match b {
                                ContentBlock::Text { text } => Some(text.as_str()),
                                _ => None,
                            })
                            .collect::<Vec<_>>()
                            .join("\n");
                        if text.is_empty() {
                            String::new()
                        } else {
                            format!("\n\n```\n{}\n```", text)
                        }
                    }
                    None => String::new(),
                };
                Some(format!("{}{}", label, body))
            }
            ContentBlock::Image { .. } => Some("[Image]".to_string()),
        })
        .collect();

    parts.join("\n\n")
}

/// Convert user content blocks (typically tool results) to markdown.
fn blocks_to_markdown_user(blocks: &[ContentBlock]) -> String {
    let parts: Vec<String> = blocks
        .iter()
        .filter_map(|block| match block {
            ContentBlock::Text { text } => {
                if text.is_empty() {
                    None
                } else {
                    Some(text.clone())
                }
            }
            ContentBlock::ToolResult {
                content, is_error, ..
            } => {
                let label = if *is_error {
                    "**Tool Error:**"
                } else {
                    "**Tool Result:**"
                };
                let body = match content {
                    Some(Content::Text(s)) => format!("\n\n```\n{}\n```", s),
                    Some(Content::Blocks(inner)) => {
                        let text = inner
                            .iter()
                            .filter_map(|b| match b {
                                ContentBlock::Text { text } => Some(text.as_str()),
                                _ => None,
                            })
                            .collect::<Vec<_>>()
                            .join("\n");
                        if text.is_empty() {
                            String::new()
                        } else {
                            format!("\n\n```\n{}\n```", text)
                        }
                    }
                    None => String::new(),
                };
                Some(format!("{}{}", label, body))
            }
            _ => None,
        })
        .collect();

    parts.join("\n\n")
}

// ---------------------------------------------------------------------------
// Title helpers
// ---------------------------------------------------------------------------

/// Derive a session title from the first line of user content.
/// Truncates to ~100 characters at a char boundary if needed.
fn derive_title_from_content(content: &str) -> String {
    let first_line = content.lines().next().unwrap_or(content).trim();
    if first_line.len() > 100 {
        // Find the last char boundary at or before byte 100
        let end = first_line
            .char_indices()
            .take_while(|&(i, _)| i <= 100)
            .last()
            .map(|(i, _)| i)
            .unwrap_or(100);
        format!("{}...", &first_line[..end])
    } else {
        first_line.to_string()
    }
}

// ---------------------------------------------------------------------------
// Timestamp helpers
// ---------------------------------------------------------------------------

/// Parse a timestamp from a serde_json::Value.
///
/// Handles both string (ISO 8601) and numeric (Unix seconds/millis) values.
/// This wraps `dirigent_anth::parse_timestamp` for convenience.
pub fn parse_timestamp_value(v: &serde_json::Value) -> Option<DateTime<Utc>> {
    parse_timestamp(v)
}

// ---------------------------------------------------------------------------
// Importer trait wrapper
// ---------------------------------------------------------------------------

/// `Importer` adapter around the Claude Code session importer.
///
/// Wraps the free functions [`discover_claude_import`] and
/// [`import_claude_sessions`] so the generic import registry can drive Claude
/// imports the same way as any other source.
pub struct ClaudeImporter;

#[async_trait]
impl Importer for ClaudeImporter {
    fn source_name(&self) -> &'static str {
        "claude"
    }

    fn config_shape(&self) -> ImportConfigShape {
        // Expose one field: path to ~/.claude directory (directory picker).
        ImportConfigShape {
            fields: vec![ConfigField {
                key: "path".into(),
                label: "Claude directory".into(),
                kind: ConfigFieldKind::Path { directory: true },
                required: true,
                help: Some("Usually ~/.claude".into()),
            }],
            example: ImportConfig {
                source: "claude".into(),
                params: {
                    let mut m = std::collections::BTreeMap::new();
                    m.insert("path".into(), serde_json::json!("~/.claude"));
                    m
                },
            },
        }
    }

    fn detect_defaults(&self) -> Option<ImportConfig> {
        let home = dirigent_anth::discover_claude_home().ok()?;
        let mut params = std::collections::BTreeMap::new();
        params.insert("path".into(), serde_json::json!(home.to_string()));
        Some(ImportConfig {
            source: "claude".into(),
            params,
        })
    }

    async fn discover(&self, cfg: &ImportConfig) -> std::result::Result<ImportDiscovery, ImportError> {
        let path = cfg
            .params
            .get("path")
            .and_then(|v| v.as_str())
            .ok_or_else(|| ImportError::Config("missing `path`".into()))?;
        let (_home, discovery) = discover_claude_import(Some(path))
            .map_err(ImportError::Discovery)?;
        Ok(discovery)
    }

    async fn import(
        &self,
        cfg: &ImportConfig,
        archivist: &Archivist,
        target: ImportTarget,
        progress: ImportProgressSink,
    ) -> std::result::Result<ImportStats, ImportError> {
        let path = cfg
            .params
            .get("path")
            .and_then(|v| v.as_str())
            .ok_or_else(|| ImportError::Config("missing `path`".into()))?;
        let home = Utf8PathBuf::from(path);
        import_claude_sessions(archivist, &home, target.archive, &progress, &target.project_map)
            .await
            .map_err(|e| ImportError::Archivist(e.to_string()))
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn make_user_text(text: &str, timestamp: &str) -> RawMessage {
        let json = format!(
            r#"{{
                "type": "user",
                "uuid": "test-uuid-001",
                "parentUuid": null,
                "timestamp": "{}",
                "sessionId": "test-session",
                "cwd": "/home/user/project",
                "version": "2.1.71",
                "gitBranch": "main",
                "isSidechain": false,
                "message": {{
                    "role": "user",
                    "content": "{}"
                }}
            }}"#,
            timestamp, text
        );
        serde_json::from_str(&json).unwrap()
    }

    fn make_assistant_text(text: &str, timestamp: &str) -> RawMessage {
        let json = format!(
            r#"{{
                "type": "assistant",
                "uuid": "test-uuid-002",
                "parentUuid": "test-uuid-001",
                "timestamp": "{}",
                "sessionId": "test-session",
                "message": {{
                    "model": "claude-opus-4-6",
                    "id": "msg_test",
                    "role": "assistant",
                    "content": [{{"type": "text", "text": "{}"}}],
                    "stop_reason": "end_turn",
                    "usage": {{"input_tokens": 100, "output_tokens": 50}}
                }}
            }}"#,
            timestamp, text
        );
        serde_json::from_str(&json).unwrap()
    }

    fn make_assistant_with_tool(tool_name: &str, timestamp: &str) -> RawMessage {
        let json = format!(
            r#"{{
                "type": "assistant",
                "uuid": "test-uuid-003",
                "parentUuid": "test-uuid-001",
                "timestamp": "{}",
                "sessionId": "test-session",
                "message": {{
                    "model": "claude-opus-4-6",
                    "id": "msg_test2",
                    "role": "assistant",
                    "content": [
                        {{"type": "text", "text": "Let me check that."}},
                        {{"type": "tool_use", "id": "toolu_abc", "name": "{}", "input": {{"command": "ls"}}}}
                    ],
                    "stop_reason": "tool_use"
                }}
            }}"#,
            timestamp, tool_name
        );
        serde_json::from_str(&json).unwrap()
    }

    fn make_queue_operation() -> RawMessage {
        let json = r#"{
            "type": "queue-operation",
            "operation": "enqueue",
            "timestamp": "2026-03-14T21:15:17.531Z",
            "sessionId": "test-session"
        }"#;
        serde_json::from_str(json).unwrap()
    }

    #[test]
    fn convert_user_text_message() {
        let msg = make_user_text("Hello world", "2026-04-01T12:00:00Z");
        let record = convert_ant_message(&msg, Uuid::nil()).expect("should produce a record");

        assert_eq!(record.role, "user");
        assert_eq!(record.content_md, "Hello world");
        assert_eq!(record.session, Uuid::nil());
        assert!(record.author.is_none());

        // Check metadata
        let meta = record.metadata.as_object().unwrap();
        assert_eq!(meta.get("cwd").unwrap(), "/home/user/project");
        assert_eq!(meta.get("git_branch").unwrap(), "main");
        assert_eq!(meta.get("claude_version").unwrap(), "2.1.71");

        // Check content_parts is present
        assert!(record.content_parts.is_some());
    }

    #[test]
    fn convert_assistant_text_message() {
        let msg = make_assistant_text("Here is your answer.", "2026-04-01T12:00:05Z");
        let record = convert_ant_message(&msg, Uuid::nil()).expect("should produce a record");

        assert_eq!(record.role, "assistant");
        assert_eq!(record.content_md, "Here is your answer.");
        assert_eq!(record.author.as_deref(), Some("claude-opus-4-6"));

        // Check metadata
        let meta = record.metadata.as_object().unwrap();
        assert_eq!(meta.get("model").unwrap(), "claude-opus-4-6");
        assert_eq!(meta.get("stop_reason").unwrap(), "end_turn");
        assert!(meta.get("usage").is_some());
    }

    #[test]
    fn convert_queue_operation_returns_none() {
        let msg = make_queue_operation();
        let result = convert_ant_message(&msg, Uuid::nil());
        assert!(result.is_none(), "QueueOperation should be skipped as noise");
    }

    #[test]
    fn convert_assistant_with_tool_use_contains_tool_name() {
        let msg = make_assistant_with_tool("Bash", "2026-04-01T12:00:10Z");
        let record = convert_ant_message(&msg, Uuid::nil()).expect("should produce a record");

        assert_eq!(record.role, "assistant");
        assert!(
            record.content_md.contains("[Tool: Bash]"),
            "markdown should contain tool reference, got: {}",
            record.content_md
        );
        // content_parts should contain proper MessagePart::Tool
        assert!(record.content_parts.is_some());
        let parts: Vec<dirigent_protocol::MessagePart> =
            serde_json::from_value(record.content_parts.unwrap()).unwrap();
        assert_eq!(parts.len(), 2);
        assert!(matches!(&parts[0], dirigent_protocol::MessagePart::Text { text } if text == "Let me check that."));
        assert!(matches!(&parts[1], dirigent_protocol::MessagePart::Tool { tool, .. } if tool == "Bash"));
    }

    #[test]
    fn parse_timestamp_value_string() {
        let v = serde_json::json!("2026-04-01T12:00:00Z");
        let dt = parse_timestamp_value(&v).unwrap();
        assert_eq!(dt.year(), 2026);
        assert_eq!(dt.month(), 4);
        assert_eq!(dt.day(), 1);
    }

    #[test]
    fn parse_timestamp_value_numeric_millis() {
        let v = serde_json::json!(1769461914249_i64);
        let dt = parse_timestamp_value(&v).unwrap();
        assert!(dt.year() >= 2025);
    }

    #[test]
    fn parse_timestamp_value_null_returns_none() {
        let v = serde_json::json!(null);
        assert!(parse_timestamp_value(&v).is_none());
    }

    use chrono::Datelike;

    #[test]
    fn convert_empty_user_message_returns_none() {
        let msg = make_user_text("", "2026-04-01T12:00:00Z");
        let result = convert_ant_message(&msg, Uuid::nil());
        assert!(result.is_none(), "Empty user message should be skipped");
    }

    #[test]
    fn assistant_blocks_to_markdown_thinking() {
        let blocks = vec![ContentBlock::Thinking {
            thinking: "Let me think about this...".to_string(),
        }];
        let md = assistant_blocks_to_markdown(&blocks);
        assert!(md.contains("<thinking>"));
        assert!(md.contains("Let me think about this..."));
        assert!(md.contains("</thinking>"));
    }

    #[test]
    fn assistant_blocks_to_markdown_image() {
        let blocks = vec![ContentBlock::Image {
            source: serde_json::json!({"type": "base64"}),
        }];
        let md = assistant_blocks_to_markdown(&blocks);
        assert_eq!(md, "[Image]");
    }

    #[test]
    fn convert_assistant_with_tools_produces_message_parts() {
        use dirigent_protocol::MessagePart;

        let blocks = vec![
            ContentBlock::Text {
                text: "Let me check that.".to_string(),
            },
            ContentBlock::ToolUse {
                id: "toolu_abc".to_string(),
                name: "Bash".to_string(),
                input: serde_json::json!({"command": "ls"}),
                caller: None,
            },
        ];

        let exchanges: Vec<dirigent_anth::types::ToolExchange> = vec![];
        let parts = assistant_blocks_to_message_parts(&blocks, &exchanges);

        assert_eq!(parts.len(), 2);
        assert!(
            matches!(&parts[0], MessagePart::Text { text } if text == "Let me check that.")
        );
        assert!(
            matches!(&parts[1], MessagePart::Tool { tool, output, .. } if tool == "Bash" && output.is_none())
        );
    }

    #[test]
    fn convert_assistant_with_correlated_tool_result() {
        use dirigent_protocol::MessagePart;

        let blocks = vec![
            ContentBlock::Text {
                text: "Let me read that file.".to_string(),
            },
            ContentBlock::ToolUse {
                id: "toolu_xyz".to_string(),
                name: "Read".to_string(),
                input: serde_json::json!({"file_path": "/tmp/test.rs"}),
                caller: None,
            },
        ];

        let exchanges = vec![dirigent_anth::types::ToolExchange {
            call: dirigent_anth::types::ToolCall {
                id: "toolu_xyz".to_string(),
                name: dirigent_anth::types::ToolName::Read,
                input: serde_json::json!({"file_path": "/tmp/test.rs"}),
                source_message_uuid: "msg1".to_string(),
            },
            result: Some(dirigent_anth::types::ToolResultData {
                tool_use_id: "toolu_xyz".to_string(),
                content: Some("fn main() {}".to_string()),
                is_error: false,
                source_message_uuid: "msg2".to_string(),
            }),
        }];

        let parts = assistant_blocks_to_message_parts(&blocks, &exchanges);
        assert_eq!(parts.len(), 2);
        if let MessagePart::Tool {
            tool,
            tool_call_id,
            output,
            ..
        } = &parts[1]
        {
            assert_eq!(tool, "Read");
            assert_eq!(tool_call_id.as_deref(), Some("toolu_xyz"));
            assert!(output.is_some());
        } else {
            panic!("Expected Tool part");
        }
    }

    #[test]
    fn user_message_with_only_tool_results_is_suppressed() {
        let user_json = r#"{
            "type": "user",
            "uuid": "test-uuid-005",
            "timestamp": "2026-04-01T12:00:15Z",
            "sessionId": "test-session",
            "message": {
                "role": "user",
                "content": [
                    {"type": "tool_result", "tool_use_id": "toolu_abc", "content": "output text"}
                ]
            }
        }"#;
        let msg: RawMessage = serde_json::from_str(user_json).unwrap();
        let record = convert_ant_message_with_exchanges(&msg, Uuid::nil(), &[], "");
        assert!(
            record.is_none(),
            "Pure tool-result user messages should be suppressed"
        );
    }

    #[test]
    fn derive_title_from_content_short() {
        let title = derive_title_from_content("Hello, can you help me refactor this module?");
        assert_eq!(title, "Hello, can you help me refactor this module?");
    }

    #[test]
    fn derive_title_from_content_truncates_long() {
        let long = "a".repeat(200);
        let title = derive_title_from_content(&long);
        assert!(title.len() <= 104); // ~100 + "..."
        assert!(title.ends_with("..."));
    }

    #[test]
    fn derive_title_from_content_handles_multibyte_chars() {
        // 'x' repeated with a multi-byte char near the boundary
        let mut s = "a".repeat(98);
        s.push('\u{2019}'); // right single quote (3 bytes) at byte 98..101
        s.push_str(&"b".repeat(100));
        let title = derive_title_from_content(&s);
        assert!(title.ends_with("..."));
        // Should NOT panic on multi-byte boundary
        assert!(title.is_char_boundary(title.len()));
    }

    #[test]
    fn derive_title_from_content_uses_first_line() {
        let title = derive_title_from_content("First line\nSecond line\nThird line");
        assert_eq!(title, "First line");
    }
}

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

    #[test]
    fn derive_message_id_stable_when_source_uuid_present() {
        let ts = chrono::Utc::now();
        let a = derive_message_id(
            Some("12345678-1234-5678-1234-567812345678"),
            "session-1",
            &ts,
            "user",
            "hello",
        );
        let b = derive_message_id(
            Some("12345678-1234-5678-1234-567812345678"),
            "session-1",
            &ts,
            "user",
            "hello",
        );
        assert_eq!(a, b, "same source uuid must produce same message_id");
    }

    #[test]
    fn derive_message_id_stable_fallback_when_uuid_absent() {
        let ts = chrono::Utc::now();
        let a = derive_message_id(None, "session-1", &ts, "user", "hello");
        let b = derive_message_id(None, "session-1", &ts, "user", "hello");
        assert_eq!(a, b);
    }

    #[test]
    fn derive_message_id_different_content_different_id() {
        let ts = chrono::Utc::now();
        let a = derive_message_id(None, "session-1", &ts, "user", "hello");
        let b = derive_message_id(None, "session-1", &ts, "user", "world");
        assert_ne!(a, b);
    }
}
