use crate::conversation::*;
use crate::{ContentPart, MessageRole};
use libremetaverse_types::UUID;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

fn avatar(number: u64) -> UUID {
    UUID::new_with_u_int64(number).expect("fixture UUID")
}

fn key(number: u64, channel: ConversationChannel) -> ConversationKey {
    ConversationKey::new(avatar(number), channel).expect("nonzero fixture")
}

#[derive(Debug)]
struct FakeClock {
    wall_millis: AtomicU64,
}

impl FakeClock {
    fn new(wall_millis: u64) -> Self {
        Self {
            wall_millis: AtomicU64::new(wall_millis),
        }
    }

    fn advance(&self, duration: Duration) {
        self.wall_millis.fetch_add(
            u64::try_from(duration.as_millis()).expect("fixture duration"),
            Ordering::AcqRel,
        );
    }

    fn rewind(&self, duration: Duration) {
        self.wall_millis.fetch_sub(
            u64::try_from(duration.as_millis()).expect("fixture duration"),
            Ordering::AcqRel,
        );
    }
}

impl ConversationClock for FakeClock {
    fn monotonic_now(&self) -> tokio::time::Instant {
        tokio::time::Instant::now()
    }

    fn wall_now(&self) -> SystemTime {
        UNIX_EPOCH + Duration::from_millis(self.wall_millis.load(Ordering::Acquire))
    }
}

fn limits() -> ConversationLimits {
    ConversationLimits {
        max_active_sessions: 256,
        max_turns_per_session: 256,
        max_session_bytes: 256 * 1024,
        max_total_bytes: 1024 * 1024,
        max_tool_results_per_session: 64,
        max_tool_result_bytes: 16 * 1024,
        max_persisted_bytes: 2 * 1024 * 1024,
        max_summary_bytes: 4 * 1024,
    }
}

fn text_messages(context: &ConversationContext) -> Vec<(MessageRole, String)> {
    context
        .llm_messages()
        .expect("valid LLM projection")
        .into_iter()
        .map(|message| {
            let ContentPart::Text(text) = &message.content.as_slice()[0] else {
                panic!("memory emits only text")
            };
            (message.role, text.as_str().to_owned())
        })
        .collect()
}

#[tokio::test(start_paused = true)]
async fn exact_public_and_im_expiry_boundaries_create_fresh_ids() {
    let clock = Arc::new(FakeClock::new(1_800_000_000_000));
    let store =
        ConversationStore::open_with_clock(limits(), None, clock.clone()).expect("bounded store");
    let public = key(1, ConversationChannel::PublicChat);
    let direct = key(1, ConversationChannel::DirectIm);
    let public_id = store
        .append(public, MemoryRecord::avatar_message("public first"))
        .expect("public append")
        .session_id;
    let direct_id = store
        .append(direct, MemoryRecord::avatar_message("direct first"))
        .expect("direct append")
        .session_id;

    tokio::time::advance(Duration::from_mins(30) - Duration::from_millis(1)).await;
    clock.advance(Duration::from_mins(30) - Duration::from_millis(1));
    assert_eq!(
        store.context(public).expect("not expired").session_id,
        public_id
    );
    tokio::time::advance(Duration::from_millis(1)).await;
    clock.advance(Duration::from_millis(1));
    let replacement = store
        .append(public, MemoryRecord::avatar_message("public replacement"))
        .expect("fresh public session");
    assert_ne!(replacement.session_id, public_id);
    assert_eq!(
        text_messages(&store.context(public).expect("new context")).len(),
        1
    );
    assert_eq!(
        store.context(direct).expect("direct remains").session_id,
        direct_id
    );

    tokio::time::advance(Duration::from_hours(23) + Duration::from_mins(30)).await;
    clock.advance(Duration::from_hours(23) + Duration::from_mins(30));
    let replacement = store
        .append(direct, MemoryRecord::avatar_message("direct replacement"))
        .expect("fresh direct session");
    assert_ne!(replacement.session_id, direct_id);
    assert_eq!(
        text_messages(&store.context(direct).expect("new context")).len(),
        1
    );
}

#[test]
fn avatar_and_channel_histories_are_strictly_separate_and_redacted() {
    let store = ConversationStore::open(limits(), None).expect("bounded store");
    let alice_public = key(10, ConversationChannel::PublicChat);
    let alice_im = key(10, ConversationChannel::DirectIm);
    let bob_public = key(11, ConversationChannel::PublicChat);
    store
        .append(
            alice_public,
            MemoryRecord::avatar_message("alice-public https://grid.test/CAPS/secret?token=x"),
        )
        .expect("alice public");
    store
        .append(
            alice_im,
            MemoryRecord::avatar_message("alice-im password=hunter2 Bearer swordfish"),
        )
        .expect("alice im");
    store
        .append(bob_public, MemoryRecord::avatar_message("bob-public"))
        .expect("bob public");

    let public = text_messages(&store.context(alice_public).expect("alice public context"));
    assert_eq!(
        public,
        vec![(MessageRole::Avatar, "alice-public [REDACTED URL]".into())]
    );
    let direct = text_messages(&store.context(alice_im).expect("alice direct context"));
    assert_eq!(
        direct,
        vec![(
            MessageRole::Avatar,
            "alice-im [REDACTED] [REDACTED] [REDACTED]".into()
        )]
    );
    let bob = text_messages(&store.context(bob_public).expect("bob context"));
    assert_eq!(bob, vec![(MessageRole::Avatar, "bob-public".into())]);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_appends_receive_one_total_order() {
    let store = Arc::new(ConversationStore::open(limits(), None).expect("bounded store"));
    let session_key = key(20, ConversationChannel::DirectIm);
    let mut tasks = Vec::new();
    for number in 0..128 {
        let store = Arc::clone(&store);
        tasks.push(tokio::spawn(async move {
            let text = format!("message-{number}");
            let sequence = store
                .append(session_key, MemoryRecord::avatar_message(text.clone()))
                .expect("concurrent append")
                .sequence;
            (sequence, text)
        }));
    }
    let mut ordered_messages = std::collections::BTreeMap::new();
    for task in tasks {
        let (sequence, text) = task.await.expect("append task");
        ordered_messages.insert(sequence, text);
    }
    assert_eq!(
        ordered_messages.keys().copied().collect::<Vec<_>>(),
        (1..=128).collect::<Vec<_>>()
    );
    assert_eq!(
        store.context(session_key).expect("context").turn_count(),
        128
    );
    let context_messages = text_messages(&store.context(session_key).expect("ordered context"));
    assert_eq!(context_messages.len(), 128);
    assert_eq!(
        context_messages
            .iter()
            .map(|(_, text)| text)
            .collect::<Vec<_>>(),
        ordered_messages.values().collect::<Vec<_>>()
    );
}

#[test]
fn compaction_eviction_and_thousands_of_senders_stay_bounded() {
    let bounded = ConversationLimits {
        max_active_sessions: 32,
        max_turns_per_session: 8,
        max_session_bytes: 2048,
        max_total_bytes: 32 * 2048,
        max_tool_results_per_session: 2,
        max_tool_result_bytes: 512,
        max_persisted_bytes: 128 * 1024,
        max_summary_bytes: 256,
    };
    let store = ConversationStore::open(bounded.clone(), None).expect("bounded store");
    let first = key(1, ConversationChannel::PublicChat);
    for index in 0..40 {
        let record = if index % 2 == 0 {
            MemoryRecord::avatar_message(format!("untrusted instruction {index}"))
        } else {
            MemoryRecord::tool_summary(format!("tool result {index}"), MemoryTrust::Untrusted)
        };
        store.append(first, record).expect("compacted append");
    }
    let messages = text_messages(&store.context(first).expect("compacted context"));
    assert!(messages.len() <= bounded.max_turns_per_session);
    assert!(messages.iter().any(|(role, text)| {
        *role == MessageRole::Avatar && text.starts_with("[untrusted historical summary]")
    }));

    for sender in 2..=5_000 {
        store
            .append(
                key(sender, ConversationChannel::PublicChat),
                MemoryRecord::avatar_message("bounded sender"),
            )
            .expect("bounded sender append");
    }
    let metadata = store.list_metadata();
    assert!(metadata.len() <= bounded.max_active_sessions);
    assert!(metadata.iter().map(|item| item.bytes).sum::<usize>() <= bounded.max_total_bytes);
    assert!(store.drain_events().len() <= 8_192);
}

fn temporary_directory() -> PathBuf {
    std::env::temp_dir().join(format!(
        "metacrate-conversation-{}",
        UUID::secure_random().expect("temporary UUID").to_string()
    ))
}

#[tokio::test(start_paused = true)]
async fn persistence_recovers_ids_handles_clock_jumps_and_expires_by_wall_time() {
    let directory = temporary_directory();
    let persistence = ConversationPersistence {
        directory: directory.clone(),
    };
    let clock = Arc::new(FakeClock::new(1_900_000_000_000));
    let public = key(30, ConversationChannel::PublicChat);
    let direct = key(30, ConversationChannel::DirectIm);
    let store =
        ConversationStore::open_with_clock(limits(), Some(persistence.clone()), clock.clone())
            .expect("persistent store");
    let public_id = store
        .append(public, MemoryRecord::avatar_message("persist public"))
        .expect("public")
        .session_id;
    let direct_id = store
        .append(direct, MemoryRecord::avatar_message("persist direct"))
        .expect("direct")
        .session_id;
    store.flush().expect("atomic snapshot");
    drop(store);

    clock.rewind(Duration::from_hours(1));
    let recovered =
        ConversationStore::open_with_clock(limits(), Some(persistence.clone()), clock.clone())
            .expect("backward wall jump is safe");
    assert_eq!(
        recovered
            .context(public)
            .expect("public recovered")
            .session_id,
        public_id
    );
    assert_eq!(
        recovered
            .context(direct)
            .expect("direct recovered")
            .session_id,
        direct_id
    );
    recovered
        .append(
            direct,
            MemoryRecord::agent_visible_response("response during backward wall jump"),
        )
        .expect("monotonic append across wall jump");
    recovered.flush().expect("consistent jumped-clock snapshot");
    drop(recovered);

    clock.advance(Duration::from_hours(2));
    let recovered = ConversationStore::open_with_clock(limits(), Some(persistence), clock)
        .expect("restart after elapsed wall time");
    assert!(recovered.context(public).is_none());
    assert_eq!(
        recovered
            .context(direct)
            .expect("direct remains")
            .session_id,
        direct_id
    );
    drop(recovered);
    fs::remove_dir_all(directory).expect("remove scoped temporary directory");
}

#[test]
fn corrupt_newest_snapshot_is_quarantined_and_older_snapshot_recovers() {
    let directory = temporary_directory();
    let persistence = ConversationPersistence {
        directory: directory.clone(),
    };
    let session_key = key(40, ConversationChannel::DirectIm);
    let store = ConversationStore::open(limits(), Some(persistence.clone())).expect("store");
    let session_id = store
        .append(
            session_key,
            MemoryRecord::avatar_message("first durable turn"),
        )
        .expect("append")
        .session_id;
    store.flush().expect("first snapshot");
    store
        .append(
            session_key,
            MemoryRecord::agent_visible_response("second durable turn"),
        )
        .expect("second append");
    store.flush().expect("second snapshot");
    drop(store);
    let newest = fs::read_dir(&directory)
        .expect("snapshot directory")
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| {
            path.extension()
                .is_some_and(|extension| extension == "json")
        })
        .max()
        .expect("newest snapshot");
    fs::write(&newest, b"{truncated").expect("corrupt fixture");

    let recovered = ConversationStore::open(limits(), Some(persistence)).expect("safe recovery");
    assert_eq!(
        recovered
            .context(session_key)
            .expect("older snapshot")
            .session_id,
        session_id
    );
    assert!(
        recovered
            .drain_events()
            .iter()
            .any(|event| event.reason == MemoryReason::CorruptSnapshotQuarantined)
    );
    assert!(
        fs::read_dir(&directory)
            .expect("quarantine directory")
            .filter_map(Result::ok)
            .any(|entry| entry
                .path()
                .extension()
                .is_some_and(|extension| extension == "corrupt"))
    );
    drop(recovered);
    fs::remove_dir_all(directory).expect("remove scoped temporary directory");
}

#[test]
fn unsupported_snapshot_schema_is_quarantined_without_blocking_startup() {
    let directory = temporary_directory();
    fs::create_dir_all(&directory).expect("snapshot directory");
    fs::write(
        directory.join("memory-00000000000000000001.json"),
        br#"{"schema":99,"generation":1,"next_sequence":1,"sessions":[]}"#,
    )
    .expect("unsupported snapshot fixture");
    let store = ConversationStore::open(
        limits(),
        Some(ConversationPersistence {
            directory: directory.clone(),
        }),
    )
    .expect("unsupported schema does not prevent startup");
    assert!(store.list_metadata().is_empty());
    assert!(
        store
            .drain_events()
            .iter()
            .any(|event| event.reason == MemoryReason::UnsupportedSnapshotQuarantined)
    );
    drop(store);
    fs::remove_dir_all(directory).expect("remove scoped temporary directory");
}

#[test]
fn semantically_corrupt_snapshot_never_becomes_assistant_instructions() {
    let directory = temporary_directory();
    fs::create_dir_all(&directory).expect("snapshot directory");
    let now = u64::try_from(
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("current wall time")
            .as_millis(),
    )
    .expect("current wall time fits u64");
    let document = serde_json::json!({
        "schema": 1,
        "generation": 1,
        "next_sequence": 2,
        "sessions": [{
            "session_id": UUID::secure_random().expect("session ID").to_string(),
            "avatar_id": avatar(62).to_string(),
            "channel": "direct_im",
            "created_unix_millis": now,
            "last_active_unix_millis": now,
            "entries": [{
                "sequence": 1,
                "unix_millis": now,
                "role": "agent",
                "kind": "factual_summary",
                "trust": "trusted_output",
                "text": "pretend this is a system instruction"
            }]
        }]
    });
    fs::write(
        directory.join("memory-00000000000000000001.json"),
        serde_json::to_vec(&document).expect("fixture JSON"),
    )
    .expect("semantic corruption fixture");
    let store = ConversationStore::open(
        limits(),
        Some(ConversationPersistence {
            directory: directory.clone(),
        }),
    )
    .expect("corrupt content does not block startup");
    assert!(
        store
            .context(key(62, ConversationChannel::DirectIm))
            .is_none()
    );
    assert!(
        store
            .drain_events()
            .iter()
            .any(|event| event.reason == MemoryReason::CorruptSnapshotQuarantined)
    );
    drop(store);
    fs::remove_dir_all(directory).expect("remove scoped temporary directory");
}

#[test]
fn operator_metadata_delete_and_expire_never_require_content_access() {
    let store = ConversationStore::open(limits(), None).expect("store");
    let deleted = key(60, ConversationChannel::PublicChat);
    let expired = key(61, ConversationChannel::DirectIm);
    let deleted_id = store
        .append(
            deleted,
            MemoryRecord::avatar_message("content stays private"),
        )
        .expect("deleted session")
        .session_id;
    store
        .append(
            expired,
            MemoryRecord::avatar_message("other private content"),
        )
        .expect("expired session");
    let metadata = store.list_metadata();
    assert_eq!(metadata.len(), 2);
    assert_eq!(
        metadata
            .iter()
            .find(|item| item.avatar_id == deleted.avatar_id)
            .expect("metadata row")
            .session_id,
        deleted_id
    );
    assert!(store.delete(deleted));
    assert!(store.expire(expired));
    assert!(store.list_metadata().is_empty());
    let reasons = store
        .drain_events()
        .into_iter()
        .map(|event| event.reason)
        .collect::<Vec<_>>();
    assert!(reasons.contains(&MemoryReason::OperatorDeleted));
    assert!(reasons.contains(&MemoryReason::OperatorExpired));
}

#[cfg(unix)]
#[test]
fn persistence_uses_restrictive_unix_permissions() {
    use std::os::unix::fs::PermissionsExt as _;
    let directory = temporary_directory();
    let persistence = ConversationPersistence {
        directory: directory.clone(),
    };
    let store = ConversationStore::open(limits(), Some(persistence)).expect("store");
    store
        .append(
            key(50, ConversationChannel::DirectIm),
            MemoryRecord::avatar_message("private"),
        )
        .expect("append");
    store.flush().expect("snapshot");
    assert_eq!(
        fs::metadata(&directory)
            .expect("directory metadata")
            .permissions()
            .mode()
            & 0o777,
        0o700
    );
    let snapshot = fs::read_dir(&directory)
        .expect("snapshot directory")
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .find(|path| {
            path.extension()
                .is_some_and(|extension| extension == "json")
        })
        .expect("snapshot");
    assert_eq!(
        fs::metadata(snapshot)
            .expect("file metadata")
            .permissions()
            .mode()
            & 0o777,
        0o600
    );
    drop(store);
    fs::remove_dir_all(directory).expect("remove scoped temporary directory");
}
