#![cfg(not(target_arch = "wasm32"))]

use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::sync::Arc;

use base64::Engine;
use diaryx_core::command::{Command, Response};
use diaryx_core::diaryx::Diaryx;
use diaryx_core::fs::{
    AsyncFileSystem, EventEmittingFs, FileSystem, InMemoryFileSystem, SyncToAsyncFs,
};
use diaryx_core::path_utils::normalize_sync_path;
use diaryx_sync::{BodyDocManager, CrdtFs, DecoratedFsBuilder, SyncPlugin, WorkspaceCrdt};
use diaryx_sync::{FileMetadata, parse_snapshot_markdown};
use futures_lite::future::block_on;
use serde_json::json;

type BaseFs = SyncToAsyncFs<InMemoryFileSystem>;
type StackFs = EventEmittingFs<CrdtFs<BaseFs>>;

const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;

struct TestNode {
    diaryx: Diaryx<StackFs>,
    fs: StackFs,
    base_fs: BaseFs,
    workspace_crdt: Arc<WorkspaceCrdt>,
    body_docs: Arc<BodyDocManager>,
}

impl TestNode {
    fn new() -> Self {
        let base = SyncToAsyncFs::new(InMemoryFileSystem::new());
        let decorated = DecoratedFsBuilder::new(base).crdt_enabled(true).build();
        let fs = decorated.fs.clone();
        let base_fs = decorated.base_fs().clone();
        let workspace_crdt = Arc::clone(&decorated.workspace_crdt);
        let body_docs = Arc::clone(&decorated.body_doc_manager);

        let sync_plugin = SyncPlugin::with_instances(
            fs.clone(),
            Arc::clone(&workspace_crdt),
            Arc::clone(&body_docs),
            Arc::clone(&decorated.storage),
        );
        let mut diaryx = Diaryx::new(fs.clone());
        diaryx
            .plugin_registry_mut()
            .register_workspace_plugin(Arc::new(sync_plugin));

        Self {
            diaryx,
            fs,
            base_fs,
            workspace_crdt,
            body_docs,
        }
    }
}

fn execute(node: &TestNode, command: Command) -> Response {
    block_on(node.diaryx.execute(command)).expect("command should succeed")
}

fn plugin_cmd(command: &str, params: serde_json::Value) -> Command {
    Command::PluginCommand {
        plugin: "diaryx.sync".into(),
        command: command.into(),
        params,
    }
}

fn expect_plugin_binary(response: Response, context: &str) -> Vec<u8> {
    match response {
        Response::PluginResult(json) => {
            let b64_str = json
                .get("data")
                .and_then(|v| v.as_str())
                .unwrap_or_else(|| panic!("expected 'data' field in PluginResult for {context}"));
            B64.decode(b64_str)
                .unwrap_or_else(|e| panic!("invalid base64 in PluginResult for {context}: {e}"))
        }
        other => panic!("expected PluginResult for {context}, got {other:?}"),
    }
}

fn assert_metadata_matches(path: &str, actual: &FileMetadata, expected: &FileMetadata) {
    assert_eq!(
        actual.filename, expected.filename,
        "filename mismatch for {path}"
    );
    assert_eq!(actual.title, expected.title, "title mismatch for {path}");
    // Skip comparing part_of and contents directly — CRDT stores UUIDs while
    // disk frontmatter stores relative paths, so they are in different formats by design.
    assert_eq!(
        actual.audience, expected.audience,
        "audience mismatch for {path}"
    );
    assert_eq!(
        actual.description, expected.description,
        "description mismatch for {path}"
    );
    assert_eq!(
        actual.deleted, expected.deleted,
        "deleted mismatch for {path}"
    );
    assert_eq!(
        actual.modified_at, expected.modified_at,
        "modified_at mismatch for {path}"
    );

    let mut actual_attachment_paths = actual
        .attachments
        .iter()
        .map(|a| normalize_sync_path(&a.path))
        .collect::<Vec<_>>();
    let mut expected_attachment_paths = expected
        .attachments
        .iter()
        .map(|a| normalize_sync_path(&a.path))
        .collect::<Vec<_>>();
    actual_attachment_paths.sort();
    expected_attachment_paths.sort();
    assert_eq!(
        actual_attachment_paths, expected_attachment_paths,
        "attachment paths mismatch for {path}"
    );
}

fn disk_markdown_files(node: &TestNode) -> BTreeMap<String, String> {
    let mut files = BTreeMap::new();

    for path in node.base_fs.inner().list_all_files() {
        if path.extension().is_some_and(|ext| ext == "md") {
            let normalized = normalize_sync_path(&path.to_string_lossy());
            let content = node
                .base_fs
                .inner()
                .read_to_string(&path)
                .expect("disk file should be readable");
            files.insert(normalized, content);
        }
    }

    files
}

fn active_crdt_paths(node: &TestNode) -> BTreeSet<String> {
    let uuid_to_path = node.fs.inner().uuid_to_path_snapshot();
    node.workspace_crdt
        .list_active_files()
        .into_iter()
        .map(|(key, _)| {
            // Map UUID key to filesystem path via CrdtFs cache first,
            // then fall back to workspace hierarchy path reconstruction
            uuid_to_path
                .get(&key)
                .cloned()
                .or_else(|| {
                    node.workspace_crdt
                        .get_path(&key)
                        .map(|p| normalize_sync_path(&p.to_string_lossy()))
                })
                .unwrap_or_else(|| normalize_sync_path(&key))
        })
        .collect()
}

fn assert_disk_matches_crdt(node: &TestNode) {
    let disk_files = disk_markdown_files(node);
    let disk_paths: BTreeSet<String> = disk_files.keys().cloned().collect();
    let crdt_paths = active_crdt_paths(node);

    assert_eq!(
        disk_paths, crdt_paths,
        "active CRDT paths must match markdown files on disk"
    );

    for (path, disk_content) in disk_files {
        let uuid = find_uuid_for_path(node, &path);

        let crdt_metadata = node
            .workspace_crdt
            .get_file(&uuid)
            .unwrap_or_else(|| panic!("CRDT metadata should exist for disk file: {path}"));
        assert!(
            !crdt_metadata.deleted,
            "active disk file should not be tombstoned in CRDT: {path}"
        );

        let (disk_metadata, disk_body) =
            parse_snapshot_markdown(&path, &disk_content).expect("disk markdown should parse");
        assert_metadata_matches(&path, &crdt_metadata, &disk_metadata);

        let body_doc = node
            .body_docs
            .get(&uuid)
            .unwrap_or_else(|| panic!("body doc should exist for active file: {path}"));
        assert_eq!(
            body_doc.get_body(),
            disk_body,
            "body doc mismatch for {path}"
        );
    }
}

fn assert_replicas_match(source: &TestNode, target: &TestNode) {
    let source_paths = active_crdt_paths(source);
    let target_paths = active_crdt_paths(target);
    assert_eq!(
        source_paths, target_paths,
        "replicas should have same paths"
    );

    // Compare by filesystem path: for each path, find the UUID on each node and compare
    for path in &source_paths {
        let source_uuid = find_uuid_for_path(source, path);
        let target_uuid = find_uuid_for_path(target, path);

        let source_meta = source
            .workspace_crdt
            .get_file(&source_uuid)
            .unwrap_or_else(|| panic!("source metadata missing for {path}"));
        let target_meta = target
            .workspace_crdt
            .get_file(&target_uuid)
            .unwrap_or_else(|| panic!("target metadata missing for {path}"));
        assert_metadata_matches(path, &source_meta, &target_meta);

        let source_body = source
            .body_docs
            .get(&source_uuid)
            .unwrap_or_else(|| panic!("source body doc missing for {path}"))
            .get_body();
        let target_body = target
            .body_docs
            .get(&target_uuid)
            .unwrap_or_else(|| panic!("target body doc missing for {path}"))
            .get_body();
        assert_eq!(source_body, target_body, "body mismatch for {path}");
    }
}

fn find_uuid_for_path(node: &TestNode, path: &str) -> String {
    // Check CrdtFs path cache
    if let Some((uuid, _)) = node
        .fs
        .inner()
        .uuid_to_path_snapshot()
        .into_iter()
        .find(|(_, p)| p == path)
    {
        return uuid;
    }
    // Check CRDT tree traversal
    if let Some(uuid) = node.workspace_crdt.find_by_path(std::path::Path::new(path)) {
        return uuid;
    }
    // Check workspace hierarchy path (handles remote sync entries)
    for (key, _) in node.workspace_crdt.list_files() {
        if let Some(p) = node.workspace_crdt.get_path(&key) {
            if normalize_sync_path(&p.to_string_lossy()) == path {
                return key;
            }
        }
    }
    path.to_string()
}

fn sync_source_to_target(source: &TestNode, target: &TestNode) {
    let target_state = expect_plugin_binary(
        execute(target, plugin_cmd("GetSyncState", json!({}))),
        "GetSyncState",
    );

    let workspace_update = expect_plugin_binary(
        execute(
            source,
            plugin_cmd(
                "GetMissingUpdates",
                json!({ "remote_state_vector": B64.encode(&target_state) }),
            ),
        ),
        "GetMissingUpdates",
    );

    if !workspace_update.is_empty() {
        let response = execute(
            target,
            plugin_cmd(
                "ApplyRemoteWorkspaceUpdateWithEffects",
                json!({
                    "update": B64.encode(&workspace_update),
                    "write_to_disk": true,
                }),
            ),
        );
        match response {
            Response::PluginResult(ref json) => {
                assert!(
                    json.get("update_id").is_some(),
                    "expected update_id in response for ApplyRemoteWorkspaceUpdateWithEffects"
                );
            }
            other => panic!(
                "expected PluginResult for ApplyRemoteWorkspaceUpdateWithEffects, got {other:?}"
            ),
        }
    }

    // Body docs use UUID keys, so we need the actual doc_ids here
    let mut active_doc_ids = source
        .workspace_crdt
        .list_active_files()
        .into_iter()
        .map(|(key, _)| key)
        .collect::<Vec<_>>();
    active_doc_ids.sort();

    for path in active_doc_ids {
        let body_state = expect_plugin_binary(
            execute(
                source,
                plugin_cmd("GetBodyFullState", json!({ "doc_name": path })),
            ),
            "GetBodyFullState",
        );

        if body_state.is_empty() {
            continue;
        }

        let response = execute(
            target,
            plugin_cmd(
                "ApplyRemoteBodyUpdateWithEffects",
                json!({
                    "doc_name": path,
                    "update": B64.encode(&body_state),
                    "write_to_disk": true,
                }),
            ),
        );
        match response {
            Response::PluginResult(ref json) => {
                assert!(
                    json.get("update_id").is_some(),
                    "expected update_id in response for ApplyRemoteBodyUpdateWithEffects"
                );
            }
            other => {
                panic!("expected PluginResult for ApplyRemoteBodyUpdateWithEffects, got {other:?}")
            }
        }
    }
}

#[test]
fn local_file_ops_keep_disk_and_crdt_in_sync() {
    let node = TestNode::new();

    block_on(async {
        node.fs
            .write_file(
                Path::new("README.md"),
                "---\ntitle: Root\nupdated: 1700000000000\n---\n\nRoot body",
            )
            .await
            .unwrap();
        node.fs
            .write_file(
                Path::new("notes/today.md"),
                "---\ntitle: Today\npart_of: ../README.md\nupdated: 1700000001000\n---\n\nToday body",
            )
            .await
            .unwrap();
        node.fs
            .write_file(
                Path::new("notes/keep.md"),
                "---\ntitle: Keep\npart_of: ../README.md\nupdated: 1700000002000\n---\n\nKeep body",
            )
            .await
            .unwrap();
    });
    assert_disk_matches_crdt(&node);

    block_on(async {
        node.fs
            .move_file(Path::new("notes/today.md"), Path::new("archive/today.md"))
            .await
            .unwrap();
        node.fs
            .write_file(
                Path::new("archive/today.md"),
                "---\ntitle: Today (Moved)\npart_of: ../README.md\nupdated: 1700000003000\n---\n\nUpdated body after move",
            )
            .await
            .unwrap();
        node.fs
            .delete_file(Path::new("notes/keep.md"))
            .await
            .unwrap();
    });

    assert_disk_matches_crdt(&node);
    assert!(
        !node.base_fs.inner().exists(Path::new("notes/today.md")),
        "old path should not exist on disk after move"
    );
    // After move, notes/today.md should no longer be an active entry
    // (it was moved to archive/today.md with a stable UUID, not tombstoned)
    let active_paths = active_crdt_paths(&node);
    assert!(
        !active_paths.contains("notes/today.md"),
        "old path should not be active in CRDT after move"
    );
    // After delete, notes/keep.md should be tombstoned
    let keep_uuid = find_uuid_for_path(&node, "notes/keep.md");
    assert!(
        node.workspace_crdt
            .get_file(&keep_uuid)
            .map(|m| m.deleted)
            .unwrap_or(false),
        "deleted file should be tombstoned in CRDT"
    );
}

#[test]
fn remote_sync_with_effects_preserves_disk_crdt_parity() {
    let source = TestNode::new();
    let target = TestNode::new();

    block_on(async {
        source
            .fs
            .write_file(
                Path::new("README.md"),
                "---\ntitle: Root\nupdated: 1700000100000\n---\n\nShared root",
            )
            .await
            .unwrap();
        source
            .fs
            .write_file(
                Path::new("notes/today.md"),
                "---\ntitle: Today\npart_of: ../README.md\nupdated: 1700000101000\n---\n\nSource body",
            )
            .await
            .unwrap();
        source
            .fs
            .write_file(
                Path::new("notes/keep.md"),
                "---\ntitle: Keep\npart_of: ../README.md\nupdated: 1700000102000\n---\n\nKeep me",
            )
            .await
            .unwrap();
    });

    sync_source_to_target(&source, &target);
    assert_disk_matches_crdt(&source);
    assert_disk_matches_crdt(&target);
    assert_replicas_match(&source, &target);

    block_on(async {
        source
            .fs
            .move_file(Path::new("notes/today.md"), Path::new("archive/today.md"))
            .await
            .unwrap();
        source
            .fs
            .write_file(
                Path::new("archive/today.md"),
                "---\ntitle: Today (Renamed)\npart_of: ../README.md\nupdated: 1700000103000\n---\n\nRenamed body",
            )
            .await
            .unwrap();
        source
            .fs
            .delete_file(Path::new("notes/keep.md"))
            .await
            .unwrap();
    });

    sync_source_to_target(&source, &target);
    assert_disk_matches_crdt(&source);
    assert_disk_matches_crdt(&target);
    assert_replicas_match(&source, &target);

    assert!(
        !target.base_fs.inner().exists(Path::new("notes/today.md")),
        "target should not retain old renamed path"
    );
    assert!(
        !target.base_fs.inner().exists(Path::new("notes/keep.md")),
        "target should delete files removed from source"
    );
    assert!(
        target.base_fs.inner().exists(Path::new("archive/today.md")),
        "target should contain renamed path"
    );
}
