use crate::config::Config;
use crate::rollout;
use crate::rollout::list::parse_timestamp_uuid_from_filename;
use crate::rollout::recorder::RolloutRecorder;
use crate::state_db::normalize_cwd_for_state_db;
use chrono::DateTime;
use chrono::NaiveDateTime;
use chrono::Timelike;
use chrono::Utc;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_state::BackfillState;
use codex_state::BackfillStats;
use codex_state::BackfillStatus;
use codex_state::DB_ERROR_METRIC;
use codex_state::DB_METRIC_BACKFILL;
use codex_state::DB_METRIC_BACKFILL_DURATION_MS;
use codex_state::ExtractionOutcome;
use codex_state::ThreadMetadataBuilder;
use codex_state::apply_rollout_item;
use std::path::Path;
use std::path::PathBuf;
use tracing::info;
use tracing::warn;

const ROLLOUT_PREFIX: &str = "rollout-";
const ROLLOUT_SUFFIX: &str = ".jsonl";
const BACKFILL_BATCH_SIZE: usize = 200;
#[cfg(not(test))]
const BACKFILL_LEASE_SECONDS: i64 = 900;
#[cfg(test)]
const BACKFILL_LEASE_SECONDS: i64 = 1;

pub(crate) fn builder_from_session_meta(
    session_meta: &SessionMetaLine,
    rollout_path: &Path,
) -> Option<ThreadMetadataBuilder> {
    let created_at = parse_timestamp_to_utc(session_meta.meta.timestamp.as_str())?;
    let mut builder = ThreadMetadataBuilder::new(
        session_meta.meta.id,
        rollout_path.to_path_buf(),
        created_at,
        session_meta.meta.source.clone(),
    );
    builder.model_provider = session_meta.meta.model_provider.clone();
    builder.agent_nickname = session_meta.meta.agent_nickname.clone();
    builder.agent_role = session_meta.meta.agent_role.clone();
    builder.cwd = session_meta.meta.cwd.clone();
    builder.cli_version = Some(session_meta.meta.cli_version.clone());
    builder.sandbox_policy = SandboxPolicy::new_read_only_policy();
    builder.approval_mode = AskForApproval::OnRequest;
    if let Some(git) = session_meta.git.as_ref() {
        builder.git_sha = git.commit_hash.clone();
        builder.git_branch = git.branch.clone();
        builder.git_origin_url = git.repository_url.clone();
    }
    Some(builder)
}

pub(crate) fn builder_from_items(
    items: &[RolloutItem],
    rollout_path: &Path,
) -> Option<ThreadMetadataBuilder> {
    if let Some(session_meta) = items.iter().find_map(|item| match item {
        RolloutItem::SessionMeta(meta_line) => Some(meta_line),
        RolloutItem::ResponseItem(_)
        | RolloutItem::Compacted(_)
        | RolloutItem::TurnContext(_)
        | RolloutItem::EventMsg(_) => None,
    }) && let Some(builder) = builder_from_session_meta(session_meta, rollout_path)
    {
        return Some(builder);
    }

    let file_name = rollout_path.file_name()?.to_str()?;
    if !file_name.starts_with(ROLLOUT_PREFIX) || !file_name.ends_with(ROLLOUT_SUFFIX) {
        return None;
    }
    let (created_ts, uuid) = parse_timestamp_uuid_from_filename(file_name)?;
    let created_at =
        DateTime::<Utc>::from_timestamp(created_ts.unix_timestamp(), 0)?.with_nanosecond(0)?;
    let id = ThreadId::from_string(&uuid.to_string()).ok()?;
    Some(ThreadMetadataBuilder::new(
        id,
        rollout_path.to_path_buf(),
        created_at,
        SessionSource::default(),
    ))
}

pub(crate) async fn extract_metadata_from_rollout(
    rollout_path: &Path,
    default_provider: &str,
    otel: Option<&OtelManager>,
) -> anyhow::Result<ExtractionOutcome> {
    let (items, _thread_id, parse_errors) =
        RolloutRecorder::load_rollout_items(rollout_path).await?;
    if items.is_empty() {
        return Err(anyhow::anyhow!(
            "empty session file: {}",
            rollout_path.display()
        ));
    }
    let builder = builder_from_items(items.as_slice(), rollout_path).ok_or_else(|| {
        anyhow::anyhow!(
            "rollout missing metadata builder: {}",
            rollout_path.display()
        )
    })?;
    let mut metadata = builder.build(default_provider);
    for item in &items {
        apply_rollout_item(&mut metadata, item, default_provider);
    }
    if let Some(updated_at) = file_modified_time_utc(rollout_path).await {
        metadata.updated_at = updated_at;
    }
    if parse_errors > 0
        && let Some(otel) = otel
    {
        otel.counter(
            DB_ERROR_METRIC,
            parse_errors as i64,
            &[("stage", "extract_metadata_from_rollout")],
        );
    }
    Ok(ExtractionOutcome {
        metadata,
        memory_mode: items.iter().rev().find_map(|item| match item {
            RolloutItem::SessionMeta(meta_line) => meta_line.meta.memory_mode.clone(),
            RolloutItem::ResponseItem(_)
            | RolloutItem::Compacted(_)
            | RolloutItem::TurnContext(_)
            | RolloutItem::EventMsg(_) => None,
        }),
        parse_errors,
    })
}

pub(crate) async fn backfill_sessions(
    runtime: &codex_state::StateRuntime,
    config: &Config,
    otel: Option<&OtelManager>,
) {
    let timer = otel.and_then(|otel| otel.start_timer(DB_METRIC_BACKFILL_DURATION_MS, &[]).ok());
    let backfill_state = match runtime.get_backfill_state().await {
        Ok(state) => state,
        Err(err) => {
            warn!(
                "failed to read backfill state at {}: {err}",
                config.codex_home.display()
            );
            if let Some(otel) = otel {
                otel.counter(DB_ERROR_METRIC, 1, &[("stage", "backfill_state_read")]);
            }
            BackfillState::default()
        }
    };
    if backfill_state.status == BackfillStatus::Complete {
        return;
    }
    let claimed = match runtime.try_claim_backfill(BACKFILL_LEASE_SECONDS).await {
        Ok(claimed) => claimed,
        Err(err) => {
            warn!(
                "failed to claim backfill worker at {}: {err}",
                config.codex_home.display()
            );
            if let Some(otel) = otel {
                otel.counter(
                    DB_ERROR_METRIC,
                    1,
                    &[("stage", "backfill_state_claim_running")],
                );
            }
            return;
        }
    };
    if !claimed {
        info!(
            "state db backfill already running at {}; skipping duplicate worker",
            config.codex_home.display()
        );
        return;
    }
    let mut backfill_state = match runtime.get_backfill_state().await {
        Ok(state) => state,
        Err(err) => {
            warn!(
                "failed to read claimed backfill state at {}: {err}",
                config.codex_home.display()
            );
            if let Some(otel) = otel {
                otel.counter(
                    DB_ERROR_METRIC,
                    1,
                    &[("stage", "backfill_state_read_claimed")],
                );
            }
            BackfillState {
                status: BackfillStatus::Running,
                ..Default::default()
            }
        }
    };
    if backfill_state.status != BackfillStatus::Running {
        if let Err(err) = runtime.mark_backfill_running().await {
            warn!(
                "failed to mark backfill running at {}: {err}",
                config.codex_home.display()
            );
            if let Some(otel) = otel {
                otel.counter(
                    DB_ERROR_METRIC,
                    1,
                    &[("stage", "backfill_state_mark_running")],
                );
            }
        } else {
            backfill_state.status = BackfillStatus::Running;
        }
    }

    let sessions_root = config.codex_home.join(rollout::SESSIONS_SUBDIR);
    let archived_root = config.codex_home.join(rollout::ARCHIVED_SESSIONS_SUBDIR);
    let mut rollout_paths: Vec<BackfillRolloutPath> = Vec::new();
    for (root, archived) in [(sessions_root, false), (archived_root, true)] {
        if !tokio::fs::try_exists(&root).await.unwrap_or(false) {
            continue;
        }
        match collect_rollout_paths(&root).await {
            Ok(paths) => {
                rollout_paths.extend(paths.into_iter().map(|path| BackfillRolloutPath {
                    watermark: backfill_watermark_for_path(config.codex_home.as_path(), &path),
                    path,
                    archived,
                }));
            }
            Err(err) => {
                warn!(
                    "failed to collect rollout paths under {}: {err}",
                    root.display()
                );
            }
        }
    }
    rollout_paths.sort_by(|a, b| a.watermark.cmp(&b.watermark));
    if let Some(last_watermark) = backfill_state.last_watermark.as_deref() {
        rollout_paths.retain(|entry| entry.watermark.as_str() > last_watermark);
    }

    let mut stats = BackfillStats {
        scanned: 0,
        upserted: 0,
        failed: 0,
    };
    let mut last_watermark = backfill_state.last_watermark.clone();
    for batch in rollout_paths.chunks(BACKFILL_BATCH_SIZE) {
        for rollout in batch {
            stats.scanned = stats.scanned.saturating_add(1);
            match extract_metadata_from_rollout(
                &rollout.path,
                config.model_provider_id.as_str(),
                otel,
            )
            .await
            {
                Ok(outcome) => {
                    if outcome.parse_errors > 0
                        && let Some(otel) = otel
                    {
                        otel.counter(
                            DB_ERROR_METRIC,
                            outcome.parse_errors as i64,
                            &[("stage", "backfill_sessions")],
                        );
                    }
                    let mut metadata = outcome.metadata;
                    metadata.cwd = normalize_cwd_for_state_db(&metadata.cwd);
                    let memory_mode = outcome.memory_mode.unwrap_or_else(|| "enabled".to_string());
                    if let Ok(Some(existing_metadata)) = runtime.get_thread(metadata.id).await {
                        metadata.prefer_existing_git_info(&existing_metadata);
                    }
                    if rollout.archived && metadata.archived_at.is_none() {
                        let fallback_archived_at = metadata.updated_at;
                        metadata.archived_at = file_modified_time_utc(&rollout.path)
                            .await
                            .or(Some(fallback_archived_at));
                    }
                    if let Err(err) = runtime.upsert_thread(&metadata).await {
                        stats.failed = stats.failed.saturating_add(1);
                        warn!("failed to upsert rollout {}: {err}", rollout.path.display());
                    } else {
                        if let Err(err) = runtime
                            .set_thread_memory_mode(metadata.id, memory_mode.as_str())
                            .await
                        {
                            stats.failed = stats.failed.saturating_add(1);
                            warn!(
                                "failed to restore memory mode for {}: {err}",
                                rollout.path.display()
                            );
                            continue;
                        }
                        stats.upserted = stats.upserted.saturating_add(1);
                        if let Ok(meta_line) =
                            rollout::list::read_session_meta_line(&rollout.path).await
                        {
                            if let Err(err) = runtime
                                .persist_dynamic_tools(
                                    meta_line.meta.id,
                                    meta_line.meta.dynamic_tools.as_deref(),
                                )
                                .await
                            {
                                if let Some(otel) = otel {
                                    otel.counter(
                                        DB_ERROR_METRIC,
                                        1,
                                        &[("stage", "backfill_dynamic_tools")],
                                    );
                                }
                                warn!(
                                    "failed to backfill dynamic tools {}: {err}",
                                    rollout.path.display()
                                );
                            }
                        } else {
                            warn!(
                                "failed to read session meta for dynamic tools {}",
                                rollout.path.display()
                            );
                        }
                    }
                }
                Err(err) => {
                    stats.failed = stats.failed.saturating_add(1);
                    warn!(
                        "failed to extract rollout {}: {err}",
                        rollout.path.display()
                    );
                }
            }
        }

        if let Some(last_entry) = batch.last() {
            if let Err(err) = runtime
                .checkpoint_backfill(last_entry.watermark.as_str())
                .await
            {
                warn!(
                    "failed to checkpoint backfill at {}: {err}",
                    config.codex_home.display()
                );
                if let Some(otel) = otel {
                    otel.counter(
                        DB_ERROR_METRIC,
                        1,
                        &[("stage", "backfill_state_checkpoint")],
                    );
                }
            } else {
                last_watermark = Some(last_entry.watermark.clone());
            }
        }
    }
    if let Err(err) = runtime
        .mark_backfill_complete(last_watermark.as_deref())
        .await
    {
        warn!(
            "failed to mark backfill complete at {}: {err}",
            config.codex_home.display()
        );
        if let Some(otel) = otel {
            otel.counter(
                DB_ERROR_METRIC,
                1,
                &[("stage", "backfill_state_mark_complete")],
            );
        }
    }

    info!(
        "state db backfill scanned={}, upserted={}, failed={}",
        stats.scanned, stats.upserted, stats.failed
    );
    if let Some(otel) = otel {
        otel.counter(
            DB_METRIC_BACKFILL,
            stats.upserted as i64,
            &[("status", "upserted")],
        );
        otel.counter(
            DB_METRIC_BACKFILL,
            stats.failed as i64,
            &[("status", "failed")],
        );
    }
    if let Some(timer) = timer.as_ref() {
        let status = if stats.failed == 0 {
            "success"
        } else if stats.upserted == 0 {
            "failed"
        } else {
            "partial_failure"
        };
        let _ = timer.record(&[("status", status)]);
    }
}

#[derive(Debug, Clone)]
struct BackfillRolloutPath {
    watermark: String,
    path: PathBuf,
    archived: bool,
}

fn backfill_watermark_for_path(codex_home: &Path, path: &Path) -> String {
    path.strip_prefix(codex_home)
        .unwrap_or(path)
        .to_string_lossy()
        .replace('\\', "/")
}

async fn file_modified_time_utc(path: &Path) -> Option<DateTime<Utc>> {
    let modified = tokio::fs::metadata(path).await.ok()?.modified().ok()?;
    let updated_at: DateTime<Utc> = modified.into();
    updated_at.with_nanosecond(0)
}

fn parse_timestamp_to_utc(ts: &str) -> Option<DateTime<Utc>> {
    const FILENAME_TS_FORMAT: &str = "%Y-%m-%dT%H-%M-%S";
    if let Ok(naive) = NaiveDateTime::parse_from_str(ts, FILENAME_TS_FORMAT) {
        let dt = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc);
        return dt.with_nanosecond(0);
    }
    if let Ok(dt) = DateTime::parse_from_rfc3339(ts) {
        return dt.with_timezone(&Utc).with_nanosecond(0);
    }
    None
}

async fn collect_rollout_paths(root: &Path) -> std::io::Result<Vec<PathBuf>> {
    let mut stack = vec![root.to_path_buf()];
    let mut paths = Vec::new();
    while let Some(dir) = stack.pop() {
        let mut read_dir = match tokio::fs::read_dir(&dir).await {
            Ok(read_dir) => read_dir,
            Err(err) => {
                warn!("failed to read directory {}: {err}", dir.display());
                continue;
            }
        };
        loop {
            let next_entry = match read_dir.next_entry().await {
                Ok(next_entry) => next_entry,
                Err(err) => {
                    warn!(
                        "failed to read directory entry under {}: {err}",
                        dir.display()
                    );
                    continue;
                }
            };
            let Some(entry) = next_entry else {
                break;
            };
            let path = entry.path();
            let file_type = match entry.file_type().await {
                Ok(file_type) => file_type,
                Err(err) => {
                    warn!("failed to read file type for {}: {err}", path.display());
                    continue;
                }
            };
            if file_type.is_dir() {
                stack.push(path);
                continue;
            }
            if !file_type.is_file() {
                continue;
            }
            let file_name = entry.file_name();
            let Some(name) = file_name.to_str() else {
                continue;
            };
            if name.starts_with(ROLLOUT_PREFIX) && name.ends_with(ROLLOUT_SUFFIX) {
                paths.push(path);
            }
        }
    }
    Ok(paths)
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::DateTime;
    use chrono::NaiveDateTime;
    use chrono::Timelike;
    use chrono::Utc;
    use codex_protocol::ThreadId;
    use codex_protocol::protocol::CompactedItem;
    use codex_protocol::protocol::GitInfo;
    use codex_protocol::protocol::RolloutItem;
    use codex_protocol::protocol::RolloutLine;
    use codex_protocol::protocol::SessionMeta;
    use codex_protocol::protocol::SessionMetaLine;
    use codex_protocol::protocol::SessionSource;
    use codex_state::BackfillStatus;
    use codex_state::ThreadMetadataBuilder;
    use pretty_assertions::assert_eq;
    use std::fs::File;
    use std::io::Write;
    use std::path::Path;
    use std::path::PathBuf;
    use tempfile::tempdir;
    use uuid::Uuid;

    #[tokio::test]
    async fn extract_metadata_from_rollout_uses_session_meta() {
        let dir = tempdir().expect("tempdir");
        let uuid = Uuid::new_v4();
        let id = ThreadId::from_string(&uuid.to_string()).expect("thread id");
        let path = dir
            .path()
            .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl"));

        let session_meta = SessionMeta {
            id,
            forked_from_id: None,
            timestamp: "2026-01-27T12:34:56Z".to_string(),
            cwd: dir.path().to_path_buf(),
            originator: "cli".to_string(),
            cli_version: "0.0.0".to_string(),
            source: SessionSource::default(),
            agent_nickname: None,
            agent_role: None,
            model_provider: Some("openai".to_string()),
            base_instructions: None,
            dynamic_tools: None,
            memory_mode: None,
        };
        let session_meta_line = SessionMetaLine {
            meta: session_meta,
            git: None,
        };
        let rollout_line = RolloutLine {
            timestamp: "2026-01-27T12:34:56Z".to_string(),
            item: RolloutItem::SessionMeta(session_meta_line.clone()),
        };
        let json = serde_json::to_string(&rollout_line).expect("rollout json");
        let mut file = File::create(&path).expect("create rollout");
        writeln!(file, "{json}").expect("write rollout");

        let outcome = extract_metadata_from_rollout(&path, "openai", None)
            .await
            .expect("extract");

        let builder =
            builder_from_session_meta(&session_meta_line, path.as_path()).expect("builder");
        let mut expected = builder.build("openai");
        apply_rollout_item(&mut expected, &rollout_line.item, "openai");
        expected.updated_at = file_modified_time_utc(&path).await.expect("mtime");

        assert_eq!(outcome.metadata, expected);
        assert_eq!(outcome.memory_mode, None);
        assert_eq!(outcome.parse_errors, 0);
    }

    #[tokio::test]
    async fn extract_metadata_from_rollout_returns_latest_memory_mode() {
        let dir = tempdir().expect("tempdir");
        let uuid = Uuid::new_v4();
        let id = ThreadId::from_string(&uuid.to_string()).expect("thread id");
        let path = dir
            .path()
            .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl"));

        let session_meta = SessionMeta {
            id,
            forked_from_id: None,
            timestamp: "2026-01-27T12:34:56Z".to_string(),
            cwd: dir.path().to_path_buf(),
            originator: "cli".to_string(),
            cli_version: "0.0.0".to_string(),
            source: SessionSource::default(),
            agent_nickname: None,
            agent_role: None,
            model_provider: Some("openai".to_string()),
            base_instructions: None,
            dynamic_tools: None,
            memory_mode: None,
        };
        let polluted_meta = SessionMeta {
            memory_mode: Some("polluted".to_string()),
            ..session_meta.clone()
        };
        let lines = vec![
            RolloutLine {
                timestamp: "2026-01-27T12:34:56Z".to_string(),
                item: RolloutItem::SessionMeta(SessionMetaLine {
                    meta: session_meta,
                    git: None,
                }),
            },
            RolloutLine {
                timestamp: "2026-01-27T12:35:00Z".to_string(),
                item: RolloutItem::SessionMeta(SessionMetaLine {
                    meta: polluted_meta,
                    git: None,
                }),
            },
        ];
        let mut file = File::create(&path).expect("create rollout");
        for line in lines {
            writeln!(
                file,
                "{}",
                serde_json::to_string(&line).expect("serialize rollout line")
            )
            .expect("write rollout line");
        }

        let outcome = extract_metadata_from_rollout(&path, "openai", None)
            .await
            .expect("extract");

        assert_eq!(outcome.memory_mode.as_deref(), Some("polluted"));
    }

    #[test]
    fn builder_from_items_falls_back_to_filename() {
        let dir = tempdir().expect("tempdir");
        let uuid = Uuid::new_v4();
        let path = dir
            .path()
            .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl"));
        let items = vec![RolloutItem::Compacted(CompactedItem {
            message: "noop".to_string(),
            replacement_history: None,
        })];

        let builder = builder_from_items(items.as_slice(), path.as_path()).expect("builder");
        let naive = NaiveDateTime::parse_from_str("2026-01-27T12-34-56", "%Y-%m-%dT%H-%M-%S")
            .expect("timestamp");
        let created_at = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc)
            .with_nanosecond(0)
            .expect("nanosecond");
        let expected = ThreadMetadataBuilder::new(
            ThreadId::from_string(&uuid.to_string()).expect("thread id"),
            path,
            created_at,
            SessionSource::default(),
        );

        assert_eq!(builder, expected);
    }

    #[tokio::test]
    async fn backfill_sessions_resumes_from_watermark_and_marks_complete() {
        let dir = tempdir().expect("tempdir");
        let codex_home = dir.path().to_path_buf();
        let first_uuid = Uuid::new_v4();
        let second_uuid = Uuid::new_v4();
        let first_path = write_rollout_in_sessions(
            codex_home.as_path(),
            "2026-01-27T12-34-56",
            "2026-01-27T12:34:56Z",
            first_uuid,
            None,
        );
        let second_path = write_rollout_in_sessions(
            codex_home.as_path(),
            "2026-01-27T12-35-56",
            "2026-01-27T12:35:56Z",
            second_uuid,
            None,
        );

        let runtime =
            codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
                .await
                .expect("initialize runtime");
        let first_watermark =
            backfill_watermark_for_path(codex_home.as_path(), first_path.as_path());
        runtime.mark_backfill_running().await.expect("mark running");
        runtime
            .checkpoint_backfill(first_watermark.as_str())
            .await
            .expect("checkpoint first watermark");
        tokio::time::sleep(std::time::Duration::from_secs(
            (BACKFILL_LEASE_SECONDS + 1) as u64,
        ))
        .await;

        let mut config = crate::config::test_config();
        config.codex_home = codex_home.clone();
        config.model_provider_id = "test-provider".to_string();
        backfill_sessions(runtime.as_ref(), &config, None).await;

        let first_id = ThreadId::from_string(&first_uuid.to_string()).expect("first thread id");
        let second_id = ThreadId::from_string(&second_uuid.to_string()).expect("second thread id");
        assert_eq!(
            runtime
                .get_thread(first_id)
                .await
                .expect("get first thread"),
            None
        );
        assert!(
            runtime
                .get_thread(second_id)
                .await
                .expect("get second thread")
                .is_some()
        );

        let state = runtime
            .get_backfill_state()
            .await
            .expect("get backfill state");
        assert_eq!(state.status, BackfillStatus::Complete);
        assert_eq!(
            state.last_watermark,
            Some(backfill_watermark_for_path(
                codex_home.as_path(),
                second_path.as_path()
            ))
        );
        assert!(state.last_success_at.is_some());
    }

    #[tokio::test]
    async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_fields() {
        let dir = tempdir().expect("tempdir");
        let codex_home = dir.path().to_path_buf();
        let thread_uuid = Uuid::new_v4();
        let rollout_path = write_rollout_in_sessions(
            codex_home.as_path(),
            "2026-01-27T12-34-56",
            "2026-01-27T12:34:56Z",
            thread_uuid,
            Some(GitInfo {
                commit_hash: Some("rollout-sha".to_string()),
                branch: Some("rollout-branch".to_string()),
                repository_url: Some("git@example.com:openai/codex.git".to_string()),
            }),
        );

        let runtime =
            codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
                .await
                .expect("initialize runtime");
        let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id");
        let mut existing = extract_metadata_from_rollout(&rollout_path, "test-provider", None)
            .await
            .expect("extract")
            .metadata;
        existing.git_sha = None;
        existing.git_branch = Some("sqlite-branch".to_string());
        existing.git_origin_url = None;
        runtime
            .upsert_thread(&existing)
            .await
            .expect("existing metadata upsert");

        let mut config = crate::config::test_config();
        config.codex_home = codex_home.clone();
        config.model_provider_id = "test-provider".to_string();
        backfill_sessions(runtime.as_ref(), &config, None).await;

        let persisted = runtime
            .get_thread(thread_id)
            .await
            .expect("get thread")
            .expect("thread exists");
        assert_eq!(persisted.git_sha.as_deref(), Some("rollout-sha"));
        assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch"));
        assert_eq!(
            persisted.git_origin_url.as_deref(),
            Some("git@example.com:openai/codex.git")
        );
    }

    #[tokio::test]
    async fn backfill_sessions_normalizes_cwd_before_upsert() {
        let dir = tempdir().expect("tempdir");
        let codex_home = dir.path().to_path_buf();
        let thread_uuid = Uuid::new_v4();
        let session_cwd = codex_home.join(".");
        let rollout_path = write_rollout_in_sessions_with_cwd(
            codex_home.as_path(),
            "2026-01-27T12-34-56",
            "2026-01-27T12:34:56Z",
            thread_uuid,
            session_cwd.clone(),
            None,
        );

        let runtime =
            codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
                .await
                .expect("initialize runtime");

        let mut config = crate::config::test_config();
        config.codex_home = codex_home.clone();
        config.model_provider_id = "test-provider".to_string();
        backfill_sessions(runtime.as_ref(), &config, None).await;

        let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id");
        let stored = runtime
            .get_thread(thread_id)
            .await
            .expect("get thread")
            .expect("thread should be backfilled");

        assert_eq!(stored.rollout_path, rollout_path);
        assert_eq!(stored.cwd, normalize_cwd_for_state_db(&session_cwd));
    }

    fn write_rollout_in_sessions(
        codex_home: &Path,
        filename_ts: &str,
        event_ts: &str,
        thread_uuid: Uuid,
        git: Option<GitInfo>,
    ) -> PathBuf {
        write_rollout_in_sessions_with_cwd(
            codex_home,
            filename_ts,
            event_ts,
            thread_uuid,
            codex_home.to_path_buf(),
            git,
        )
    }

    fn write_rollout_in_sessions_with_cwd(
        codex_home: &Path,
        filename_ts: &str,
        event_ts: &str,
        thread_uuid: Uuid,
        cwd: PathBuf,
        git: Option<GitInfo>,
    ) -> PathBuf {
        let id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id");
        let sessions_dir = codex_home.join("sessions");
        std::fs::create_dir_all(sessions_dir.as_path()).expect("create sessions dir");
        let path = sessions_dir.join(format!("rollout-{filename_ts}-{thread_uuid}.jsonl"));
        let session_meta = SessionMeta {
            id,
            forked_from_id: None,
            timestamp: event_ts.to_string(),
            cwd,
            originator: "cli".to_string(),
            cli_version: "0.0.0".to_string(),
            source: SessionSource::default(),
            agent_nickname: None,
            agent_role: None,
            model_provider: Some("test-provider".to_string()),
            base_instructions: None,
            dynamic_tools: None,
            memory_mode: None,
        };
        let session_meta_line = SessionMetaLine {
            meta: session_meta,
            git,
        };
        let rollout_line = RolloutLine {
            timestamp: event_ts.to_string(),
            item: RolloutItem::SessionMeta(session_meta_line),
        };
        let json = serde_json::to_string(&rollout_line).expect("serialize rollout");
        let mut file = File::create(&path).expect("create rollout");
        writeln!(file, "{json}").expect("write rollout");
        path
    }
}
