use std::{collections::HashMap, env, fs, sync::Arc, time::Duration};

use camino_tempfile::tempdir;
use chrono::Utc;
use datetime_literal::datetime;
use jp_config::{
    PartialConfig as _,
    fs::load_partial,
    model::id::{ModelIdOrAliasConfig, PartialModelIdOrAliasConfig, ProviderId},
    util::build,
};
use jp_storage::{
    backend::{FsStorageBackend, NullLockBackend, NullPersistBackend},
    value::read_json,
};
use parking_lot::RwLock;
use serial_test::serial;
use test_log::test;

use super::*;

/// Test helper: wire a single backend into all four Workspace slots.
fn workspace_with_fs(root: impl Into<Utf8PathBuf>, fs: &FsStorageBackend) -> Workspace {
    Workspace::new(root).with_backend(Arc::new(fs.clone()))
}

#[test]
fn test_workspace_find_root() {
    struct TestCase {
        workspace_dir: &'static str,
        workspace_dir_name: Option<&'static str>,
        workspace_dir_name_is_file: bool,
        cwd: &'static str,
        expected: Option<&'static str>,
    }

    let workspace_dir_name = Some("test_workspace");
    let workspace_dir_name_is_file = false;

    let test_cases = HashMap::from([
        ("workspace in current directory", TestCase {
            workspace_dir: "project",
            workspace_dir_name,
            workspace_dir_name_is_file,
            cwd: "project",
            expected: Some("project"),
        }),
        ("workspace in parent directory", TestCase {
            workspace_dir: "project",
            workspace_dir_name,
            workspace_dir_name_is_file,
            cwd: "project/subdir",
            expected: Some("project"),
        }),
        ("workspace in grandparent directory", TestCase {
            workspace_dir: "project",
            workspace_dir_name,
            workspace_dir_name_is_file,
            cwd: "project/subdir/subsubdir",
            expected: Some("project"),
        }),
        ("no workspace directory", TestCase {
            workspace_dir: "project",
            workspace_dir_name: None,
            workspace_dir_name_is_file,
            cwd: "project",
            expected: None,
        }),
        ("workspace name is a file", TestCase {
            workspace_dir: "project",
            workspace_dir_name,
            workspace_dir_name_is_file: true,
            cwd: "project",
            expected: None,
        }),
        ("different workspace name", TestCase {
            workspace_dir: "project",
            workspace_dir_name: Some("different_name"),
            workspace_dir_name_is_file,
            cwd: "project",
            expected: None,
        }),
        ("empty workspace name", TestCase {
            workspace_dir: "project",
            workspace_dir_name: Some(""),
            workspace_dir_name_is_file,
            cwd: "project",
            expected: None,
        }),
    ]);

    for (name, case) in test_cases {
        #[allow(clippy::unnecessary_literal_unwrap)]
        let workspace_dir_name = workspace_dir_name.unwrap();

        let root = tempdir().unwrap().path().to_path_buf();
        let cwd = root.join(case.cwd);
        let project = root.join(case.workspace_dir);
        let expected = case.expected.map(|v| root.join(v));

        fs::create_dir_all(&cwd).unwrap();
        fs::create_dir_all(&project).unwrap();

        if case.workspace_dir_name.is_some() {
            if case.workspace_dir_name_is_file {
                fs::write(project.join(workspace_dir_name), "").unwrap();
            } else {
                fs::create_dir_all(project.join(workspace_dir_name)).unwrap();
            }
        }

        let result = Workspace::find_root(cwd, case.workspace_dir_name.unwrap_or("non-exist"));
        assert_eq!(result, expected, "Failed test case: {name}");
    }
}

#[test]
fn test_workspace_persist_via_lock() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");

    let mut workspace = workspace_with_fs(&root, &FsStorageBackend::new(&storage).unwrap());
    let config = AppConfig::new_test();

    let id = workspace.create_conversation(Conversation::default(), config.into());

    // Persist via ConversationMut: mark dirty then flush.
    let h = workspace.acquire_conversation(&id).unwrap();
    let mut conv = workspace.test_lock(h).into_mut();
    conv.update_metadata(|_| {}); // set dirty flag
    conv.flush().unwrap();
    drop(conv);

    assert!(storage.is_dir());

    let fs = FsStorageBackend::new(&storage).unwrap();
    let metadata_path = fs.conversation_metadata_path(&id).unwrap();
    assert!(metadata_path.is_file());

    let _metadata: Conversation = read_json(&metadata_path).unwrap();
}

#[test]
fn test_workspace_conversations() {
    let mut workspace = Workspace::new(Utf8PathBuf::new());
    assert_eq!(workspace.conversations().count(), 0);

    let id = ConversationId::default();
    let conversation = Conversation::default();
    workspace
        .state
        .conversations
        .entry(id)
        .or_default()
        .set(Arc::new(RwLock::new(conversation)))
        .unwrap();
    assert_eq!(workspace.conversations().count(), 1);
}

#[test]
fn test_workspace_acquire_conversation() {
    let mut workspace = Workspace::new(Utf8PathBuf::new());
    assert!(workspace.state.conversations.is_empty());

    let id = ConversationId::try_from(chrono::Utc::now() - Duration::from_secs(1)).unwrap();
    assert!(workspace.acquire_conversation(&id).is_err());

    let conversation = Conversation::default();
    workspace
        .state
        .conversations
        .entry(id)
        .or_default()
        .set(Arc::new(RwLock::new(conversation.clone())))
        .unwrap();

    let handle = workspace.acquire_conversation(&id).unwrap();
    assert_eq!(*workspace.metadata(&handle).unwrap(), conversation);
}

#[test]
fn test_workspace_create_conversation() {
    let mut workspace = Workspace::new(Utf8PathBuf::new());
    assert!(workspace.state.conversations.is_empty());

    let conversation = Conversation::default();
    let config = AppConfig::new_test();
    let id = workspace.create_conversation(conversation.clone(), config.into());

    assert_eq!(
        workspace
            .state
            .conversations
            .get(&id)
            .and_then(|v| v.get())
            .map(|arc| arc.read().clone()),
        Some(conversation)
    );
}

#[test]
fn test_workspace_remove_conversation() {
    let mut workspace = Workspace::new(Utf8PathBuf::new());
    assert!(workspace.state.conversations.is_empty());

    let id = ConversationId::try_from(chrono::Utc::now() - Duration::from_secs(1)).unwrap();
    let conversation = Conversation::default();
    workspace
        .state
        .conversations
        .entry(id)
        .or_default()
        .set(Arc::new(RwLock::new(conversation)))
        .unwrap();
    // Also add events entry so test_lock works.
    workspace
        .state
        .events
        .entry(id)
        .or_default()
        .set(Arc::new(RwLock::new(ConversationStream::new_test())))
        .unwrap();

    let handle = workspace.acquire_conversation(&id).unwrap();
    let lock = workspace.test_lock(handle);
    workspace.remove_conversation_with_lock(lock.into_mut());
    assert!(workspace.state.conversations.is_empty());
}

#[test]
fn test_load_index_fresh_workspace() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");

    fs::create_dir_all(&storage).unwrap();

    let mut workspace = workspace_with_fs(&root, &FsStorageBackend::new(&storage).unwrap());
    workspace.disable_persistence();

    workspace.load_conversation_index();
    assert_eq!(workspace.conversations().count(), 0);
}

#[test]
fn test_load_index_fresh_workspace_then_create_conversation() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");

    fs::create_dir_all(&storage).unwrap();

    let mut workspace = workspace_with_fs(&root, &FsStorageBackend::new(&storage).unwrap());
    workspace.disable_persistence();

    workspace.load_conversation_index();
    assert_eq!(workspace.conversations().count(), 0);

    let config = Arc::new(AppConfig::new_test());
    let id = workspace.create_conversation(Conversation::default(), config);
    let handle = workspace.acquire_conversation(&id).unwrap();
    assert!(
        !workspace.events(&handle).unwrap().is_empty()
            || workspace.events(&handle).unwrap().is_empty()
    );
    assert_eq!(workspace.metadata(&handle).unwrap().title, None);
}

#[test]
fn test_load_index_existing_workspace_events_accessible() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage_path = root.join("storage");

    let config = Arc::new(AppConfig::new_test());
    let id = ConversationId::try_from(datetime!(2024-03-15 12:00:00 Z)).unwrap();

    // Write a conversation to disk via flush.
    {
        let mut ws = workspace_with_fs(&root, &FsStorageBackend::new(&storage_path).unwrap());
        ws.create_conversation_with_id(id, Conversation::default(), config.clone());
        let h = ws.acquire_conversation(&id).unwrap();
        let mut conv = ws.test_lock(h).into_mut();
        conv.update_metadata(|_| {});
        conv.flush().unwrap();
    }

    // Reload from scratch.
    let mut ws = workspace_with_fs(&root, &FsStorageBackend::new(&storage_path).unwrap());
    ws.disable_persistence();
    ws.load_conversation_index();

    let handle = ws.acquire_conversation(&id).unwrap();
    ws.eager_load_conversation(&handle).unwrap();
    let events = ws.events(&handle).unwrap();

    let stream_config = events.config();
    assert!(stream_config.is_ok());
}

/// Regression test: continuing a conversation without the original config
/// passed via `--cfg` must preserve the conversation's config.
#[test]
fn test_conversation_config_preserved_across_reload() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage_path = root.join("storage");

    let mut custom_config = AppConfig::new_test();
    custom_config.assistant.model.id =
        ModelIdOrAliasConfig::Id((ProviderId::Anthropic, "custom-model").try_into().unwrap());
    let id = ConversationId::try_from(datetime!(2024-05-20 10:00:00 Z)).unwrap();

    // Persist via flush.
    {
        let mut ws = workspace_with_fs(&root, &FsStorageBackend::new(&storage_path).unwrap());
        ws.create_conversation_with_id(id, Conversation::default(), Arc::new(custom_config));
        let h = ws.acquire_conversation(&id).unwrap();
        let mut conv = ws.test_lock(h).into_mut();
        conv.update_metadata(|_| {});
        conv.flush().unwrap();
    }

    // Reload without the custom config.
    let mut ws = workspace_with_fs(&root, &FsStorageBackend::new(&storage_path).unwrap());
    ws.disable_persistence();
    ws.load_conversation_index();
    let handle = ws.acquire_conversation(&id).unwrap();
    ws.eager_load_conversation(&handle).unwrap();
    let bare_partial = AppConfig::new_test().to_partial();
    let stream_config = ws
        .events(&handle)
        .unwrap()
        .config()
        .expect("valid config")
        .to_partial();
    let merged = load_partial(bare_partial, stream_config).expect("merge ok");
    let final_config = build(merged).expect("valid config");

    let resolved = final_config.assistant.model.id.resolved();
    assert_eq!(
        resolved.name.as_ref(),
        "custom-model",
        "conversation config must be preserved when continuing without --cfg flag"
    );

    let stream_partial = ws.events(&handle).unwrap().config().unwrap().to_partial();
    let delta = stream_partial.delta(final_config.to_partial());
    assert_eq!(
        delta.assistant.model.id,
        PartialModelIdOrAliasConfig::empty(),
        "config delta must not contain a model change when continuing a conversation without \
         overrides"
    );
}

#[test]
fn test_workspace_persist_single_conversation_via_lock() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");

    let mut workspace = workspace_with_fs(&root, &FsStorageBackend::new(&storage).unwrap());
    let config = Arc::new(AppConfig::new_test());

    let id1 = ConversationId::try_from(datetime!(2024-01-01 00:00:00 Z)).unwrap();
    let id2 = ConversationId::try_from(datetime!(2024-01-02 00:00:00 Z)).unwrap();

    workspace.create_conversation_with_id(id1, Conversation::default(), config.clone());
    workspace.create_conversation_with_id(id2, Conversation::default(), config.clone());

    // Only persist id1 via lock.
    let h1 = workspace.acquire_conversation(&id1).unwrap();
    let mut conv = workspace.test_lock(h1).into_mut();
    conv.update_metadata(|_| {}); // set dirty flag
    conv.flush().unwrap();
    drop(conv);

    assert!(storage.is_dir());

    let fs_check = FsStorageBackend::new(&storage).unwrap();
    let id1_metadata = fs_check.conversation_metadata_path(&id1);
    assert!(id1_metadata.is_some_and(|p| p.is_file()));

    // id2 was never persisted, so its directory shouldn't exist.
    let id2_metadata = fs_check.conversation_metadata_path(&id2);
    assert!(id2_metadata.is_none());
}

/// Regression test: files in a local conversation's user-storage directory must
/// survive persistence.
#[test]
fn test_persist_preserves_files_in_user_storage() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage_path = root.join("storage");
    let user_root = tmp.path().join("user");

    let config = Arc::new(AppConfig::new_test());
    let id = ConversationId::try_from(datetime!(2024-07-01 00:00:00 Z)).unwrap();

    let fs = FsStorageBackend::new(&storage_path)
        .unwrap()
        .with_user_storage(&user_root, "test-ws", "abc")
        .unwrap();
    let mut workspace = workspace_with_fs(&root, &fs);

    let conversation = Conversation::default().with_local(true);
    workspace.create_conversation_with_id(id, conversation, config);

    let conv_dir = fs.build_conversation_dir(&id, None, true);
    fs::create_dir_all(&conv_dir).unwrap();
    let query_file = conv_dir.join("QUERY_MESSAGE.md");
    fs::write(&query_file, "test query content").unwrap();
    assert!(query_file.is_file());

    let h = workspace.acquire_conversation(&id).unwrap();
    let mut conv = workspace.test_lock(h).into_mut();
    conv.update_metadata(|_| {});
    conv.flush().unwrap();
    drop(conv);

    assert!(
        query_file.is_file(),
        "query file in user-storage conversation directory must survive persistence"
    );

    let workspace_conv_dir = fs.build_conversation_dir(&id, None, false);
    assert!(
        !workspace_conv_dir.exists(),
        "local conversation should not create a workspace-side directory"
    );
}

#[test]
fn test_remove_ephemeral_conversations() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");

    let fs = FsStorageBackend::new(&storage).unwrap();
    let mut ws = workspace_with_fs(&root, &fs);
    let config = Arc::new(AppConfig::new_test());

    let expired_id = ConversationId::try_from(datetime!(2024-01-01 00:00:00 Z)).unwrap();
    let future_id = ConversationId::try_from(datetime!(2024-01-02 00:00:00 Z)).unwrap();
    let expired_titled_id = ConversationId::try_from(datetime!(2024-01-03 00:00:00 Z)).unwrap();
    let skipped_id = ConversationId::try_from(datetime!(2024-01-04 00:00:00 Z)).unwrap();
    let permanent_id = ConversationId::try_from(datetime!(2024-01-05 00:00:00 Z)).unwrap();

    let past = Some(Utc::now() - chrono::Duration::hours(1));
    let ahead = Some(Utc::now() + chrono::Duration::hours(1));

    // Expired: should be removed.
    ws.create_conversation_with_id(
        expired_id,
        Conversation {
            expires_at: past,
            ..Default::default()
        },
        config.clone(),
    );

    // Future expiration: should be kept.
    ws.create_conversation_with_id(
        future_id,
        Conversation {
            expires_at: ahead,
            ..Default::default()
        },
        config.clone(),
    );

    // Expired with title: should be removed.
    ws.create_conversation_with_id(
        expired_titled_id,
        Conversation {
            title: Some("hello world".into()),
            expires_at: past,
            ..Default::default()
        },
        config.clone(),
    );

    // Expired but in skip list: should be kept.
    ws.create_conversation_with_id(
        skipped_id,
        Conversation {
            expires_at: past,
            ..Default::default()
        },
        config.clone(),
    );

    // No expiration: should be kept.
    ws.create_conversation_with_id(permanent_id, Conversation::default(), config.clone());

    // Flush all to disk so the filesystem scanner can see them.
    for &id in &[
        expired_id,
        future_id,
        expired_titled_id,
        skipped_id,
        permanent_id,
    ] {
        let h = ws.acquire_conversation(&id).unwrap();
        let mut conv = ws.test_lock(h).into_mut();
        conv.update_metadata(|_| {});
        conv.flush().unwrap();
    }

    ws.remove_ephemeral_conversations(&[skipped_id]);

    let fs_check = FsStorageBackend::new(&storage).unwrap();
    assert!(
        fs_check.find_conversation_dir(&expired_id).is_none(),
        "expired conversation should be removed"
    );
    assert!(
        fs_check.find_conversation_dir(&future_id).is_some(),
        "future conversation should be kept"
    );
    assert!(
        fs_check.find_conversation_dir(&expired_titled_id).is_none(),
        "expired titled conversation should be removed"
    );
    assert!(
        fs_check.find_conversation_dir(&skipped_id).is_some(),
        "skipped conversation should be kept"
    );
    assert!(
        fs_check.find_conversation_dir(&permanent_id).is_some(),
        "permanent conversation should be kept"
    );
}

/// Verify that `NullLockBackend` (used for `--no-persist`) allows multiple
/// locks on the same conversation without blocking.
#[test]
fn test_no_persist_skips_locking() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");
    std::fs::create_dir_all(&storage).unwrap();

    let fs = Arc::new(FsStorageBackend::new(&storage).unwrap());

    // Simulate --no-persist: load from FS, but use null persist + null lock.
    let mut workspace = Workspace::new(&root)
        .with_loader(fs.clone() as Arc<dyn jp_storage::backend::LoadBackend>)
        .with_sessions(fs as Arc<dyn jp_storage::backend::SessionBackend>)
        .with_persist(Arc::new(NullPersistBackend))
        .with_locker(Arc::new(NullLockBackend));

    let config = Arc::new(AppConfig::new_test());
    let lock1 = workspace
        .create_and_lock_conversation(Conversation::default(), config.clone(), None)
        .unwrap();
    let id = lock1.id();

    // A second lock on the same conversation should succeed (no exclusion).
    let handle = workspace.acquire_conversation(&id).unwrap();
    let result = workspace.lock_conversation(handle, None).unwrap();
    assert!(
        matches!(result, LockResult::Acquired(_)),
        "NullLockBackend should never block"
    );
}

/// Verify that `lock_new_conversation` returns an error when the lock backend
/// denies the lock (instead of silently falling back to `NoopLockGuard`).
#[test]
fn test_lock_new_conversation_errors_on_denial() {
    let mut workspace = Workspace::new("root");
    let config = Arc::new(AppConfig::new_test());

    // Create a conversation and lock it via the in-memory backend.
    let id = workspace.create_conversation(Conversation::default(), config.clone());
    let handle = workspace.acquire_conversation(&id).unwrap();
    let _guard = workspace.lock_conversation(handle, None).unwrap();

    // Now try to create-and-lock another conversation on the same ID. The
    // in-memory backend will deny the lock because it's already held. Since we
    // can't create a duplicate ID, test via a fresh workspace that shares the
    // same locker with the lock already held.
    //
    // Instead, we test via lock_conversation directly: the lock is held, so a
    // second attempt should return AlreadyLocked.
    let handle2 = workspace.acquire_conversation(&id).unwrap();
    let result = workspace.lock_conversation(handle2, None).unwrap();
    assert!(
        matches!(result, LockResult::AlreadyLocked(_)),
        "in-memory backend should deny second lock on same conversation"
    );
}

#[test]
fn test_archive_removes_from_index() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");

    let fs = FsStorageBackend::new(&storage).unwrap();
    let mut ws = workspace_with_fs(&root, &fs);
    let config = Arc::new(AppConfig::new_test());

    let id = ConversationId::try_from(datetime!(2024-06-01 00:00:00 Z)).unwrap();
    ws.create_conversation_with_id(id, Conversation::default(), config);

    // Flush so the conversation exists on disk.
    let h = ws.acquire_conversation(&id).unwrap();
    let mut conv = ws.test_lock(h).into_mut();
    conv.update_metadata(|_| {});
    conv.flush().unwrap();
    drop(conv);

    // Archive it.
    let h = ws.acquire_conversation(&id).unwrap();
    let lock = ws.test_lock(h);
    ws.archive_conversation(lock.into_mut());

    // No longer in the active index.
    assert!(ws.acquire_conversation(&id).is_err());
    assert_eq!(ws.conversations().count(), 0);
}

#[test]
fn test_archive_sets_archived_at() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");

    let fs = FsStorageBackend::new(&storage).unwrap();
    let mut ws = workspace_with_fs(&root, &fs);
    let config = Arc::new(AppConfig::new_test());

    let id = ConversationId::try_from(datetime!(2024-06-01 00:00:00 Z)).unwrap();
    ws.create_conversation_with_id(id, Conversation::default(), config);

    let h = ws.acquire_conversation(&id).unwrap();
    let mut conv = ws.test_lock(h).into_mut();
    conv.update_metadata(|_| {});
    conv.flush().unwrap();
    drop(conv);

    let before = Utc::now();
    let h = ws.acquire_conversation(&id).unwrap();
    let lock = ws.test_lock(h);
    ws.archive_conversation(lock.into_mut());

    // Metadata loaded from the archive should have archived_at set.
    let archived: Vec<_> = ws.archived_conversations().collect();
    assert_eq!(archived.len(), 1);
    assert_eq!(archived[0].0, id);
    let archived_at = archived[0]
        .1
        .archived_at
        .expect("archived_at should be set");
    assert!(archived_at >= before);
}

#[test]
fn test_unarchive_restores_to_index() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");

    let fs = FsStorageBackend::new(&storage).unwrap();
    let mut ws = workspace_with_fs(&root, &fs);
    let config = Arc::new(AppConfig::new_test());

    let id = ConversationId::try_from(datetime!(2024-06-01 00:00:00 Z)).unwrap();
    ws.create_conversation_with_id(id, Conversation::default(), config);

    let h = ws.acquire_conversation(&id).unwrap();
    let mut conv = ws.test_lock(h).into_mut();
    conv.update_metadata(|_| {});
    conv.flush().unwrap();
    drop(conv);

    // Archive then unarchive.
    let h = ws.acquire_conversation(&id).unwrap();
    let lock = ws.test_lock(h);
    ws.archive_conversation(lock.into_mut());
    assert!(ws.acquire_conversation(&id).is_err());

    let handle = ws.unarchive_conversation(&id).unwrap();
    assert_eq!(handle.id(), id);

    // Back in the active index.
    assert!(ws.acquire_conversation(&id).is_ok());
    assert_eq!(ws.conversations().count(), 1);
}

#[test]
fn test_unarchive_clears_archived_at() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");

    let fs = FsStorageBackend::new(&storage).unwrap();
    let mut ws = workspace_with_fs(&root, &fs);
    let config = Arc::new(AppConfig::new_test());

    let id = ConversationId::try_from(datetime!(2024-06-01 00:00:00 Z)).unwrap();
    ws.create_conversation_with_id(id, Conversation::default(), config);

    let h = ws.acquire_conversation(&id).unwrap();
    let mut conv = ws.test_lock(h).into_mut();
    conv.update_metadata(|_| {});
    conv.flush().unwrap();
    drop(conv);

    // Archive.
    let h = ws.acquire_conversation(&id).unwrap();
    let lock = ws.test_lock(h);
    ws.archive_conversation(lock.into_mut());

    // Verify archived_at is set.
    let archived: Vec<_> = ws.archived_conversations().collect();
    assert!(archived[0].1.archived_at.is_some());

    // Unarchive.
    ws.unarchive_conversation(&id).unwrap();

    // archived_at should be cleared in the active index.
    let h = ws.acquire_conversation(&id).unwrap();
    let meta = ws.metadata(&h).unwrap();
    assert!(
        meta.archived_at.is_none(),
        "archived_at should be cleared after unarchive"
    );
}

#[test]
fn test_archived_conversations_returns_empty_when_none() {
    let ws = Workspace::new(Utf8PathBuf::new());
    assert_eq!(ws.archived_conversations().count(), 0);
}

#[test]
fn test_archived_keyword_resolves_most_recently_archived() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");

    let fs = FsStorageBackend::new(&storage).unwrap();
    let mut ws = workspace_with_fs(&root, &fs);
    let config = Arc::new(AppConfig::new_test());

    let id1 = ConversationId::try_from(datetime!(2024-06-01 00:00:00 Z)).unwrap();
    let id2 = ConversationId::try_from(datetime!(2024-06-02 00:00:00 Z)).unwrap();

    for id in [id1, id2] {
        ws.create_conversation_with_id(id, Conversation::default(), config.clone());
        let h = ws.acquire_conversation(&id).unwrap();
        let mut conv = ws.test_lock(h).into_mut();
        conv.update_metadata(|_| {});
        conv.flush().unwrap();
        drop(conv);
    }

    // Archive id1 first, then id2. id2 gets the later archived_at.
    let h = ws.acquire_conversation(&id1).unwrap();
    ws.archive_conversation(ws.test_lock(h).into_mut());
    std::thread::sleep(Duration::from_millis(10));
    let h = ws.acquire_conversation(&id2).unwrap();
    ws.archive_conversation(ws.test_lock(h).into_mut());

    // The `archived` keyword should resolve to id2 (most recently archived).
    let archived: Vec<_> = ws.archived_conversations().collect();
    assert_eq!(archived.len(), 2);

    let most_recent = archived
        .iter()
        .max_by_key(|(_, c)| c.archived_at)
        .map(|(id, _)| *id)
        .unwrap();
    assert_eq!(most_recent, id2);
}

#[test]
fn test_unarchive_nonexistent_returns_error() {
    let tmp = tempdir().unwrap();
    let root = tmp.path().join("root");
    let storage = root.join("storage");
    fs::create_dir_all(&storage).unwrap();

    let fs_backend = FsStorageBackend::new(&storage).unwrap();
    let mut ws = workspace_with_fs(&root, &fs_backend);

    let id = ConversationId::try_from(datetime!(2024-06-01 00:00:00 Z)).unwrap();
    assert!(ws.unarchive_conversation(&id).is_err());
}

/// Snapshot the two env vars [`user_data_dir`] depends on, so each test can
/// freely mutate them and put the process state back the way it found it.
struct UserDataDirEnvGuard {
    jp: Option<String>,
    xdg: Option<String>,
}

impl UserDataDirEnvGuard {
    fn capture() -> Self {
        Self {
            jp: env::var("JP_USER_DATA_DIR").ok(),
            xdg: env::var("XDG_DATA_HOME").ok(),
        }
    }
}

impl Drop for UserDataDirEnvGuard {
    fn drop(&mut self) {
        match &self.jp {
            Some(value) => unsafe { env::set_var("JP_USER_DATA_DIR", value) },
            None => unsafe { env::remove_var("JP_USER_DATA_DIR") },
        }
        match &self.xdg {
            Some(value) => unsafe { env::set_var("XDG_DATA_HOME", value) },
            None => unsafe { env::remove_var("XDG_DATA_HOME") },
        }
    }
}

#[test]
#[serial(env_vars)]
fn user_data_dir_uses_jp_user_data_dir_verbatim() {
    let _guard = UserDataDirEnvGuard::capture();
    unsafe { env::set_var("JP_USER_DATA_DIR", "/tmp/jp-test/user-data") };
    unsafe { env::remove_var("XDG_DATA_HOME") };

    let dir = user_data_dir().unwrap();

    // Used verbatim: no `jp` suffix appended.
    assert_eq!(dir, Utf8PathBuf::from("/tmp/jp-test/user-data"));
}

#[test]
#[serial(env_vars)]
fn user_data_dir_jp_user_data_dir_wins_over_xdg() {
    let _guard = UserDataDirEnvGuard::capture();
    unsafe { env::set_var("JP_USER_DATA_DIR", "/tmp/jp-test/user-data") };
    unsafe { env::set_var("XDG_DATA_HOME", "/tmp/jp-test/xdg") };

    let dir = user_data_dir().unwrap();

    assert_eq!(dir, Utf8PathBuf::from("/tmp/jp-test/user-data"));
}

#[test]
#[serial(env_vars)]
fn user_data_dir_uses_xdg_data_home_when_absolute() {
    let _guard = UserDataDirEnvGuard::capture();
    // Use tempdir so the path is absolute on every platform — a literal
    // like `/tmp/jp-test/xdg` is relative on Windows and would fall
    // through to the platform default.
    let tmp = tempdir().unwrap();
    let xdg_root = tmp.path();
    unsafe { env::remove_var("JP_USER_DATA_DIR") };
    unsafe { env::set_var("XDG_DATA_HOME", xdg_root.as_str()) };

    let dir = user_data_dir().unwrap();

    // Absolute XDG path gets the `jp` application suffix.
    assert_eq!(dir, xdg_root.join("jp"));
}

#[test]
#[serial(env_vars)]
fn user_data_dir_falls_through_when_xdg_relative() {
    let _guard = UserDataDirEnvGuard::capture();
    unsafe { env::remove_var("JP_USER_DATA_DIR") };
    unsafe { env::set_var("XDG_DATA_HOME", "relative/dir") };

    let dir = user_data_dir().unwrap();

    // Per the XDG spec, a relative XDG_DATA_HOME is treated as unset, so the
    // platform default is used instead of `relative/dir/jp`.
    assert_ne!(dir, Utf8PathBuf::from("relative/dir/jp"));
    assert!(
        dir.is_absolute(),
        "expected platform default to be absolute, got {dir}"
    );
}

#[test]
#[serial(env_vars)]
fn user_data_dir_falls_through_when_xdg_empty() {
    let _guard = UserDataDirEnvGuard::capture();
    unsafe { env::remove_var("JP_USER_DATA_DIR") };
    unsafe { env::set_var("XDG_DATA_HOME", "") };

    let dir = user_data_dir().unwrap();

    // Empty XDG_DATA_HOME is treated as unset, not as a bare `/jp`.
    assert_ne!(dir, Utf8PathBuf::from("/jp"));
    assert_ne!(dir.as_str(), "");
    assert!(
        dir.is_absolute(),
        "expected platform default to be absolute, got {dir}"
    );
}

#[test]
#[serial(env_vars)]
fn user_data_dir_treats_empty_jp_user_data_dir_as_unset() {
    let _guard = UserDataDirEnvGuard::capture();
    // tempdir for the same reason as the absolute-XDG test: a Unix-style
    // literal isn't absolute on Windows.
    let tmp = tempdir().unwrap();
    let xdg_root = tmp.path();
    unsafe { env::set_var("JP_USER_DATA_DIR", "") };
    unsafe { env::set_var("XDG_DATA_HOME", xdg_root.as_str()) };

    let dir = user_data_dir().unwrap();

    // Empty JP_USER_DATA_DIR must not short-circuit into an empty path that
    // callers would then resolve under the current working directory.
    assert_eq!(dir, xdg_root.join("jp"));
}
