//! L2 tests for the project life-cycle use cases (`CreateProject`,
//! `OpenProject`, `ListProjects`, `CloseProject`/`CloseTab`).
//!
//! Every port is faked in-memory so the use cases are exercised without any I/O:
//! - [`FakeFs`] — a `Mutex<HashMap<String, Vec<u8>>>` filesystem that records
//!   directories and file contents,
//! - [`FakeStore`] — an in-memory `ProjectStore` (registry + workspace),
//! - [`SpyBus`] — records published [`DomainEvent`]s,
//! - [`SeqIds`] / [`FixedClock`] — deterministic id/time.

use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use domain::events::DomainEvent;
use domain::layout::Workspace;
use domain::ports::{
    Clock, DirEntry, EventBus, EventStream, FileSystem, FsError, IdGenerator, ProjectStore,
    RemotePath, StoreError,
};
use domain::{Project, ProjectId, ProjectPath, RemoteRef};

use application::{
    CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput,
    ListProjects, OpenProject, OpenProjectInput, ReadProjectContext, ReadProjectContextInput,
    UpdateProjectContext, UpdateProjectContextInput,
};

// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------

#[derive(Default)]
struct FakeFsInner {
    files: HashMap<String, Vec<u8>>,
    dirs: HashSet<String>,
}

/// An in-memory [`FileSystem`] recording writes and created directories.
#[derive(Default, Clone)]
struct FakeFs(Arc<Mutex<FakeFsInner>>);

impl FakeFs {
    fn read_file(&self, path: &str) -> Option<Vec<u8>> {
        self.0.lock().unwrap().files.get(path).cloned()
    }
    fn has_dir(&self, path: &str) -> bool {
        self.0.lock().unwrap().dirs.contains(path)
    }
}

#[async_trait]
impl FileSystem for FakeFs {
    async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
        self.0
            .lock()
            .unwrap()
            .files
            .get(path.as_str())
            .cloned()
            .ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
    }

    async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
        self.0
            .lock()
            .unwrap()
            .files
            .insert(path.as_str().to_owned(), data.to_vec());
        Ok(())
    }

    async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
        let inner = self.0.lock().unwrap();
        Ok(inner.files.contains_key(path.as_str()) || inner.dirs.contains(path.as_str()))
    }

    async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
        self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
        Ok(())
    }

    async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
        Ok(Vec::new())
    }

    async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
        Ok(())
    }
}

#[derive(Default)]
struct FakeStoreInner {
    projects: Vec<Project>,
    workspace: Option<Workspace>,
}

/// An in-memory [`ProjectStore`].
#[derive(Default, Clone)]
struct FakeStore(Arc<Mutex<FakeStoreInner>>);

impl FakeStore {
    fn saved_workspace(&self) -> Option<Workspace> {
        self.0.lock().unwrap().workspace.clone()
    }
}

#[async_trait]
impl ProjectStore for FakeStore {
    async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
        Ok(self.0.lock().unwrap().projects.clone())
    }

    async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
        self.0
            .lock()
            .unwrap()
            .projects
            .iter()
            .find(|p| p.id == id)
            .cloned()
            .ok_or(StoreError::NotFound)
    }

    async fn save_project(&self, project: &Project) -> Result<(), StoreError> {
        let mut inner = self.0.lock().unwrap();
        if let Some(slot) = inner.projects.iter_mut().find(|p| p.id == project.id) {
            *slot = project.clone();
        } else {
            inner.projects.push(project.clone());
        }
        Ok(())
    }

    async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> {
        self.0.lock().unwrap().workspace = Some(workspace.clone());
        Ok(())
    }

    async fn load_workspace(&self) -> Result<Workspace, StoreError> {
        Ok(self.0.lock().unwrap().workspace.clone().unwrap_or_default())
    }
}

/// A [`ProjectStore`] whose registry read always fails — used to assert the
/// `Store` error code propagates.
#[derive(Default, Clone)]
struct BrokenStore;

#[async_trait]
impl ProjectStore for BrokenStore {
    async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
        Err(StoreError::Io("boom".into()))
    }
    async fn load_project(&self, _id: ProjectId) -> Result<Project, StoreError> {
        Err(StoreError::Io("boom".into()))
    }
    async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
        Err(StoreError::Io("boom".into()))
    }
    async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
        Err(StoreError::Io("boom".into()))
    }
    async fn load_workspace(&self) -> Result<Workspace, StoreError> {
        Err(StoreError::Io("boom".into()))
    }
}

/// Records published events.
#[derive(Default, Clone)]
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);

impl SpyBus {
    fn events(&self) -> Vec<DomainEvent> {
        self.0.lock().unwrap().clone()
    }
}

impl EventBus for SpyBus {
    fn publish(&self, event: DomainEvent) {
        self.0.lock().unwrap().push(event);
    }
    fn subscribe(&self) -> EventStream {
        Box::new(std::iter::empty())
    }
}

/// Deterministic ids: nil-based UUIDs derived from a counter.
struct SeqIds(Mutex<u128>);
impl SeqIds {
    fn new() -> Self {
        Self(Mutex::new(1))
    }
}
impl IdGenerator for SeqIds {
    fn new_uuid(&self) -> uuid::Uuid {
        let mut n = self.0.lock().unwrap();
        let v = *n;
        *n += 1;
        uuid::Uuid::from_u128(v)
    }
}

struct FixedClock(i64);
impl Clock for FixedClock {
    fn now_millis(&self) -> i64 {
        self.0
    }
}

// ---------------------------------------------------------------------------
// Wiring helpers
// ---------------------------------------------------------------------------

struct Env {
    store: FakeStore,
    fs: FakeFs,
    bus: SpyBus,
    create: CreateProject,
    open: OpenProject,
    read_context: ReadProjectContext,
    update_context: UpdateProjectContext,
}

fn env() -> Env {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let bus = SpyBus::default();
    let ids: Arc<dyn IdGenerator> = Arc::new(SeqIds::new());
    let clock: Arc<dyn Clock> = Arc::new(FixedClock(1_700_000_000_000));

    let create = CreateProject::new(
        Arc::new(store.clone()),
        Arc::new(fs.clone()),
        ids,
        clock,
        Arc::new(bus.clone()),
    );
    let open = OpenProject::new(Arc::new(store.clone()), Arc::new(fs.clone()));
    let read_context = ReadProjectContext::new(Arc::new(fs.clone()));
    let update_context = UpdateProjectContext::new(Arc::new(fs.clone()));

    Env {
        store,
        fs,
        bus,
        create,
        open,
        read_context,
        update_context,
    }
}

fn input(name: &str, root: &str) -> CreateProjectInput {
    CreateProjectInput {
        name: name.to_owned(),
        root: root.to_owned(),
        remote: None,
        default_profile_id: None,
    }
}

// ---------------------------------------------------------------------------
// CreateProject
// ---------------------------------------------------------------------------

#[tokio::test]
async fn create_inits_ideai_dir_and_writes_camelcase_project_json() {
    let env = env();
    let out = env
        .create
        .execute(input("Demo", "/home/me/proj"))
        .await
        .expect("creation succeeds");

    // .ideai/ created.
    assert!(env.fs.has_dir("/home/me/proj/.ideai"), "dir created");

    // project.json written with camelCase fields and no `root`.
    let bytes = env
        .fs
        .read_file("/home/me/proj/.ideai/project.json")
        .expect("project.json written");
    let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();

    assert_eq!(json["version"], 1);
    assert_eq!(json["name"], "Demo");
    assert_eq!(json["id"], out.project.id.to_string());
    assert_eq!(json["createdAt"], 1_700_000_000_000i64);
    assert_eq!(json["remote"]["kind"], "local");
    assert!(json.get("root").is_none(), "root must NOT be stored");
    // default_profile_id omitted when None (skip_serializing_if).
    assert!(json.get("defaultProfileId").is_none());
}

#[tokio::test]
async fn create_registers_project_in_store() {
    let env = env();
    let out = env.create.execute(input("Demo", "/p")).await.unwrap();

    let stored = env.store.list_projects().await.unwrap();
    assert_eq!(stored.len(), 1);
    assert_eq!(stored[0].id, out.project.id);
    assert_eq!(stored[0].name, "Demo");
}

#[tokio::test]
async fn project_context_lives_under_ideai_and_missing_reads_empty() {
    let env = env();
    let out = env.create.execute(input("Demo", "/p")).await.unwrap();

    let missing = env
        .read_context
        .execute(ReadProjectContextInput {
            project: out.project.clone(),
        })
        .await
        .unwrap();
    assert_eq!(missing.content, "");

    env.update_context
        .execute(UpdateProjectContextInput {
            project: out.project.clone(),
            content: "# Shared\n\nUse pnpm.".to_owned(),
        })
        .await
        .unwrap();

    let bytes = env
        .fs
        .read_file("/p/.ideai/CONTEXT.md")
        .expect("project context written under .ideai");
    assert_eq!(String::from_utf8(bytes).unwrap(), "# Shared\n\nUse pnpm.");

    let read_back = env
        .read_context
        .execute(ReadProjectContextInput {
            project: out.project,
        })
        .await
        .unwrap();
    assert_eq!(read_back.content, "# Shared\n\nUse pnpm.");
}

#[tokio::test]
async fn create_publishes_project_created_event() {
    let env = env();
    let out = env.create.execute(input("Demo", "/p")).await.unwrap();

    assert_eq!(
        env.bus.events(),
        vec![DomainEvent::ProjectCreated {
            project_id: out.project.id
        }]
    );
}

#[tokio::test]
async fn create_rejects_duplicate_remote_root() {
    let env = env();
    env.create.execute(input("A", "/same")).await.unwrap();

    let err = env
        .create
        .execute(input("B", "/same"))
        .await
        .expect_err("duplicate (remote, root) rejected");

    assert_eq!(err.code(), "INVALID", "got {err:?}");
    // Only the first project remains registered.
    assert_eq!(env.store.list_projects().await.unwrap().len(), 1);
}

#[tokio::test]
async fn create_allows_same_root_on_different_remote() {
    let env = env();
    env.create.execute(input("Local", "/shared")).await.unwrap();

    let remote_input = CreateProjectInput {
        remote: Some(RemoteRef::Wsl {
            distro: "Ubuntu".to_owned(),
        }),
        ..input("Wsl", "/shared")
    };
    env.create
        .execute(remote_input)
        .await
        .expect("same root, different remote is allowed");

    assert_eq!(env.store.list_projects().await.unwrap().len(), 2);
}

#[tokio::test]
async fn create_rejects_non_absolute_root() {
    let env = env();
    let err = env
        .create
        .execute(input("X", "relative/path"))
        .await
        .expect_err("non-absolute root rejected");
    assert_eq!(err.code(), "INVALID", "got {err:?}");
}

#[tokio::test]
async fn create_rejects_empty_name() {
    let env = env();
    let err = env
        .create
        .execute(input("", "/abs"))
        .await
        .expect_err("empty name rejected");
    assert_eq!(err.code(), "INVALID", "got {err:?}");
}

#[tokio::test]
async fn create_propagates_store_error_code() {
    let ids: Arc<dyn IdGenerator> = Arc::new(SeqIds::new());
    let clock: Arc<dyn Clock> = Arc::new(FixedClock(0));
    let create = CreateProject::new(
        Arc::new(BrokenStore),
        Arc::new(FakeFs::default()),
        ids,
        clock,
        Arc::new(SpyBus::default()),
    );
    let err = create
        .execute(input("X", "/abs"))
        .await
        .expect_err("store failure surfaces");
    assert_eq!(err.code(), "STORE", "got {err:?}");
}

// ---------------------------------------------------------------------------
// OpenProject — tolerant reads
// ---------------------------------------------------------------------------

#[tokio::test]
async fn open_loads_project_and_meta() {
    let env = env();
    let created = env.create.execute(input("Demo", "/o/proj")).await.unwrap();

    let out = env
        .open
        .execute(OpenProjectInput {
            project_id: created.project.id,
        })
        .await
        .expect("open succeeds");

    assert_eq!(out.project.id, created.project.id);
    assert_eq!(out.project.root, created.project.root);
    let meta = out.meta.expect("meta present (project.json was written)");
    assert_eq!(meta.id, created.project.id);
    assert_eq!(meta.name, "Demo");
    // No agents.json was written → manifest tolerantly None.
    assert!(out.manifest.is_none(), "agents.json absent → None");
}

#[tokio::test]
async fn open_unknown_project_is_not_found() {
    let env = env();
    let err = env
        .open
        .execute(OpenProjectInput {
            project_id: ProjectId::from_uuid(uuid::Uuid::from_u128(999)),
        })
        .await
        .expect_err("unknown id");
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}

#[tokio::test]
async fn open_tolerates_missing_meta_file() {
    // Register a project in the store WITHOUT writing any .ideai/ files.
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let id = ProjectId::from_uuid(uuid::Uuid::from_u128(7));
    let project = Project::new(
        id,
        "Orphan",
        ProjectPath::new("/no/ideai").unwrap(),
        RemoteRef::Local,
        0,
    )
    .unwrap();
    store.save_project(&project).await.unwrap();

    let open = OpenProject::new(Arc::new(store), Arc::new(fs));
    let out = open
        .execute(OpenProjectInput { project_id: id })
        .await
        .expect("open does not fail on missing meta");
    assert!(out.meta.is_none(), "missing project.json → None");
    assert!(out.manifest.is_none(), "missing agents.json → None");
}

#[tokio::test]
async fn open_tolerates_corrupt_json() {
    let store = FakeStore::default();
    let fs = FakeFs::default();
    let id = ProjectId::from_uuid(uuid::Uuid::from_u128(8));
    let project = Project::new(
        id,
        "Corrupt",
        ProjectPath::new("/c/proj").unwrap(),
        RemoteRef::Local,
        0,
    )
    .unwrap();
    store.save_project(&project).await.unwrap();
    // Write garbage at both .ideai/ paths.
    fs.write(
        &RemotePath::new("/c/proj/.ideai/project.json"),
        b"{ not json ]",
    )
    .await
    .unwrap();
    fs.write(
        &RemotePath::new("/c/proj/.ideai/agents.json"),
        b"<<<broken>>>",
    )
    .await
    .unwrap();

    let open = OpenProject::new(Arc::new(store), Arc::new(fs));
    let out = open
        .execute(OpenProjectInput { project_id: id })
        .await
        .expect("corrupt JSON does not fail the open");
    assert!(out.meta.is_none(), "corrupt project.json → None");
    assert!(out.manifest.is_none(), "corrupt agents.json → None");
}

// ---------------------------------------------------------------------------
// ListProjects
// ---------------------------------------------------------------------------

#[tokio::test]
async fn list_projects_returns_registered() {
    let env = env();
    env.create.execute(input("A", "/a")).await.unwrap();
    env.create.execute(input("B", "/b")).await.unwrap();

    let list = ListProjects::new(Arc::new(env.store.clone()));
    let out = list.execute().await.unwrap();
    let names: Vec<&str> = out.projects.iter().map(|p| p.name.as_str()).collect();
    assert_eq!(out.projects.len(), 2);
    assert!(names.contains(&"A") && names.contains(&"B"));
}

// ---------------------------------------------------------------------------
// CloseProject / CloseTab
// ---------------------------------------------------------------------------

#[tokio::test]
async fn close_persists_workspace() {
    let store = FakeStore::default();
    let close = CloseProject::new(Arc::new(store.clone()));
    let id = ProjectId::from_uuid(uuid::Uuid::from_u128(3));

    let out = close
        .execute(CloseProjectInput {
            project_id: id,
            workspace: Some(Workspace::default()),
        })
        .await
        .unwrap();

    assert_eq!(out.project_id, id);
    assert!(store.saved_workspace().is_some(), "workspace persisted");
}

#[tokio::test]
async fn close_without_workspace_skips_persistence() {
    let store = FakeStore::default();
    let close = CloseProject::new(Arc::new(store.clone()));
    let id = ProjectId::from_uuid(uuid::Uuid::from_u128(4));

    close
        .execute(CloseProjectInput {
            project_id: id,
            workspace: None,
        })
        .await
        .unwrap();

    assert!(
        store.saved_workspace().is_none(),
        "no persistence when None"
    );
}

#[tokio::test]
async fn close_tab_delegates_to_persistence() {
    let store = FakeStore::default();
    let close_tab = CloseTab::new(Arc::new(store.clone()));
    let id = ProjectId::from_uuid(uuid::Uuid::from_u128(5));

    let out = close_tab
        .execute(CloseTabInput {
            project_id: id,
            workspace: Some(Workspace::default()),
        })
        .await
        .unwrap();

    assert_eq!(out.project_id, id);
    assert!(store.saved_workspace().is_some(), "tab close persists too");
}
