//! Plugin Tauri commands and asset protocol.

use std::future::Future;
use std::path::{Path, PathBuf};

use application::{ReviewPluginPackageInput, SetPluginEnabledInput, UninstallPluginInput};
use backend::dto::{
    ErrorDto, PluginAdminDto, PluginConfigDocumentDto, PluginConfigDocumentReadDto,
    PluginConfigDocumentUpdateDto, PluginConfigDocumentWriteResultDto, PluginEventBatchDto,
    PluginEventPollDto, PluginEventSubscribeDto, PluginEventSubscriptionDto,
    PluginEventUnsubscribeDto, PluginInstallResultDto, PluginProjectStructureDto,
    PluginProjectStructureQueryDto, PluginReviewDto, PluginRunCommandDto,
    PluginRuntimeContributionCatalogDto, PluginStorageGetDto, PluginStorageSetDto, PluginTaskDto,
    PluginTaskStatusDto, PluginToolchainDiagnosticDto, PluginToolchainDiagnosticRequestDto,
    PluginUninstallResultDto, PluginWorkspaceBinaryFileDto, PluginWorkspaceDirectoryListingDto,
    PluginWorkspacePathDto, PluginWorkspaceStatDto, PluginWorkspaceTextFileDto,
    PluginWorkspaceWriteBinaryDto, PluginWorkspaceWriteTextDto, ReviewPluginPackageDto,
};
use domain::ports::{PluginManifestValidator, PluginPackageStore, PluginRegistryStore};
use domain::{PluginId, RelativePath};
use http::{header, Response, StatusCode};
use tauri::{AppHandle, Manager, State};

use crate::state::AppState;

const PLUGIN_ASSET_CORS_ORIGIN: &str = "*";
const PLUGIN_ASSET_CORS_METHODS: &str = "GET";

/// Lists installed plugins for the admin surface.
#[tauri::command]
pub async fn plugin_list_plugins(
    state: State<'_, AppState>,
) -> Result<Vec<PluginAdminDto>, ErrorDto> {
    state
        .list_plugins
        .execute()
        .await
        .map(|plugins| plugins.into_iter().map(PluginAdminDto::from).collect())
        .map_err(ErrorDto::from)
}

/// Reviews a local plugin package without committing it.
#[tauri::command]
pub async fn plugin_review_package(
    input: ReviewPluginPackageDto,
    state: State<'_, AppState>,
) -> Result<PluginReviewDto, ErrorDto> {
    let input = match input.source_kind.as_str() {
        "archive" => ReviewPluginPackageInput::Archive { path: input.path },
        "directory" => ReviewPluginPackageInput::Directory { path: input.path },
        other => {
            return Err(ErrorDto::invalid(format!(
                "invalid plugin source kind: {other}"
            )))
        }
    };
    state
        .review_plugin_package
        .execute(input)
        .await
        .map(PluginReviewDto::from)
        .map_err(ErrorDto::from)
}

/// Installs a plugin from a local archive.
#[tauri::command]
pub async fn plugin_install_from_archive(
    path: String,
    state: State<'_, AppState>,
) -> Result<PluginInstallResultDto, ErrorDto> {
    application::diag!("[plugins] install archive start path={path}");
    let result = state
        .install_plugin_from_archive
        .execute(path)
        .await
        .map(PluginInstallResultDto::from)
        .map_err(ErrorDto::from);
    match &result {
        Ok(result) => application::diag!(
            "[plugins] install archive ok plugin={} version={} hash={} lifecycle={:?}",
            result.plugin.id,
            result.plugin.version,
            result.review.content_hash,
            result.plugin.lifecycle_state
        ),
        Err(err) => application::diag!(
            "[plugins] install archive failed code={} message={}",
            err.code,
            err.message
        ),
    }
    result
}

/// Installs a plugin from a local directory snapshot.
#[tauri::command]
pub async fn plugin_install_from_directory(
    path: String,
    state: State<'_, AppState>,
) -> Result<PluginInstallResultDto, ErrorDto> {
    application::diag!("[plugins] install directory start path={path}");
    let result = state
        .install_plugin_from_directory
        .execute(path)
        .await
        .map(PluginInstallResultDto::from)
        .map_err(ErrorDto::from);
    match &result {
        Ok(result) => application::diag!(
            "[plugins] install directory ok plugin={} version={} hash={} lifecycle={:?}",
            result.plugin.id,
            result.plugin.version,
            result.review.content_hash,
            result.plugin.lifecycle_state
        ),
        Err(err) => application::diag!(
            "[plugins] install directory failed code={} message={}",
            err.code,
            err.message
        ),
    }
    result
}

/// Enables or disables a plugin.
#[tauri::command]
pub async fn plugin_set_enabled(
    plugin_id: String,
    enabled: bool,
    state: State<'_, AppState>,
) -> Result<PluginAdminDto, ErrorDto> {
    state
        .set_plugin_enabled
        .execute(SetPluginEnabledInput { plugin_id, enabled })
        .await
        .map(PluginAdminDto::from)
        .map_err(ErrorDto::from)
}

/// Uninstalls a plugin.
#[tauri::command]
pub async fn plugin_uninstall(
    plugin_id: String,
    state: State<'_, AppState>,
) -> Result<PluginUninstallResultDto, ErrorDto> {
    state
        .uninstall_plugin
        .execute(UninstallPluginInput { plugin_id })
        .await
        .map(PluginUninstallResultDto::from)
        .map_err(ErrorDto::from)
}

/// Lists active runtime contributions for frontend bootstrap.
#[tauri::command]
pub async fn plugin_list_runtime_contributions(
    state: State<'_, AppState>,
) -> Result<PluginRuntimeContributionCatalogDto, ErrorDto> {
    state
        .list_plugin_runtime_contributions
        .execute()
        .await
        .map(PluginRuntimeContributionCatalogDto::from)
        .map_err(ErrorDto::from)
}

/// Reads a UTF-8 workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_read_text(
    input: PluginWorkspacePathDto,
    state: State<'_, AppState>,
) -> Result<PluginWorkspaceTextFileDto, ErrorDto> {
    state
        .plugin_workspace_access
        .read_text(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Reads a binary workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_read_binary(
    input: PluginWorkspacePathDto,
    state: State<'_, AppState>,
) -> Result<PluginWorkspaceBinaryFileDto, ErrorDto> {
    state
        .plugin_workspace_access
        .read_binary(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Writes a UTF-8 workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_write_text(
    input: PluginWorkspaceWriteTextDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    state
        .plugin_workspace_access
        .write_text(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Writes a binary workspace file for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_write_binary(
    input: PluginWorkspaceWriteBinaryDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    state
        .plugin_workspace_access
        .write_binary(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Reads a plugin-owned JSON storage value.
#[tauri::command]
pub async fn plugin_storage_get(
    input: PluginStorageGetDto,
    state: State<'_, AppState>,
) -> Result<Option<serde_json::Value>, ErrorDto> {
    state
        .plugin_storage_access
        .get(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Writes a plugin-owned JSON storage value.
#[tauri::command]
pub async fn plugin_storage_set(
    input: PluginStorageSetDto,
    state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
    state
        .plugin_storage_access
        .set(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Deletes a plugin-owned JSON storage value.
#[tauri::command]
pub async fn plugin_storage_delete(
    input: PluginStorageGetDto,
    state: State<'_, AppState>,
) -> Result<bool, ErrorDto> {
    state
        .plugin_storage_access
        .delete(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Lists a workspace directory for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_list_dir(
    input: PluginWorkspacePathDto,
    state: State<'_, AppState>,
) -> Result<PluginWorkspaceDirectoryListingDto, ErrorDto> {
    state
        .plugin_workspace_access
        .list_dir(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Stats a workspace path for the public plugin API.
#[tauri::command]
pub async fn plugin_workspace_stat(
    input: PluginWorkspacePathDto,
    state: State<'_, AppState>,
) -> Result<PluginWorkspaceStatDto, ErrorDto> {
    state
        .plugin_workspace_access
        .stat(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Queries a bounded generic project structure for the public plugin API.
#[tauri::command]
pub async fn plugin_query_project_structure(
    input: PluginProjectStructureQueryDto,
    state: State<'_, AppState>,
) -> Result<PluginProjectStructureDto, ErrorDto> {
    state
        .query_project_structure
        .execute(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Reads a structured configuration document for the public plugin API.
#[tauri::command]
pub async fn plugin_config_read_document(
    input: PluginConfigDocumentReadDto,
    state: State<'_, AppState>,
) -> Result<PluginConfigDocumentDto, ErrorDto> {
    state
        .plugin_config_documents
        .read(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Updates a structured configuration document for the public plugin API.
#[tauri::command]
pub async fn plugin_config_update_document(
    input: PluginConfigDocumentUpdateDto,
    state: State<'_, AppState>,
) -> Result<PluginConfigDocumentWriteResultDto, ErrorDto> {
    state
        .plugin_config_documents
        .update(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Launches a command-backed background task for the public plugin API.
#[tauri::command]
pub async fn plugin_task_run_command(
    input: PluginRunCommandDto,
    state: State<'_, AppState>,
) -> Result<PluginTaskDto, ErrorDto> {
    state
        .plugin_command_tasks
        .run_command(input.into())
        .await
        .map(PluginTaskDto::from)
        .map_err(ErrorDto::from)
}

/// Reads one command task status for the public plugin API.
#[tauri::command]
pub async fn plugin_task_get_status(
    input: PluginTaskStatusDto,
    state: State<'_, AppState>,
) -> Result<Option<PluginTaskDto>, ErrorDto> {
    state
        .plugin_command_tasks
        .get_status(input.into())
        .await
        .map(|task| task.map(PluginTaskDto::from))
        .map_err(ErrorDto::from)
}

/// Diagnoses generic external toolchain prerequisites for the public plugin API.
#[tauri::command]
pub async fn plugin_toolchain_diagnose(
    input: PluginToolchainDiagnosticRequestDto,
    state: State<'_, AppState>,
) -> Result<PluginToolchainDiagnosticDto, ErrorDto> {
    state
        .plugin_toolchain_diagnostics
        .diagnose(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Subscribes to stable public plugin events.
#[tauri::command]
pub async fn plugin_events_subscribe(
    input: PluginEventSubscribeDto,
    state: State<'_, AppState>,
) -> Result<PluginEventSubscriptionDto, ErrorDto> {
    state
        .plugin_event_subscriptions
        .subscribe(input.into())
        .await
        .map_err(ErrorDto::from)
}

/// Drains retained public plugin events for one subscription.
#[tauri::command]
pub fn plugin_events_poll(
    input: PluginEventPollDto,
    state: State<'_, AppState>,
) -> Result<PluginEventBatchDto, ErrorDto> {
    state
        .plugin_event_subscriptions
        .poll(input.into())
        .map_err(ErrorDto::from)
}

/// Disposes a public plugin event subscription.
#[tauri::command]
pub fn plugin_events_unsubscribe(
    input: PluginEventUnsubscribeDto,
    state: State<'_, AppState>,
) -> PluginEventSubscriptionDto {
    state.plugin_event_subscriptions.unsubscribe(input.into())
}

/// Opens the plugin store folder, or one plugin folder when an id is provided.
#[tauri::command]
pub fn plugin_open_plugins_folder(
    plugin_id: Option<String>,
    app: AppHandle,
) -> Result<(), ErrorDto> {
    let mut path = app
        .path()
        .app_data_dir()
        .map_err(|e| ErrorDto::invalid(e.to_string()))?
        .join("plugins");
    if let Some(id) = plugin_id {
        let id = PluginId::new(id).map_err(|e| ErrorDto::invalid(e.to_string()))?;
        path = path.join("installed").join(id.as_str());
    }
    open_folder(&path)
}

/// Serves `idea-plugin://<pluginId>/<version>/<contentHash>/<relativePath>` after
/// checking registry state, hash and root confinement.
pub fn plugin_asset_protocol(
    app: &AppHandle,
    request: http::Request<Vec<u8>>,
) -> Response<Vec<u8>> {
    let uri = request.uri().to_string();
    match plugin_asset_response(app, request) {
        Ok(response) => {
            application::diag!(
                "[plugins] asset ok uri={} status={} bytes={}",
                uri,
                response.status(),
                response.body().len()
            );
            response
        }
        Err((status, message)) => {
            application::diag!(
                "[plugins] asset failed uri={uri} status={status} message={message}"
            );
            plugin_asset_response_builder(status)
                .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
                .body(message.into_bytes())
                .unwrap_or_else(|e| {
                    application::diag!("[plugins] asset error response build failed: {e}");
                    Response::new(Vec::new())
                })
        }
    }
}

fn plugin_asset_response(
    app: &AppHandle,
    request: http::Request<Vec<u8>>,
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
    let state = app.state::<AppState>();
    let app_data = app
        .path()
        .app_data_dir()
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    plugin_asset_response_with_stores(
        &app_data,
        request,
        state.plugin_registry_store.as_ref(),
        state.plugin_package_store.as_ref(),
        state.plugin_manifest_validator.as_ref(),
    )
}

fn plugin_asset_response_with_stores(
    app_data: &Path,
    request: http::Request<Vec<u8>>,
    registry_store: &dyn PluginRegistryStore,
    package_store: &dyn PluginPackageStore,
    validator: &dyn PluginManifestValidator,
) -> Result<Response<Vec<u8>>, (StatusCode, String)> {
    let uri = request.uri();
    let plugin_id = uri
        .host()
        .ok_or_else(|| (StatusCode::BAD_REQUEST, "missing plugin id".to_owned()))
        .and_then(|raw| {
            PluginId::new(raw.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
        })?;
    let mut parts = uri.path().trim_start_matches('/').splitn(3, '/');
    let version_segment = parts.next().unwrap_or_default();
    let hash = parts.next().unwrap_or_default();
    let rel = parts.next().unwrap_or_default();
    if version_segment.is_empty() || hash.is_empty() {
        return Err((
            StatusCode::BAD_REQUEST,
            "invalid plugin asset URL".to_owned(),
        ));
    }
    let rel =
        RelativePath::new(rel.to_owned()).map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
    let allowed = block_on_protocol_future(asset_allowed(
        &plugin_id,
        hash,
        &rel,
        registry_store,
        package_store,
        validator,
    ))?;
    if !allowed {
        return Err((
            StatusCode::FORBIDDEN,
            "plugin asset is not active".to_owned(),
        ));
    }
    let root = app_data
        .join("plugins")
        .join("installed")
        .join(plugin_id.as_str());
    let target = root.join(rel.as_str());
    let root = root
        .canonicalize()
        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
    let target = target
        .canonicalize()
        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
    if !target.starts_with(&root) {
        return Err((
            StatusCode::FORBIDDEN,
            "asset escapes plugin root".to_owned(),
        ));
    }
    let bytes = std::fs::read(&target).map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
    plugin_asset_response_builder(StatusCode::OK)
        .header(header::CONTENT_TYPE, content_type(&target))
        .body(bytes)
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}

fn plugin_asset_response_builder(status: StatusCode) -> http::response::Builder {
    Response::builder()
        .status(status)
        .header(
            header::ACCESS_CONTROL_ALLOW_ORIGIN,
            PLUGIN_ASSET_CORS_ORIGIN,
        )
        .header(
            header::ACCESS_CONTROL_ALLOW_METHODS,
            PLUGIN_ASSET_CORS_METHODS,
        )
}

async fn asset_allowed(
    plugin_id: &PluginId,
    hash: &str,
    _rel: &RelativePath,
    registry_store: &dyn PluginRegistryStore,
    package_store: &dyn PluginPackageStore,
    validator: &dyn PluginManifestValidator,
) -> Result<bool, (StatusCode, String)> {
    let registry = registry_store
        .load_registry()
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    let Some(entry) = registry.find(plugin_id) else {
        return Ok(false);
    };
    if !entry.lifecycle_state.is_runtime_active() || entry.content_hash.as_str() != hash {
        return Ok(false);
    }
    let package = domain::PluginPackageRef {
        plugin_id: Some(plugin_id.clone()),
        root: plugin_id.as_str().to_owned(),
    };
    let manifest_bytes = package_store
        .read_manifest(&package)
        .await
        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;
    validator
        .validate(&manifest_bytes.bytes, &package)
        .map_err(|e| (StatusCode::FORBIDDEN, e.to_string()))?;
    Ok(true)
}

fn block_on_protocol_future<F: Future>(future: F) -> F::Output {
    match tokio::runtime::Handle::try_current() {
        Ok(handle) => tokio::task::block_in_place(|| handle.block_on(future)),
        Err(_) => tauri::async_runtime::block_on(future),
    }
}

fn content_type(path: &Path) -> &'static str {
    match path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or_default()
    {
        "js" | "mjs" => "text/javascript; charset=utf-8",
        "json" => "application/json; charset=utf-8",
        "svg" => "image/svg+xml",
        "png" => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "webp" => "image/webp",
        "css" => "text/css; charset=utf-8",
        _ => "application/octet-stream",
    }
}

fn open_folder(path: &PathBuf) -> Result<(), ErrorDto> {
    let mut command = if cfg!(target_os = "macos") {
        let mut c = std::process::Command::new("open");
        c.arg(path);
        c
    } else if cfg!(target_os = "windows") {
        let mut c = std::process::Command::new("cmd");
        c.args(["/C", "start", ""]).arg(path);
        c
    } else {
        let mut c = std::process::Command::new("xdg-open");
        c.arg(path);
        c
    };
    command
        .spawn()
        .map(|_| ())
        .map_err(|e| ErrorDto::invalid(e.to_string()))
}

#[cfg(test)]
mod tests {
    use super::{
        asset_allowed, block_on_protocol_future, plugin_asset_response_with_stores,
        PLUGIN_ASSET_CORS_METHODS, PLUGIN_ASSET_CORS_ORIGIN,
    };
    use std::sync::Mutex;
    use std::time::{SystemTime, UNIX_EPOCH};

    use async_trait::async_trait;
    use domain::ports::{
        PluginManifestBytes, PluginManifestError, PluginPackageStore, PluginRegistryError,
        PluginRegistryStore, PluginStoreError,
    };
    use domain::{
        ContentHash, LocalPath, PluginContributionSet, PluginId, PluginInstallSource,
        PluginLifecycleState, PluginManifest, PluginRegistry, PluginRegistryEntry,
        PluginTrustLevel, PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage,
    };
    use http::{header, StatusCode};

    struct FakeRegistry {
        registry: Mutex<PluginRegistry>,
    }

    #[async_trait]
    impl PluginRegistryStore for FakeRegistry {
        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(())
        }
    }

    struct FakePackages {
        manifest: Vec<u8>,
    }

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

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

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

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

        async fn commit_install(
            &self,
            _staged: StagedPluginPackage,
            _plugin_id: &PluginId,
        ) -> Result<domain::PluginPackageRef, PluginStoreError> {
            Err(PluginStoreError::Invalid("not used".to_owned()))
        }

        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<domain::PluginBundleUrl, PluginStoreError> {
            Ok(domain::PluginBundleUrl::new(format!(
                "idea-plugin://{}/current/{}/{}",
                plugin_id.as_str(),
                hash.as_str(),
                entry.as_str()
            )))
        }
    }

    struct RejectingValidator;

    impl domain::ports::PluginManifestValidator for RejectingValidator {
        fn validate(
            &self,
            _bytes: &[u8],
            _package: &domain::PluginPackageRef,
        ) -> Result<domain::PluginManifest, PluginManifestError> {
            Err(PluginManifestError::Invalid("broken manifest".to_owned()))
        }
    }

    struct AcceptingValidator {
        plugin_id: PluginId,
        main: RelativePath,
    }

    impl domain::ports::PluginManifestValidator for AcceptingValidator {
        fn validate(
            &self,
            _bytes: &[u8],
            _package: &domain::PluginPackageRef,
        ) -> Result<PluginManifest, PluginManifestError> {
            Ok(PluginManifest {
                idea_plugin_manifest_version: 1,
                id: self.plugin_id.clone(),
                display_name: "Git Graph".to_owned(),
                publisher: None,
                version: PluginVersion::new("1.0.0").unwrap(),
                description: None,
                engine_idea: None,
                main: self.main.clone(),
                icon: None,
                trust_level: PluginTrustLevel::Full,
                capabilities: Vec::new(),
                activation_scope: domain::PluginActivationScope::default(),
                contributes: PluginContributionSet::default(),
            })
        }
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn protocol_future_can_be_waited_inside_tauri_runtime() {
        let value = block_on_protocol_future(async { 42 });

        assert_eq!(value, 42);
    }

    #[tokio::test]
    async fn asset_allowed_confines_invalid_active_manifest_to_forbidden() {
        let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
        let hash = ContentHash::new("abc123").unwrap();
        let registry = FakeRegistry {
            registry: Mutex::new(PluginRegistry {
                version: 1,
                plugins: vec![PluginRegistryEntry {
                    id: plugin_id.clone(),
                    lifecycle_state: PluginLifecycleState::Enabled,
                    source: PluginInstallSource::Directory {
                        path_label: "/source/plugin".to_owned(),
                    },
                    content_hash: hash.clone(),
                    restart_required: false,
                    error: None,
                }],
            }),
        };
        let packages = FakePackages {
            manifest: br#"{"broken":true}"#.to_vec(),
        };
        let rel = RelativePath::new("dist/index.js").unwrap();

        let err = asset_allowed(
            &plugin_id,
            hash.as_str(),
            &rel,
            &registry,
            &packages,
            &RejectingValidator,
        )
        .await
        .unwrap_err();

        assert_eq!(err.0, StatusCode::FORBIDDEN);
        assert!(err.1.contains("broken manifest"));
    }

    #[test]
    fn plugin_asset_response_serves_confined_file_not_declared_in_manifest() {
        let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
        let hash = ContentHash::new("abc123").unwrap();
        let registry = FakeRegistry {
            registry: Mutex::new(PluginRegistry {
                version: 1,
                plugins: vec![PluginRegistryEntry {
                    id: plugin_id.clone(),
                    lifecycle_state: PluginLifecycleState::Enabled,
                    source: PluginInstallSource::Directory {
                        path_label: "/source/plugin".to_owned(),
                    },
                    content_hash: hash.clone(),
                    restart_required: false,
                    error: None,
                }],
            }),
        };
        let packages = FakePackages {
            manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
        };
        let validator = AcceptingValidator {
            plugin_id: plugin_id.clone(),
            main: RelativePath::new("dist/index.js").unwrap(),
        };
        let app_data = test_app_data_dir("plugin-asset-undeclared");
        let rel = RelativePath::new("dist/constants.js").unwrap();
        let target = app_data
            .join("plugins")
            .join("installed")
            .join(plugin_id.as_str())
            .join(rel.as_str());
        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
        std::fs::write(&target, "export const answer = 42;").unwrap();
        let request = http::Request::builder()
            .uri(format!(
                "idea-plugin://{}/current/{}/{}",
                plugin_id.as_str(),
                hash.as_str(),
                rel.as_str()
            ))
            .body(Vec::new())
            .unwrap();

        let response =
            plugin_asset_response_with_stores(&app_data, request, &registry, &packages, &validator)
                .unwrap();

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(response.body(), b"export const answer = 42;");

        std::fs::remove_dir_all(app_data).ok();
    }

    #[test]
    fn plugin_asset_response_rejects_invalid_hash_or_inactive_plugin() {
        let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
        let hash = ContentHash::new("abc123").unwrap();
        let rel = RelativePath::new("dist/constants.js").unwrap();
        let registry = FakeRegistry {
            registry: Mutex::new(PluginRegistry {
                version: 1,
                plugins: vec![PluginRegistryEntry {
                    id: plugin_id.clone(),
                    lifecycle_state: PluginLifecycleState::Enabled,
                    source: PluginInstallSource::Directory {
                        path_label: "/source/plugin".to_owned(),
                    },
                    content_hash: hash.clone(),
                    restart_required: false,
                    error: None,
                }],
            }),
        };
        let packages = FakePackages {
            manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
        };
        let validator = AcceptingValidator {
            plugin_id: plugin_id.clone(),
            main: RelativePath::new("dist/index.js").unwrap(),
        };
        let app_data = test_app_data_dir("plugin-asset-rejected");
        let target = app_data
            .join("plugins")
            .join("installed")
            .join(plugin_id.as_str())
            .join(rel.as_str());
        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
        std::fs::write(&target, "export const answer = 42;").unwrap();
        let invalid_hash_request = http::Request::builder()
            .uri(format!(
                "idea-plugin://{}/current/deadbeef/{}",
                plugin_id.as_str(),
                rel.as_str()
            ))
            .body(Vec::new())
            .unwrap();

        let invalid_hash_err = plugin_asset_response_with_stores(
            &app_data,
            invalid_hash_request,
            &registry,
            &packages,
            &validator,
        )
        .unwrap_err();

        assert_eq!(invalid_hash_err.0, StatusCode::FORBIDDEN);

        registry.registry.lock().unwrap().plugins[0].lifecycle_state =
            PluginLifecycleState::Disabled;
        let inactive_request = http::Request::builder()
            .uri(format!(
                "idea-plugin://{}/current/{}/{}",
                plugin_id.as_str(),
                hash.as_str(),
                rel.as_str()
            ))
            .body(Vec::new())
            .unwrap();

        let inactive_err = plugin_asset_response_with_stores(
            &app_data,
            inactive_request,
            &registry,
            &packages,
            &validator,
        )
        .unwrap_err();

        assert_eq!(inactive_err.0, StatusCode::FORBIDDEN);

        std::fs::remove_dir_all(app_data).ok();
    }

    #[test]
    fn plugin_asset_response_rejects_symlink_path_traversal() {
        let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
        let hash = ContentHash::new("abc123").unwrap();
        let rel = RelativePath::new("assets/leak.txt").unwrap();
        let registry = FakeRegistry {
            registry: Mutex::new(PluginRegistry {
                version: 1,
                plugins: vec![PluginRegistryEntry {
                    id: plugin_id.clone(),
                    lifecycle_state: PluginLifecycleState::Enabled,
                    source: PluginInstallSource::Directory {
                        path_label: "/source/plugin".to_owned(),
                    },
                    content_hash: hash.clone(),
                    restart_required: false,
                    error: None,
                }],
            }),
        };
        let packages = FakePackages {
            manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
        };
        let validator = AcceptingValidator {
            plugin_id: plugin_id.clone(),
            main: RelativePath::new("dist/index.js").unwrap(),
        };
        let app_data = test_app_data_dir("plugin-asset-traversal");
        let plugin_root = app_data
            .join("plugins")
            .join("installed")
            .join(plugin_id.as_str());
        let assets = plugin_root.join("assets");
        std::fs::create_dir_all(&assets).unwrap();
        let outside = app_data.join("outside.txt");
        std::fs::write(&outside, "secret").unwrap();
        #[cfg(unix)]
        std::os::unix::fs::symlink(&outside, assets.join("leak.txt")).unwrap();
        #[cfg(windows)]
        std::os::windows::fs::symlink_file(&outside, assets.join("leak.txt")).unwrap();
        let request = http::Request::builder()
            .uri(format!(
                "idea-plugin://{}/current/{}/{}",
                plugin_id.as_str(),
                hash.as_str(),
                rel.as_str()
            ))
            .body(Vec::new())
            .unwrap();

        let err =
            plugin_asset_response_with_stores(&app_data, request, &registry, &packages, &validator)
                .unwrap_err();

        assert_eq!(err.0, StatusCode::FORBIDDEN);
        assert!(err.1.contains("escapes plugin root"));

        std::fs::remove_dir_all(app_data).ok();
    }

    #[test]
    fn plugin_asset_response_includes_cors_headers_for_dynamic_import() {
        let plugin_id = PluginId::new("dev.acme.gitgraph").unwrap();
        let hash = ContentHash::new("abc123").unwrap();
        let rel = RelativePath::new("dist/index.js").unwrap();
        let registry = FakeRegistry {
            registry: Mutex::new(PluginRegistry {
                version: 1,
                plugins: vec![PluginRegistryEntry {
                    id: plugin_id.clone(),
                    lifecycle_state: PluginLifecycleState::Enabled,
                    source: PluginInstallSource::Directory {
                        path_label: "/source/plugin".to_owned(),
                    },
                    content_hash: hash.clone(),
                    restart_required: false,
                    error: None,
                }],
            }),
        };
        let packages = FakePackages {
            manifest: br#"{"ideaPluginManifestVersion":1}"#.to_vec(),
        };
        let validator = AcceptingValidator {
            plugin_id: plugin_id.clone(),
            main: rel.clone(),
        };
        let app_data = test_app_data_dir("plugin-asset-cors");
        let target = app_data
            .join("plugins")
            .join("installed")
            .join(plugin_id.as_str())
            .join(rel.as_str());
        std::fs::create_dir_all(target.parent().unwrap()).unwrap();
        std::fs::write(&target, "export default 42;").unwrap();
        let request = http::Request::builder()
            .uri(format!(
                "idea-plugin://{}/current/{}/{}",
                plugin_id.as_str(),
                hash.as_str(),
                rel.as_str()
            ))
            .body(Vec::new())
            .unwrap();

        let response =
            plugin_asset_response_with_stores(&app_data, request, &registry, &packages, &validator)
                .unwrap();

        assert_eq!(response.status(), StatusCode::OK);
        assert_eq!(
            response.headers().get(header::ACCESS_CONTROL_ALLOW_ORIGIN),
            Some(&http::HeaderValue::from_static(PLUGIN_ASSET_CORS_ORIGIN))
        );
        assert_eq!(
            response.headers().get(header::ACCESS_CONTROL_ALLOW_METHODS),
            Some(&http::HeaderValue::from_static(PLUGIN_ASSET_CORS_METHODS))
        );
        assert_eq!(
            response.headers().get(header::CONTENT_TYPE),
            Some(&http::HeaderValue::from_static(
                "text/javascript; charset=utf-8"
            ))
        );

        std::fs::remove_dir_all(app_data).ok();
    }

    fn test_app_data_dir(label: &str) -> std::path::PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!("idea-{label}-{}-{nanos}", std::process::id()))
    }
}
