//! L10 tests for [`MoveTabToNewWindow`] with a fake [`ProjectStore`]: the tab is
//! detached and the workspace is persisted (load returns the new state).

use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use domain::ids::{ProjectId, TabId, WindowId};
use domain::layout::{
    LayoutNode, LayoutTree, LeafCell, PersistedPluginLayoutWindow, PersistedWindowKind,
    PersistedWindowState, Tab, Window, WindowStateSnapshot, Workspace,
};
use domain::ports::{
    IdGenerator, PluginManifestBytes, PluginPackageStore, PluginRegistryError, PluginRegistryStore,
    PluginStoreError, ProjectStore, StoreError, WindowStateStore,
};
use domain::project::{Project, ProjectPath};
use domain::{
    ContentHash, LocalPath, NodeId, PluginBundleUrl, PluginId, PluginInstallSource,
    PluginLayoutType, PluginLifecycleState, PluginPackageRef, PluginRegistry, PluginRegistryEntry,
    RelativePath, RemoteRef, RemovalOutcome, StagedPluginPackage,
};
use uuid::Uuid;

use application::plugin::JsonPluginManifestValidator;
use application::{
    MoveTabToNewWindow, MoveTabToNewWindowInput, OpenPluginLayoutWindow,
    OpenPluginLayoutWindowInput, RestoreOpenWindows, SnapshotOpenWindows, SnapshotOpenWindowsInput,
};

/// A `ProjectStore` fake that only implements the workspace persistence the use
/// case needs (the project methods are never called here).
#[derive(Clone)]
struct FakeStore(Arc<Mutex<Workspace>>);
#[async_trait]
impl ProjectStore for FakeStore {
    async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
        unreachable!()
    }
    async fn load_project(&self, _id: ProjectId) -> Result<Project, StoreError> {
        unreachable!()
    }
    async fn save_project(&self, _p: &Project) -> Result<(), StoreError> {
        unreachable!()
    }
    async fn save_workspace(&self, ws: &Workspace) -> Result<(), StoreError> {
        *self.0.lock().unwrap() = ws.clone();
        Ok(())
    }
    async fn load_workspace(&self) -> Result<Workspace, StoreError> {
        Ok(self.0.lock().unwrap().clone())
    }
}

struct SeqIds(Mutex<u128>);
impl IdGenerator for SeqIds {
    fn new_uuid(&self) -> Uuid {
        let mut n = self.0.lock().unwrap();
        let id = Uuid::from_u128(*n);
        *n += 1;
        id
    }
}

#[derive(Clone, Default)]
struct FakeWindowStateStore(Arc<Mutex<WindowStateSnapshot>>);

#[async_trait]
impl WindowStateStore for FakeWindowStateStore {
    async fn save_window_state(&self, snapshot: &WindowStateSnapshot) -> Result<(), StoreError> {
        *self.0.lock().unwrap() = snapshot.clone();
        Ok(())
    }

    async fn load_window_state(&self) -> Result<WindowStateSnapshot, StoreError> {
        Ok(self.0.lock().unwrap().clone())
    }
}

fn plugin_manifest() -> Vec<u8> {
    br#"{
      "ideaPluginManifestVersion": 1,
      "id": "dev.idea.android-plugin",
      "displayName": "Android",
      "version": "1.0.0",
      "engines": {"idea": ">=0.1.0 <1.0.0"},
      "main": "dist/index.js",
      "trustLevel": "full",
      "capabilities": ["ui"],
      "contributes": {
        "layouts": [{"type":"idea-android.health","label":"Android Health","component":"AndroidHealth"}]
      }
    }"#.to_vec()
}

#[derive(Clone)]
struct FakePluginPackages {
    manifest: Arc<Mutex<Vec<u8>>>,
}

impl FakePluginPackages {
    fn new(manifest: Vec<u8>) -> Self {
        Self {
            manifest: Arc::new(Mutex::new(manifest)),
        }
    }
}

#[async_trait]
impl PluginPackageStore for FakePluginPackages {
    async fn list_installed(&self) -> Result<Vec<PluginPackageRef>, PluginStoreError> {
        Ok(Vec::new())
    }

    async fn read_manifest(
        &self,
        _package: &PluginPackageRef,
    ) -> Result<PluginManifestBytes, PluginStoreError> {
        Ok(PluginManifestBytes {
            bytes: self.manifest.lock().unwrap().clone(),
        })
    }

    async fn install_from_archive(
        &self,
        _archive: &LocalPath,
    ) -> Result<StagedPluginPackage, PluginStoreError> {
        Err(PluginStoreError::Invalid("unused".to_owned()))
    }

    async fn install_from_directory(
        &self,
        _dir: &LocalPath,
    ) -> Result<StagedPluginPackage, PluginStoreError> {
        Err(PluginStoreError::Invalid("unused".to_owned()))
    }

    async fn commit_install(
        &self,
        staged: StagedPluginPackage,
        plugin_id: &PluginId,
    ) -> Result<PluginPackageRef, PluginStoreError> {
        Ok(PluginPackageRef {
            plugin_id: Some(plugin_id.clone()),
            root: staged.root,
        })
    }

    async fn remove_package(
        &self,
        _plugin_id: &PluginId,
    ) -> Result<RemovalOutcome, PluginStoreError> {
        Ok(RemovalOutcome::NotFound)
    }

    fn bundle_url(
        &self,
        plugin_id: &PluginId,
        entry: &RelativePath,
        hash: &ContentHash,
    ) -> Result<PluginBundleUrl, PluginStoreError> {
        Ok(PluginBundleUrl::new(format!(
            "idea-plugin://{}/current/{}/{}",
            plugin_id.as_str(),
            hash.as_str(),
            entry.as_str()
        )))
    }
}

#[derive(Clone)]
struct FakePluginRegistry {
    registry: Arc<Mutex<PluginRegistry>>,
}

impl FakePluginRegistry {
    fn with_state(lifecycle_state: PluginLifecycleState) -> Self {
        Self {
            registry: Arc::new(Mutex::new(PluginRegistry {
                version: 1,
                plugins: vec![PluginRegistryEntry {
                    id: PluginId::new("dev.idea.android-plugin").unwrap(),
                    lifecycle_state,
                    source: PluginInstallSource::Directory {
                        path_label: "/plugin".to_owned(),
                    },
                    content_hash: ContentHash::new("abc123").unwrap(),
                    restart_required: false,
                    error: None,
                }],
            })),
        }
    }
}

#[async_trait]
impl PluginRegistryStore for FakePluginRegistry {
    async fn load_registry(&self) -> Result<PluginRegistry, PluginRegistryError> {
        Ok(self.registry.lock().unwrap().clone())
    }

    async fn save_registry(&self, registry: &PluginRegistry) -> Result<(), PluginRegistryError> {
        *self.registry.lock().unwrap() = registry.clone();
        Ok(())
    }
}

#[derive(Clone)]
struct FakeProjectRegistry {
    existing: Arc<Mutex<Vec<ProjectId>>>,
}

impl FakeProjectRegistry {
    fn new(existing: Vec<ProjectId>) -> Self {
        Self {
            existing: Arc::new(Mutex::new(existing)),
        }
    }
}

#[async_trait]
impl ProjectStore for FakeProjectRegistry {
    async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
        unreachable!()
    }

    async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
        if !self.existing.lock().unwrap().contains(&id) {
            return Err(StoreError::NotFound);
        }

        Ok(Project {
            id,
            name: format!("project-{id}"),
            root: ProjectPath::new(format!("/tmp/{id}")).unwrap(),
            remote: RemoteRef::Local,
            created_at: 0,
        })
    }

    async fn save_project(&self, _p: &Project) -> Result<(), StoreError> {
        unreachable!()
    }

    async fn save_workspace(&self, _ws: &Workspace) -> Result<(), StoreError> {
        unreachable!()
    }

    async fn load_workspace(&self) -> Result<Workspace, StoreError> {
        unreachable!()
    }
}

fn tid(n: u128) -> TabId {
    TabId::from_uuid(Uuid::from_u128(n))
}
fn wid(n: u128) -> WindowId {
    WindowId::from_uuid(Uuid::from_u128(n))
}
fn tab(n: u128) -> Tab {
    Tab {
        id: tid(n),
        project_id: ProjectId::from_uuid(Uuid::from_u128(1000 + n)),
        layout: LayoutTree::new(LayoutNode::Leaf(LeafCell {
            id: NodeId::from_uuid(Uuid::from_u128(900 + n)),
            session: None,
            agent: None,
            conversation_id: None,
            engine_session_id: None,
            agent_was_running: false,
        })),
    }
}

fn seeded() -> FakeStore {
    let ws = Workspace {
        windows: vec![Window::new(wid(1), vec![tab(1), tab(2)], tid(1)).unwrap()],
    };
    FakeStore(Arc::new(Mutex::new(ws)))
}

fn persisted_main() -> PersistedWindowState {
    PersistedWindowState {
        label: "main".to_owned(),
        kind: PersistedWindowKind::Main,
        panel: None,
        project_id: None,
        plugin_layout: None,
        url: None,
        visible: true,
        maximized: false,
        fullscreen: false,
        outer_position: None,
        outer_size: None,
        monitor: None,
        last_focused_at: None,
    }
}

fn persisted_view(label: &str, project_id: ProjectId) -> PersistedWindowState {
    PersistedWindowState {
        label: label.to_owned(),
        kind: PersistedWindowKind::View,
        panel: Some("tickets".to_owned()),
        project_id: Some(project_id),
        plugin_layout: None,
        url: Some(format!("index.html?panel=tickets&project={project_id}")),
        visible: true,
        maximized: false,
        fullscreen: false,
        outer_position: None,
        outer_size: None,
        monitor: None,
        last_focused_at: None,
    }
}

fn persisted_plugin_layout(
    label: &str,
    plugin_id: &str,
    layout_type: &str,
) -> PersistedWindowState {
    PersistedWindowState {
        label: label.to_owned(),
        kind: PersistedWindowKind::PluginLayout,
        panel: None,
        project_id: None,
        plugin_layout: Some(PersistedPluginLayoutWindow {
            plugin_id: PluginId::new(plugin_id).unwrap(),
            layout_type: PluginLayoutType::new(layout_type).unwrap(),
            state: serde_json::json!({ "from": "test" }),
        }),
        url: Some(format!(
            "index.html?pluginLayout=1&pluginId={plugin_id}&layoutType={layout_type}"
        )),
        visible: true,
        maximized: false,
        fullscreen: false,
        outer_position: None,
        outer_size: None,
        monitor: None,
        last_focused_at: None,
    }
}

fn restore_uc(store: FakeWindowStateStore, registry: FakePluginRegistry) -> RestoreOpenWindows {
    RestoreOpenWindows::new(
        Arc::new(store),
        Arc::new(FakeProjectRegistry::new(vec![])),
        Arc::new(FakePluginPackages::new(plugin_manifest())),
        Arc::new(registry),
        Arc::new(JsonPluginManifestValidator::new("0.3.0")),
    )
}

fn open_plugin_layout_uc(registry: FakePluginRegistry) -> OpenPluginLayoutWindow {
    OpenPluginLayoutWindow::new(
        Arc::new(FakePluginPackages::new(plugin_manifest())),
        Arc::new(registry),
        Arc::new(JsonPluginManifestValidator::new("0.3.0")),
    )
}

#[tokio::test]
async fn detaches_tab_and_persists_workspace() {
    let store = seeded();
    // The id generator's first uuid (from_u128(7)) becomes the new window id.
    let ids = Arc::new(SeqIds(Mutex::new(7)));
    let uc = MoveTabToNewWindow::new(Arc::new(store.clone()), ids);

    let out = uc
        .execute(MoveTabToNewWindowInput { tab_id: tid(1) })
        .await
        .unwrap();

    assert_eq!(out.new_window_id, WindowId::from_uuid(Uuid::from_u128(7)));
    assert_eq!(out.workspace.windows.len(), 2);

    // Persisted: reloading the store yields the detached layout.
    let reloaded = store.load_workspace().await.unwrap();
    assert_eq!(reloaded, out.workspace);
    let detached = reloaded
        .windows
        .iter()
        .find(|w| w.id == out.new_window_id)
        .unwrap();
    assert_eq!(detached.tabs.len(), 1);
    assert_eq!(detached.tabs[0].id, tid(1));
}

#[tokio::test]
async fn unknown_tab_is_not_found() {
    let store = seeded();
    let uc = MoveTabToNewWindow::new(Arc::new(store), Arc::new(SeqIds(Mutex::new(7))));
    let err = uc
        .execute(MoveTabToNewWindowInput { tab_id: tid(404) })
        .await
        .unwrap_err();
    assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}

#[tokio::test]
async fn snapshot_open_windows_persists_the_supplied_snapshot() {
    let store = FakeWindowStateStore::default();
    let uc = SnapshotOpenWindows::new(Arc::new(store.clone()));
    let windows = vec![persisted_main()];

    uc.execute(SnapshotOpenWindowsInput {
        windows: windows.clone(),
    })
    .await
    .unwrap();

    let saved = store.load_window_state().await.unwrap();
    assert_eq!(saved.version, domain::WINDOW_STATE_SNAPSHOT_VERSION);
    assert_eq!(saved.windows, windows);
}

#[tokio::test]
async fn restore_open_windows_keeps_panel_views_without_reopening_projects() {
    let existing = ProjectId::from_uuid(Uuid::from_u128(42));
    let missing = ProjectId::from_uuid(Uuid::from_u128(43));
    let valid_label = "view-tickets-0000000000000000000000000000002a";
    let mut url_missing = persisted_view("view-tickets-0000000000000000000000000000002c", existing);
    url_missing.url = None;
    let mut no_panel = persisted_view("view-tickets-0000000000000000000000000000002d", existing);
    no_panel.panel = None;

    let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![
        persisted_main(),
        persisted_main(),
        persisted_view(valid_label, existing),
        persisted_view(valid_label, existing),
        persisted_view("view-tickets-0000000000000000000000000000002b", missing),
        url_missing,
        no_panel,
    ]))));
    let uc = restore_uc(
        store,
        FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
    );

    let out = uc.execute().await.unwrap();

    assert_eq!(
        out.windows
            .iter()
            .map(|w| w.label.as_str())
            .collect::<Vec<_>>(),
        vec![
            "main",
            valid_label,
            "view-tickets-0000000000000000000000000000002b",
            "view-tickets-0000000000000000000000000000002c"
        ]
    );
}

#[tokio::test]
async fn open_plugin_layout_window_validates_active_runtime_contribution() {
    let uc = open_plugin_layout_uc(FakePluginRegistry::with_state(
        PluginLifecycleState::Enabled,
    ));

    let out = uc
        .execute(OpenPluginLayoutWindowInput {
            plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
            layout_type: PluginLayoutType::new("idea-android.health").unwrap(),
            state: serde_json::json!({ "deviceId": "pixel-8" }),
        })
        .await
        .unwrap();

    assert_eq!(out.contribution.provider_plugin_display_name, "Android");
    assert_eq!(out.contribution.label, "Android Health");
    assert_eq!(out.surface.plugin_id.as_str(), "dev.idea.android-plugin");
    assert_eq!(out.surface.layout_type.as_str(), "idea-android.health");
    assert_eq!(out.surface.state["deviceId"], "pixel-8");
}

#[tokio::test]
async fn open_plugin_layout_window_rejects_inactive_plugin() {
    let uc = open_plugin_layout_uc(FakePluginRegistry::with_state(
        PluginLifecycleState::Disabled,
    ));

    let err = uc
        .execute(OpenPluginLayoutWindowInput {
            plugin_id: PluginId::new("dev.idea.android-plugin").unwrap(),
            layout_type: PluginLayoutType::new("idea-android.health").unwrap(),
            state: serde_json::Value::Null,
        })
        .await
        .unwrap_err();

    assert_eq!(err.code(), "INVALID", "got {err:?}");
}

#[tokio::test]
async fn restore_open_windows_keeps_valid_plugin_layout_windows_only() {
    let mut missing_surface = persisted_plugin_layout(
        "view-plugin-layout-missing",
        "dev.idea.android-plugin",
        "idea-android.health",
    );
    missing_surface.plugin_layout = None;
    let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![
        persisted_plugin_layout(
            "view-plugin-layout-valid",
            "dev.idea.android-plugin",
            "idea-android.health",
        ),
        persisted_plugin_layout(
            "view-plugin-layout-unknown-layout",
            "dev.idea.android-plugin",
            "idea-android.missing",
        ),
        missing_surface,
    ]))));
    let uc = restore_uc(
        store,
        FakePluginRegistry::with_state(PluginLifecycleState::Enabled),
    );

    let out = uc.execute().await.unwrap();

    assert_eq!(
        out.windows
            .iter()
            .map(|w| w.label.as_str())
            .collect::<Vec<_>>(),
        vec!["view-plugin-layout-valid"]
    );
}
