//! OpenAI Codex CLI importer: takes a path to a directory of JSONL session files.

use std::path::PathBuf;

use async_trait::async_trait;
use chrono::Utc;
use uuid::Uuid;

use dirigent_codex::{ParsedMessage, ParsedSession};

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 Codex sessions.
pub const CODEX_CONNECTOR_TYPE: &str = "Codex";

/// Fingerprint prefix for locally-imported Codex sessions.
pub const CODEX_FINGERPRINT_PREFIX: &str = "import/local:codex";

/// Namespace UUID for deterministic UUIDv5 derivations of message ids that
/// Codex does not expose natively.
const CODEX_MESSAGE_NS: Uuid = Uuid::from_u128(0x9e28_b7d4_af9c_4fe2_a8d1_8c41_21b3_2222);

pub struct CodexImporter;

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

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

    async fn discover(
        &self,
        cfg: &ImportConfig,
    ) -> std::result::Result<ImportDiscovery, ImportError> {
        let path = require_path(cfg)?;
        let files = dirigent_codex::discover_sessions(&path)
            .map_err(|e| ImportError::Discovery(e.to_string()))?;

        // Parse each file to count messages. This is a best-effort estimate —
        // malformed lines are skipped by the parser, so counts reflect what
        // the importer would actually write.
        let mut total_estimated_messages: usize = 0;
        for file in &files {
            if let Ok(session) = dirigent_codex::parse_file(file) {
                total_estimated_messages += session.messages.len();
            }
        }
        let total_sessions = files.len();

        // Codex sessions live flat in one directory; bucket them into a
        // single synthetic project named after the directory.
        let project_name = path
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("Codex sessions")
            .to_string();

        Ok(ImportDiscovery {
            source_name: "Codex".to_string(),
            source_path: path.display().to_string(),
            projects: vec![ImportProject {
                name: project_name,
                session_count: total_sessions,
            }],
            total_sessions,
            total_estimated_messages,
        })
    }

    async fn import(
        &self,
        cfg: &ImportConfig,
        archivist: &Archivist,
        target: ImportTarget,
        progress: ImportProgressSink,
    ) -> std::result::Result<ImportStats, ImportError> {
        let path = require_path(cfg)?;
        let files = dirigent_codex::discover_sessions(&path)
            .map_err(|e| ImportError::Discovery(e.to_string()))?;

        // Parse every session file up front so that `convert_messages`
        // (called by `import_sessions`) can do O(1) lookups.
        let mut parsed: Vec<ParsedSession> = Vec::with_capacity(files.len());
        for file in &files {
            match dirigent_codex::parse_file(file) {
                Ok(session) => parsed.push(session),
                Err(e) => {
                    tracing::warn!(
                        path = %file.display(),
                        error = %e,
                        "Skipping unreadable Codex session file"
                    );
                }
            }
        }

        let mut discovered: Vec<DiscoveredSession> = Vec::with_capacity(parsed.len());
        for s in &parsed {
            let metadata = serde_json::json!({
                "source": "codex",
                "source_path": s.source_path.display().to_string(),
                "native_id": s.native_id,
            });
            let file_size = std::fs::metadata(&s.source_path).ok().map(|m| m.len());

            discovered.push(DiscoveredSession {
                native_session_id: s.native_id.clone(),
                title: None,
                created_at: s.created_at,
                updated_at: s.updated_at,
                message_count: s.messages.len(),
                metadata,
                project_path: None,
                file_size,
            });
        }

        // Map native_id -> parsed session for O(1) lookup in `convert`.
        let session_lookup: std::collections::HashMap<String, ParsedSession> = parsed
            .into_iter()
            .map(|s| (s.native_id.clone(), s))
            .collect();

        // Fingerprint the import by the canonical directory path. Re-running
        // against the same directory aliases onto the same connector.
        let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
        let fingerprint = format!("{}:{}", CODEX_FINGERPRINT_PREFIX, canonical_path.display());

        let connector_req = RegisterConnectorRequest {
            r#type: CODEX_CONNECTOR_TYPE.to_string(),
            title: format!("Codex ({})", canonical_path.display()),
            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 = session_lookup.get(native_id).ok_or_else(|| {
                ArchivistError::InvalidRequest(format!(
                    "Parsed session not found for native_id: {}",
                    native_id
                ))
            })?;
            Ok(convert_session_to_records(session))
        };

        import_sessions(
            archivist,
            connector_req,
            discovered,
            convert,
            target.archive,
            &progress,
            false,
            &target.project_map,
        )
        .await
        .map_err(|e| ImportError::Archivist(e.to_string()))
    }
}

// ---------------------------------------------------------------------------
// Conversion helpers
// ---------------------------------------------------------------------------

fn require_path(cfg: &ImportConfig) -> std::result::Result<PathBuf, ImportError> {
    cfg.params
        .get("path")
        .and_then(|v| v.as_str())
        .map(PathBuf::from)
        .ok_or_else(|| ImportError::Config("missing `path`".into()))
}

/// Convert every [`ParsedMessage`] in a session into a [`MessageRecord`],
/// leaving `session = Uuid::nil()` for the generic orchestrator to patch.
fn convert_session_to_records(session: &ParsedSession) -> Vec<MessageRecord> {
    session
        .messages
        .iter()
        .enumerate()
        .filter_map(|(idx, m)| convert_parsed_message(&session.native_id, idx, m))
        .collect()
}

fn convert_parsed_message(
    native_session_id: &str,
    index: usize,
    msg: &ParsedMessage,
) -> Option<MessageRecord> {
    // Skip purely empty messages — nothing to archive.
    if msg.content.trim().is_empty() {
        return None;
    }

    let ts = msg.ts.unwrap_or_else(Utc::now);

    // Codex events don't carry per-message UUIDs, so always derive a stable
    // UUIDv5 from (native_session, index, role, ts). Index disambiguates
    // otherwise-identical back-to-back messages.
    let key = format!(
        "{}:{}:{}:{}",
        native_session_id,
        index,
        msg.role,
        ts.to_rfc3339(),
    );
    let message_id = Uuid::new_v5(&CODEX_MESSAGE_NS, key.as_bytes());

    let parts = vec![dirigent_protocol::MessagePart::Text {
        text: msg.content.clone(),
    }];
    let content_parts = serde_json::to_value(&parts).ok();

    Some(MessageRecord {
        version: 1,
        message_id,
        session: Uuid::nil(),
        parent_id: None,
        ts,
        role: msg.role.clone(),
        author: None,
        content_md: msg.content.clone(),
        content_parts,
        attachments: Vec::new(),
        metadata: msg.metadata.clone(),
    })
}

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

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

    fn sample_message(role: &str, content: &str) -> ParsedMessage {
        ParsedMessage {
            ts: None,
            role: role.into(),
            content: content.into(),
            metadata: serde_json::Value::Null,
        }
    }

    fn sample_message_at(role: &str, content: &str, ts: chrono::DateTime<Utc>) -> ParsedMessage {
        ParsedMessage {
            ts: Some(ts),
            role: role.into(),
            content: content.into(),
            metadata: serde_json::Value::Null,
        }
    }

    #[test]
    fn empty_content_is_skipped() {
        let m = sample_message("user", "   ");
        assert!(convert_parsed_message("s", 0, &m).is_none());
    }

    #[test]
    fn non_empty_message_converts() {
        let m = sample_message("user", "hello");
        let r = convert_parsed_message("s", 0, &m).expect("converts");
        assert_eq!(r.role, "user");
        assert_eq!(r.content_md, "hello");
        assert_eq!(r.session, Uuid::nil());
        assert!(r.content_parts.is_some());
    }

    #[test]
    fn message_id_is_deterministic_per_session_index() {
        // Fix ts so we don't accidentally hash Utc::now() into the id key.
        let ts = chrono::TimeZone::timestamp_opt(&Utc, 1_735_732_800, 0)
            .single()
            .unwrap();
        let m = sample_message_at("user", "hello", ts);
        let a = convert_parsed_message("session-a", 0, &m).unwrap();
        let b = convert_parsed_message("session-a", 0, &m).unwrap();
        assert_eq!(a.message_id, b.message_id);

        // Different index → different id.
        let c = convert_parsed_message("session-a", 1, &m).unwrap();
        assert_ne!(a.message_id, c.message_id);

        // Different session → different id.
        let d = convert_parsed_message("session-b", 0, &m).unwrap();
        assert_ne!(a.message_id, d.message_id);
    }

    #[test]
    fn require_path_reports_missing_config() {
        let cfg = ImportConfig {
            source: "codex".into(),
            params: Default::default(),
        };
        let err = require_path(&cfg).expect_err("should fail");
        assert!(matches!(err, ImportError::Config(_)));
    }
}
